From fa3bc601b7bfb69f81af21d11d22f67f8cba9612 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:50:54 +0000 Subject: [PATCH 1/3] Export videos for named platforms and check each file against its limits Exports only knew aspect ratios, so a cut could still be rejected or degraded on upload: too long for the platform, over its size limit, or far off its loudness. export_video now takes platforms (TikTok, Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels, LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the presets. Each platform is rendered to its frame with the export's fit, its frame rate cap, a bitrate cap that also keeps the file under the size limit, and its loudness target in one loudnorm pass. Every render is probed and measured afterwards. A file over the size limit is encoded once more at a bitrate scaled from what it averaged, and audio more than 2 LU off the target gets one linear loudness pass. The result is stored on the output as a pass, warn or fail check with language-neutral issue codes. A platform the video is too long or too short for is not rendered: the card lists it with the reason, the other targets still render, and the bot is told to trim first. The limits come from the earlier profile research, with its sources, review date and unverified flag; content, disclosure and caption rules are left out because they need inference or a review flow. Preset definitions move to their own module so the platform schemas can use their aspect ratios without an import cycle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapter-kit/src/media.ts | 7 + packages/adapters/src/builtin-tools.ts | 8 +- packages/adapters/src/media-runtime.ts | 47 ++- packages/adapters/src/media/ffmpeg.test.ts | 71 +++++ packages/adapters/src/media/ffmpeg.ts | 93 ++++++ .../src/media/image-operations.test.ts | 1 + .../adapters/src/media/media-test-support.ts | 12 +- .../src/media/platform-export.test.ts | 188 ++++++++++++ .../adapters/src/media/platform-export.ts | 221 ++++++++++++++ .../adapters/src/media/transcription.test.ts | 1 + .../src/media/video-edit-plan.test.ts | 84 +++++- .../adapters/src/media/video-edit-plan.ts | 81 ++++- .../media/video-operations.postgres.test.ts | 57 ++++ .../src/media/video-operations.test.ts | 95 +++++- .../adapters/src/media/video-operations.ts | 234 +++++++++++++-- packages/contracts/src/index.ts | 2 + packages/contracts/src/media-generation.ts | 2 +- .../contracts/src/media-platforms.test.ts | 69 +++++ packages/contracts/src/media-platforms.ts | 282 ++++++++++++++++++ packages/contracts/src/media-presets.ts | 28 ++ packages/contracts/src/media-video.test.ts | 29 +- packages/contracts/src/media-video.ts | 30 +- packages/contracts/src/media.ts | 38 +-- 23 files changed, 1597 insertions(+), 83 deletions(-) create mode 100644 packages/adapters/src/media/platform-export.test.ts create mode 100644 packages/adapters/src/media/platform-export.ts create mode 100644 packages/contracts/src/media-platforms.test.ts create mode 100644 packages/contracts/src/media-platforms.ts create mode 100644 packages/contracts/src/media-presets.ts diff --git a/packages/adapter-kit/src/media.ts b/packages/adapter-kit/src/media.ts index c9610ea..9a4ea34 100644 --- a/packages/adapter-kit/src/media.ts +++ b/packages/adapter-kit/src/media.ts @@ -1,9 +1,12 @@ import type { + MediaCheck, MediaJobStatus, MediaOperation, MediaOutput, MediaOutputKind, + MediaPlatformId, MediaPresetId, + MediaSkippedTarget, } from "@rakazo/contracts"; import type { AdapterDescriptor } from "./types.js"; @@ -137,6 +140,8 @@ export interface MediaJobOutputFile { posterPath?: string; parentArtifactId?: string; preset?: MediaPresetId; + platform?: MediaPlatformId; + check?: MediaCheck; } /** An artifact the job may read, copied into the job's temp directory. */ @@ -168,6 +173,8 @@ export interface MediaJobContext { fetchArtifact(artifactId: string): Promise; /** Stores a produced file as an artifact and adds it to the media card. */ addOutput(file: MediaJobOutputFile): Promise; + /** Shows a platform the job did not render on the media card, with the reason. */ + addSkipped(target: MediaSkippedTarget): Promise; } export interface MediaOperationResult { diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index fb87c63..ed2e05c 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -7,6 +7,7 @@ import { ExportVideoInputSchema, GenerateImageInputSchema, GenerateVideoInputSchema, + MEDIA_PLATFORM_PROFILES, MODEL_TIERS, SecretAskPurpose, SecretHttpRequest, @@ -34,6 +35,11 @@ export const SUBAGENT_MODEL_TIER_DESCRIPTION = const VIDEO_PRESETS_AND_FITS = "Presets: vertical 9:16, landscape 16:9, square 1:1, portrait 4:5, classic 4:3, cinema 21:9. fit: crop (default) fills the frame and cuts the edges, pad adds bars, blur fills the bars with a blurred copy."; +/** "tiktok 600 s, …": each platform id with the longest video it takes. */ +const VIDEO_PLATFORMS = Object.values(MEDIA_PLATFORM_PROFILES) + .map((profile) => `${profile.id} ${profile.duration.hardMaxMs / 1000} s`) + .join(", "); + export const DELEGATION_TOOL_NAMES = new Set([ "run_subagent", "spawn_bot", @@ -564,7 +570,7 @@ export const builtinAgentTools: ConnectorTool[] = [ }, { name: "export_video", - description: `Export a video artifact as one H.264/AAC MP4 per preset. ${VIDEO_PRESETS_AND_FITS} For cuts, speed, audio or subtitles use edit_video.`, + description: `Export a video artifact as one H.264/AAC MP4 per platform and per preset, 6 at most. When the user names where it will be posted, use platforms: each file is fitted to that platform's frame, frame rate, bitrate and loudness, then checked. Platforms (longest video): ${VIDEO_PLATFORMS}. A platform the video is too long or too short for is skipped: trim with edit_video first. ${VIDEO_PRESETS_AND_FITS} For cuts, speed, audio or subtitles use edit_video.`, inputSchema: mediaParameters(ExportVideoInputSchema), }, { diff --git a/packages/adapters/src/media-runtime.ts b/packages/adapters/src/media-runtime.ts index d699a40..4817154 100644 --- a/packages/adapters/src/media-runtime.ts +++ b/packages/adapters/src/media-runtime.ts @@ -32,12 +32,14 @@ import type { MediaJobStatus, MediaOperation, MediaOutput, + MediaSkippedTarget, MessageBlock, } from "@rakazo/contracts"; import { attachmentKindOf, MEDIA_JOB_ACTIVE_STATUSES, MediaOperationSchema, + MediaSkippedTargetSchema, } from "@rakazo/contracts"; import { attachmentExtensionForMimeType, inferAttachmentMimeType } from "@rakazo/core"; import type { MediaJob, PrismaClient, ThreadEvents } from "@rakazo/db"; @@ -160,7 +162,17 @@ function issuesText(error: z.ZodError): string { .join("; "); } -function cardBlock(job: MediaJob, outputs: MediaOutput[]): MediaJobBlock { +/** What a write adds to the card: stored outputs and platforms the job did not render. */ +interface CardAdditions { + outputs?: MediaOutput[]; + skipped?: MediaSkippedTarget[]; +} + +function cardBlock( + job: MediaJob, + outputs: MediaOutput[], + skipped: MediaSkippedTarget[] = [], +): MediaJobBlock { return { kind: "media", jobId: job.id, @@ -170,6 +182,7 @@ function cardBlock(job: MediaJob, outputs: MediaOutput[]): MediaJobBlock { ...(job.progress === null ? {} : { progress: job.progress }), ...(job.error === null ? {} : { error: job.error }), outputs, + ...(skipped.length ? { skipped } : {}), }; } @@ -346,8 +359,9 @@ export function createMediaRuntime( const writeCard = async ( tx: Prisma.TransactionClient, job: MediaJob, - appended: MediaOutput[] = [], + added: CardAdditions = {}, ): Promise<{ exists: boolean; seq?: number; outputs: MediaOutput[] }> => { + const appended = added.outputs ?? []; if (!job.messageId) return { exists: false, outputs: appended }; const message = await tx.message.findFirst({ where: { id: job.messageId, threadId: job.threadId }, @@ -356,7 +370,8 @@ export function createMediaRuntime( if (!message) return { exists: false, outputs: appended }; const previous = mediaBlockOf(message.blocks, job.id); const outputs = [...(previous?.outputs ?? []), ...appended]; - const next = cardBlock(job, outputs); + const skipped = [...(previous?.skipped ?? []), ...(added.skipped ?? [])]; + const next = cardBlock(job, outputs, skipped); const current = Array.isArray(message.blocks) ? (message.blocks as MessageBlock[]) : []; const blocks = previous ? current.map((block) => (block === previous ? next : block)) @@ -386,7 +401,7 @@ export function createMediaRuntime( jobId: string, token: string | null, data: Prisma.MediaJobUpdateManyMutationInput, - appended: MediaOutput[] = [], + added: CardAdditions = {}, ) => { const committed = await withTransactionRetry(() => deps.prisma.$transaction(async (tx) => { @@ -408,7 +423,7 @@ export function createMediaRuntime( const updated = await tx.mediaJob.updateMany({ where: fence, data }); if (!updated.count) return null; const job = await tx.mediaJob.findUniqueOrThrow({ where: { id: jobId } }); - const card = await writeCard(tx, job, appended); + const card = await writeCard(tx, job, added); return { job, card }; }), ); @@ -801,10 +816,15 @@ export function createMediaRuntime( ...(posterArtifactId ? { posterArtifactId } : {}), ...(parent ? { parentArtifactId: parent.id } : {}), ...(file.preset ? { preset: file.preset } : {}), + ...(file.platform ? { platform: file.platform } : {}), + ...(file.check ? { check: file.check } : {}), }; - const written = await updateJob(jobId, token, { outputArtifactIds: { push: stored } }, [ - output, - ]); + const written = await updateJob( + jobId, + token, + { outputArtifactIds: { push: stored } }, + { outputs: [output] }, + ); if (!written) { abort("lease"); throw new Error("Media job lost its lease"); @@ -816,6 +836,17 @@ export function createMediaRuntime( throw error; } }, + addSkipped: async (target) => { + signal.throwIfAborted(); + const written = await updateJob( + jobId, + token, + { updatedAt: new Date() }, + { skipped: [MediaSkippedTargetSchema.parse(target)] }, + ); + if (!written) abort("lease"); + else if (!written.card.exists) abort("abandoned"); + }, }; try { diff --git a/packages/adapters/src/media/ffmpeg.test.ts b/packages/adapters/src/media/ffmpeg.test.ts index e431827..5cde9ff 100644 --- a/packages/adapters/src/media/ffmpeg.test.ts +++ b/packages/adapters/src/media/ffmpeg.test.ts @@ -10,7 +10,9 @@ import { FfmpegError, ffmpegInputArgs, ffmpegSeconds, + measureLoudness, packetDurationMs, + parseLoudnormOutput, parseProbeOutput, probeMedia, renderPoster, @@ -18,6 +20,7 @@ import { } from "./ffmpeg.js"; import type { TestClips } from "./media-test-support.js"; import { createTestClips } from "./media-test-support.js"; +import { planLoudnessFix } from "./video-edit-plan.js"; const ffmpeg = await detectFfmpeg(); @@ -73,6 +76,18 @@ describe("probe parsing", () => { }); }); + it("reads the stream's average bitrate when the container records it", () => { + const probe = parseProbeOutput( + JSON.stringify({ + format: { duration: "3" }, + streams: [ + stream({ width: 1080, height: 1920, avg_frame_rate: "30/1", bit_rate: "522994" }), + ], + }), + ); + expect(probe.video?.bitRateKbps).toBe(523); + }); + it("reads legacy rotate tags, skips cover art and falls back to stream durations", () => { const probe = parseProbeOutput( JSON.stringify({ @@ -108,6 +123,40 @@ describe("probe parsing", () => { }); }); +describe("loudness parsing", () => { + it("reads loudnorm's JSON block from the end of the log", () => { + const log = [ + "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'file:out.mp4':", + "[Parsed_loudnorm_0 @ 0x5555] ", + "{", + '\t"input_i" : "-17.42",', + '\t"input_tp" : "-3.10",', + '\t"input_lra" : "5.20",', + '\t"input_thresh" : "-27.71",', + '\t"output_i" : "-14.02",', + '\t"normalization_type" : "dynamic",', + '\t"target_offset" : "0.02"', + "}", + ].join("\n"); + expect(parseLoudnormOutput(log)).toEqual({ + integratedLufs: -17.42, + truePeakDb: -3.1, + rangeLu: 5.2, + thresholdLufs: -27.71, + offsetLu: 0.02, + }); + }); + + it("reads digital silence as minus infinity and refuses a log without numbers", () => { + const silent = parseLoudnormOutput( + '{"input_i":"-inf","input_tp":"-inf","input_lra":"0.00","input_thresh":"-70.00","target_offset":"inf"}', + ); + expect(silent?.integratedLufs).toBe(Number.NEGATIVE_INFINITY); + expect(parseLoudnormOutput("Conversion failed!")).toBeNull(); + expect(parseLoudnormOutput('{"input_i":"-14.0"}')).toBeNull(); + }); +}); + describe("ffmpeg arguments", () => { it("opens inputs through the file protocol with the demuxer of their MIME type", () => { expect( @@ -151,6 +200,28 @@ describe.skipIf(!ffmpeg)("ffmpeg integration", () => { expect(silent.video).toMatchObject({ width: 180, height: 320 }); }); + it("measures loudness and brings it to the target with a linear second pass", async () => { + const target = { integratedLufs: -14, truePeakDb: -1 }; + const before = await measureLoudness(clips.landscape, target, { mimeType: "video/mp4" }); + // The generated tone sits well below the streaming target. + expect(before.integratedLufs).toBeLessThan(-18); + const fixed = join(clips.dir, "loudness-fixed.mp4"); + await runFfmpeg( + planLoudnessFix({ + inputPath: clips.landscape, + outputPath: fixed, + durationMs: 3000, + target, + measured: before, + }), + ); + const after = await measureLoudness(fixed, target, { mimeType: "video/mp4" }); + expect(Math.abs(after.integratedLufs - target.integratedLufs)).toBeLessThan(1); + const probe = await probeMedia(fixed, { mimeType: "video/mp4" }); + expect(probe.video).toMatchObject({ codec: "h264", width: 320, height: 180 }); + expect(Math.abs(probe.durationMs - 3000)).toBeLessThan(50); + }); + it("refuses a playlist disguised as an MP4 instead of following its URLs", async () => { const playlist = join(clips.dir, "playlist.mp4"); await writeFile( diff --git a/packages/adapters/src/media/ffmpeg.ts b/packages/adapters/src/media/ffmpeg.ts index dba7930..eb72491 100644 --- a/packages/adapters/src/media/ffmpeg.ts +++ b/packages/adapters/src/media/ffmpeg.ts @@ -27,6 +27,8 @@ export interface MediaProbe { fps: number; /** Pixel aspect ratio of the displayed frame; 1 for square pixels. */ sampleAspectRatio: number; + /** Average bitrate of the stream when the container records it. */ + bitRateKbps?: number; }; audio?: { codec: string; sampleRate: number; channels: number }; hasAudio: boolean; @@ -210,6 +212,7 @@ interface ProbeStream { sample_rate?: string; channels?: number; duration?: string; + bit_rate?: string; disposition?: { attached_pic?: number }; tags?: { rotate?: string }; side_data_list?: { rotation?: number }[]; @@ -256,6 +259,7 @@ function probeVideo(stream: ProbeStream, width: number, height: number) { const sideways = rotation === 90 || rotation === 270; const sar = ratio(stream.sample_aspect_ratio, ":") || 1; const fps = ratio(stream.avg_frame_rate, "/") || ratio(stream.r_frame_rate, "/"); + const bitRate = positive(Number(stream.bit_rate)); return { codec: stream.codec_name ?? "", width: sideways ? height : width, @@ -263,6 +267,7 @@ function probeVideo(stream: ProbeStream, width: number, height: number) { rotation, fps: fps > 0 && fps <= MAX_PLAUSIBLE_FPS ? fps : 0, sampleAspectRatio: sideways ? 1 / sar : sar, + ...(bitRate ? { bitRateKbps: Math.round(bitRate / 1000) } : {}), }; } @@ -496,6 +501,94 @@ export async function extractAudio( ); } +/** EBU R128 values of a file's first audio track, as ffmpeg's loudnorm filter measures them. */ +export interface LoudnessMeasurement { + /** Integrated loudness in LUFS; -Infinity for digital silence. */ + integratedLufs: number; + truePeakDb: number; + rangeLu: number; + thresholdLufs: number; + /** Gain loudnorm's second pass adds after limiting, for the targets it measured against. */ + offsetLu: number; +} + +const LOUDNESS_TIMEOUT_MS = 10 * 60_000; + +/** loudnorm's filter string: the target, and the first pass's numbers for a linear second pass. */ +export function loudnormFilter( + target: { integratedLufs: number; truePeakDb: number }, + measured?: LoudnessMeasurement, +): string { + const options = [ + `I=${target.integratedLufs}`, + `TP=${target.truePeakDb}`, + // A wide loudness range keeps loudnorm linear (no pumping) for most speech and music. + "LRA=11", + ...(measured + ? [ + `measured_I=${measured.integratedLufs}`, + `measured_TP=${measured.truePeakDb}`, + `measured_LRA=${measured.rangeLu}`, + `measured_thresh=${measured.thresholdLufs}`, + `offset=${measured.offsetLu}`, + "linear=true", + ] + : []), + ]; + return `loudnorm=${options.join(":")}`; +} + +/** Measures the loudness of a file with an audio track against a target. */ +export async function measureLoudness( + input: string, + target: { integratedLufs: number; truePeakDb: number }, + options: { signal?: AbortSignal; mimeType?: string } = {}, +): Promise { + const { ffmpegPath } = await requireFfmpeg(); + const result = await capture( + ffmpegPath, + [ + ...["-hide_banner", "-nostdin", "-nostats", "-loglevel", "info"], + ...ffmpegInputArgs(input, { mimeType: options.mimeType }), + ...["-map", "0:a:0", "-af", `${loudnormFilter(target)}:print_format=json`], + ...["-f", "null", "-"], + ], + { signal: options.signal, timeoutMs: LOUDNESS_TIMEOUT_MS }, + ); + if (result.code !== 0) throw ffmpegFailure("ffmpeg", result.code, result.stderr); + const measured = parseLoudnormOutput(result.stderr); + if (!measured) throw new Error("ffmpeg did not report the loudness."); + return measured; +} + +/** Reads the JSON block loudnorm prints with print_format=json at the end of its log. */ +export function parseLoudnormOutput(log: string): LoudnessMeasurement | null { + const start = log.lastIndexOf("{"); + const end = log.lastIndexOf("}"); + if (start < 0 || end < start) return null; + let data: Record; + try { + data = JSON.parse(log.slice(start, end + 1)) as Record; + } catch { + return null; + } + const value = (key: string) => { + const text = data[key]?.trim(); + // Digital silence measures as -inf, and its offset as inf. + if (text === "-inf") return Number.NEGATIVE_INFINITY; + if (text === "inf") return Number.POSITIVE_INFINITY; + return text ? Number(text) : Number.NaN; + }; + const measured = { + integratedLufs: value("input_i"), + truePeakDb: value("input_tp"), + rangeLu: value("input_lra"), + thresholdLufs: value("input_thresh"), + offsetLu: value("target_offset"), + }; + return Object.values(measured).some(Number.isNaN) ? null : measured; +} + /** Writes one JPEG frame at atMs (default 0), falling back to the first frame past the end. */ export async function renderPoster( input: string, diff --git a/packages/adapters/src/media/image-operations.test.ts b/packages/adapters/src/media/image-operations.test.ts index 84da555..88c906d 100644 --- a/packages/adapters/src/media/image-operations.test.ts +++ b/packages/adapters/src/media/image-operations.test.ts @@ -103,6 +103,7 @@ async function run( }; return output; }, + addSkipped: async () => undefined, }; const result = await operation(name).handler.run(context); return { result, outputs, progress }; diff --git a/packages/adapters/src/media/media-test-support.ts b/packages/adapters/src/media/media-test-support.ts index 940419a..f5e06ff 100644 --- a/packages/adapters/src/media/media-test-support.ts +++ b/packages/adapters/src/media/media-test-support.ts @@ -2,7 +2,7 @@ import { copyFile, mkdtemp, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { MediaJobContext, MediaJobInputFile, MediaJobOutputFile } from "@rakazo/adapter-kit"; -import type { MediaOutput, MediaOutputKind } from "@rakazo/contracts"; +import type { MediaOutput, MediaOutputKind, MediaSkippedTarget } from "@rakazo/contracts"; import { attachmentKindOf } from "@rakazo/contracts"; import { runFfmpeg } from "./ffmpeg.js"; @@ -50,12 +50,13 @@ export interface TestArtifact { kind?: MediaOutputKind; } -/** A MediaJobContext over local files that records progress and outputs. */ +/** A MediaJobContext over local files that records progress, outputs and skipped platforms. */ export async function fakeMediaJobContext(input: unknown, artifacts: Record) { const tempDir = await mkdtemp(join(tmpdir(), "evoo-media-job-")); const controller = new AbortController(); const progress: number[] = []; const outputs: (MediaJobOutputFile & { size: number; posterSize?: number })[] = []; + const skipped: MediaSkippedTarget[] = []; const context: MediaJobContext = { jobId: "job-1", spaceId: "space-1", @@ -99,8 +100,13 @@ export async function fakeMediaJobContext(input: unknown, artifacts: Record { + skipped.push(target); + }, }; - return { context, controller, progress, outputs, tempDir }; + return { context, controller, progress, outputs, skipped, tempDir }; } diff --git a/packages/adapters/src/media/platform-export.test.ts b/packages/adapters/src/media/platform-export.test.ts new file mode 100644 index 0000000..630882f --- /dev/null +++ b/packages/adapters/src/media/platform-export.test.ts @@ -0,0 +1,188 @@ +import { MEDIA_PLATFORM_PROFILES } from "@rakazo/contracts"; +import { describe, expect, it } from "vitest"; +import type { ExportMeasurement } from "./platform-export.js"; +import { + BITRATE_TOLERANCE, + checkExport, + DURATION_TOLERANCE_MS, + describeSkipped, + durationIssues, + LOUDNESS_TOLERANCE_LU, + MIN_VIDEO_KBPS, + needsLoudnessFix, + platformTarget, + sizeBudgetKbps, + sizeFixKbps, + TRUE_PEAK_TOLERANCE_DB, +} from "./platform-export.js"; + +const tiktok = MEDIA_PLATFORM_PROFILES.tiktok; + +/** A clean 30 s TikTok render; tests override what they examine. */ +function measured(overrides: Partial = {}): ExportMeasurement { + return { + width: 1080, + height: 1920, + fps: 30, + codec: "h264", + durationMs: 30_000, + sizeBytes: 20_000_000, + videoKbps: 5000, + loudness: { + integratedLufs: -14.2, + truePeakDb: -1.3, + rangeLu: 4, + thresholdLufs: -24.5, + offsetLu: 0.1, + }, + ...overrides, + }; +} + +function loudness(integratedLufs: number, truePeakDb = -2) { + return { integratedLufs, truePeakDb, rangeLu: 4, thresholdLufs: -30, offsetLu: 0 }; +} + +describe("platform targets", () => { + it("renders to the profile and caps the bitrate below its size limit for long videos", () => { + expect(platformTarget(tiktok, "blur", 30_000)).toEqual({ + width: 1080, + height: 1920, + fit: "blur", + maxFps: 30, + maxBitrateKbps: 8000, + integratedLufs: -14, + truePeakDb: -1, + }); + // 287 MB over 10 minutes leaves about 3.6 Mbps of video after headroom and audio. + const tenMinutes = platformTarget(tiktok, "crop", 600_000).maxBitrateKbps; + expect(tenMinutes).toBe(sizeBudgetKbps(tiktok, 600_000)); + expect(tenMinutes).toBeGreaterThan(3000); + expect(tenMinutes).toBeLessThan(3700); + expect(tenMinutes * 600 + 160 * 600).toBeLessThan(287 * 8000); + expect(sizeBudgetKbps(tiktok, 1e9)).toBe(MIN_VIDEO_KBPS); + }); +}); + +describe("duration limits", () => { + it("refuses videos past the limits, with a small tolerance for container rounding", () => { + expect(durationIssues(tiktok, 600_000 + DURATION_TOLERANCE_MS)).toEqual([]); + expect(durationIssues(tiktok, 720_000)).toEqual([ + { code: "too_long", maxMs: 600_000, actualMs: 720_000 }, + ]); + expect(durationIssues(tiktok, 3000 - DURATION_TOLERANCE_MS)).toEqual([]); + expect(durationIssues(tiktok, 2000)).toEqual([ + { code: "too_short", minMs: 3000, actualMs: 2000 }, + ]); + }); + + it("words a skipped platform for the bot with what to do about it", () => { + expect(describeSkipped(tiktok, durationIssues(tiktok, 720_000))).toBe( + "Not exported for TikTok: 720 s is longer than its 600 s limit; trim the video first.", + ); + expect(describeSkipped(tiktok, durationIssues(tiktok, 1500))).toBe( + "Not exported for TikTok: 1.5 s is shorter than its 3 s minimum; use a longer video.", + ); + }); +}); + +describe("platform check", () => { + it("passes a render within every tolerance", () => { + expect( + checkExport( + tiktok, + measured({ + videoKbps: 8000 * (1 + BITRATE_TOLERANCE), + fps: 30.2, + loudness: loudness(-14 - LOUDNESS_TOLERANCE_LU, -1 + TRUE_PEAK_TOLERANCE_DB), + }), + ), + ).toEqual({ status: "pass", issues: [] }); + }); + + it("warns about what the platform would degrade", () => { + expect( + checkExport( + tiktok, + measured({ + width: 720, + height: 1280, + fps: 60, + videoKbps: 9500, + loudness: loudness(-17.26, -0.2), + }), + ), + ).toEqual({ + status: "warn", + issues: [ + { + code: "resolution_mismatch", + width: 1080, + height: 1920, + actualWidth: 720, + actualHeight: 1280, + }, + { code: "fps_mismatch", maxFps: 30, actualFps: 60 }, + { code: "bitrate_high", maxKbps: 8000, actualKbps: 9500 }, + { code: "loudness_off", targetLufs: -14, actualLufs: -17.3 }, + { code: "true_peak_high", maxDb: -1, actualDb: -0.2 }, + ], + }); + }); + + it("fails what the platform would reject, most severe first", () => { + const check = checkExport( + tiktok, + measured({ codec: "hevc", sizeBytes: 300_456_789, loudness: loudness(-20) }), + ); + expect(check.status).toBe("fail"); + expect(check.issues).toEqual([ + { code: "file_too_large", maxMb: 287, actualMb: 300.5 }, + { code: "codec_mismatch" }, + { code: "loudness_off", targetLufs: -14, actualLufs: -20 }, + ]); + expect(checkExport(tiktok, measured({ durationMs: 601_000 })).issues[0]).toEqual({ + code: "too_long", + maxMs: 600_000, + actualMs: 601_000, + }); + }); + + it("does not judge the loudness of silent or missing audio, or an unknown frame rate", () => { + expect(checkExport(tiktok, measured({ loudness: null, fps: 0 })).status).toBe("pass"); + expect( + checkExport(tiktok, measured({ loudness: loudness(Number.NEGATIVE_INFINITY) })).status, + ).toBe("pass"); + expect(checkExport(tiktok, measured({ loudness: loudness(-65) })).status).toBe("pass"); + }); +}); + +describe("automatic fixes", () => { + it("re-encodes an oversized file once at a bitrate scaled from what it averaged", () => { + expect(sizeFixKbps(tiktok, measured(), 8000)).toBeNull(); + // 330 MB at 4400 kbps average: scale by 287/330 and keep 10% headroom. + const lower = sizeFixKbps(tiktok, measured({ sizeBytes: 330_000_000, videoKbps: 4400 }), 8000); + expect(lower).toBe(Math.floor(4400 * (287 / 330) * 0.9)); + // Without a stream bitrate, the file size and length give the average. + expect( + sizeFixKbps( + tiktok, + measured({ sizeBytes: 330_000_000, videoKbps: undefined, durationMs: 600_000 }), + 8000, + ), + ).toBe(Math.floor((4400 - 160) * (287 / 330) * 0.9)); + // Nothing lower is left to try. + expect( + sizeFixKbps(tiktok, measured({ sizeBytes: 330_000_000, videoKbps: 200 }), MIN_VIDEO_KBPS), + ).toBeNull(); + }); + + it("re-normalizes audible audio that missed the loudness target by more than the tolerance", () => { + expect(needsLoudnessFix(tiktok, measured())).toBe(false); + expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-16) }))).toBe(false); + expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-16.5) }))).toBe(true); + expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-10) }))).toBe(true); + expect(needsLoudnessFix(tiktok, measured({ loudness: null }))).toBe(false); + expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-70) }))).toBe(false); + }); +}); diff --git a/packages/adapters/src/media/platform-export.ts b/packages/adapters/src/media/platform-export.ts new file mode 100644 index 0000000..e887248 --- /dev/null +++ b/packages/adapters/src/media/platform-export.ts @@ -0,0 +1,221 @@ +import type { MediaCheck, MediaCheckIssue, MediaFit, PlatformProfile } from "@rakazo/contracts"; +import type { LoudnessMeasurement } from "./ffmpeg.js"; +import type { ExportTarget } from "./video-edit-plan.js"; +import { AUDIO_BITRATE_KBPS } from "./video-edit-plan.js"; + +/** + * Pure rules of a platform export: what to render, whether the result fits the platform, and + * which one-time fix to try when it does not. + */ + +/** Container durations differ from the cut by a few audio frames. */ +export const DURATION_TOLERANCE_MS = 100; +/** Average video bitrate above the platform's cap by more than this share earns a warning. */ +export const BITRATE_TOLERANCE = 0.1; +/** Frame rate above the platform's cap by more than this share earns a warning. */ +export const FPS_TOLERANCE = 0.01; +/** Integrated loudness this far from the target is re-normalized once, then warned about. */ +export const LOUDNESS_TOLERANCE_LU = 2; +/** True peak above the platform's ceiling by more than this earns a warning (AAC adds a little). */ +export const TRUE_PEAK_TOLERANCE_DB = 0.5; +/** Integrated loudness below this is silence: nothing to normalize or check. */ +export const SILENCE_LUFS = -60; +/** Share of the file size limit the first encode budgets for, and a re-encode after an overshoot. */ +export const SIZE_HEADROOM = 0.95; +export const SIZE_FIX_HEADROOM = 0.9; +/** Below this the picture falls apart; a file that still does not fit fails the check instead. */ +export const MIN_VIDEO_KBPS = 300; +const BYTES_PER_MB = 1_000_000; + +const FAILING_CODES = new Set([ + "too_long", + "too_short", + "file_too_large", + "codec_mismatch", +]); + +/** What a rendered file measured. */ +export interface ExportMeasurement { + width: number; + height: number; + /** 0 when unknown. */ + fps: number; + codec: string; + durationMs: number; + sizeBytes: number; + videoKbps?: number; + /** Null without an audio track. */ + loudness: LoudnessMeasurement | null; +} + +/** Highest video bitrate that keeps a file of this length under the platform's size limit. */ +export function sizeBudgetKbps( + profile: PlatformProfile, + durationMs: number, + headroom = SIZE_HEADROOM, +): number { + const totalKbps = (profile.video.maxFileSizeMb * BYTES_PER_MB * 8) / Math.max(1, durationMs); + return Math.max(MIN_VIDEO_KBPS, Math.floor(totalKbps * headroom - AUDIO_BITRATE_KBPS)); +} + +export function platformTarget( + profile: PlatformProfile, + fit: MediaFit, + durationMs: number, +): ExportTarget { + return { + width: profile.video.width, + height: profile.video.height, + fit, + maxFps: profile.video.fps, + maxBitrateKbps: Math.min(profile.video.maxBitrateKbps, sizeBudgetKbps(profile, durationMs)), + integratedLufs: profile.audio.targetLufs, + truePeakDb: profile.audio.truePeakDb, + }; +} + +/** Why a video of this length cannot go on the platform; empty when it can. */ +export function durationIssues(profile: PlatformProfile, durationMs: number): MediaCheckIssue[] { + const { hardMinMs, hardMaxMs } = profile.duration; + if (durationMs > hardMaxMs + DURATION_TOLERANCE_MS) { + return [{ code: "too_long", maxMs: hardMaxMs, actualMs: Math.round(durationMs) }]; + } + if (durationMs < hardMinMs - DURATION_TOLERANCE_MS) { + return [{ code: "too_short", minMs: hardMinMs, actualMs: Math.round(durationMs) }]; + } + return []; +} + +function audible(loudness: LoudnessMeasurement | null): loudness is LoudnessMeasurement { + return loudness !== null && loudness.integratedLufs > SILENCE_LUFS; +} + +/** Compares a rendered file with the platform's limits; failing issues come first. */ +export function checkExport(profile: PlatformProfile, measured: ExportMeasurement): MediaCheck { + const { video, audio } = profile; + const issues: MediaCheckIssue[] = [...durationIssues(profile, measured.durationMs)]; + const maxBytes = video.maxFileSizeMb * BYTES_PER_MB; + if (measured.sizeBytes > maxBytes) { + issues.push({ + code: "file_too_large", + maxMb: video.maxFileSizeMb, + actualMb: round(measured.sizeBytes / BYTES_PER_MB, 1), + }); + } + if (measured.codec !== video.codec) issues.push({ code: "codec_mismatch" }); + if (measured.width !== video.width || measured.height !== video.height) { + issues.push({ + code: "resolution_mismatch", + width: video.width, + height: video.height, + actualWidth: measured.width, + actualHeight: measured.height, + }); + } + if (measured.fps > video.fps * (1 + FPS_TOLERANCE)) { + issues.push({ code: "fps_mismatch", maxFps: video.fps, actualFps: round(measured.fps, 2) }); + } + if ( + measured.videoKbps !== undefined && + measured.videoKbps > video.maxBitrateKbps * (1 + BITRATE_TOLERANCE) + ) { + issues.push({ + code: "bitrate_high", + maxKbps: video.maxBitrateKbps, + actualKbps: Math.round(measured.videoKbps), + }); + } + if (audible(measured.loudness)) { + const { integratedLufs, truePeakDb } = measured.loudness; + if (Math.abs(integratedLufs - audio.targetLufs) > LOUDNESS_TOLERANCE_LU) { + issues.push({ + code: "loudness_off", + targetLufs: audio.targetLufs, + actualLufs: round(integratedLufs, 1), + }); + } + if (truePeakDb > audio.truePeakDb + TRUE_PEAK_TOLERANCE_DB) { + issues.push({ + code: "true_peak_high", + maxDb: audio.truePeakDb, + actualDb: round(truePeakDb, 1), + }); + } + } + const failing = issues.filter((issue) => FAILING_CODES.has(issue.code)); + const warning = issues.filter((issue) => !FAILING_CODES.has(issue.code)); + return { + status: failing.length ? "fail" : warning.length ? "warn" : "pass", + issues: [...failing, ...warning], + }; +} + +/** + * The lower bitrate cap for one re-encode of a file over the size limit, scaled from what the + * file actually averaged; null when it fits or a lower cap would not help. + */ +export function sizeFixKbps( + profile: PlatformProfile, + measured: ExportMeasurement, + currentKbps: number, +): number | null { + const maxBytes = profile.video.maxFileSizeMb * BYTES_PER_MB; + if (measured.sizeBytes <= maxBytes) return null; + const averageKbps = + measured.videoKbps ?? + (measured.sizeBytes * 8) / Math.max(1, measured.durationMs) - AUDIO_BITRATE_KBPS; + const scaled = Math.min(currentKbps, averageKbps) * (maxBytes / measured.sizeBytes); + const next = Math.max(MIN_VIDEO_KBPS, Math.floor(scaled * SIZE_FIX_HEADROOM)); + return next < currentKbps ? next : null; +} + +/** Whether one more loudness pass is worth it: the audio is audible and off the target. */ +export function needsLoudnessFix(profile: PlatformProfile, measured: ExportMeasurement): boolean { + return ( + audible(measured.loudness) && + Math.abs(measured.loudness.integratedLufs - profile.audio.targetLufs) > LOUDNESS_TOLERANCE_LU + ); +} + +/** An issue in a few English words for the bot's summary; the web words them itself. */ +export function describeIssue(issue: MediaCheckIssue): string { + switch (issue.code) { + case "too_long": + return `${seconds(issue.actualMs)} is longer than its ${seconds(issue.maxMs)} limit`; + case "too_short": + return `${seconds(issue.actualMs)} is shorter than its ${seconds(issue.minMs)} minimum`; + case "file_too_large": + return `${issue.actualMb} MB is larger than its ${issue.maxMb} MB limit`; + case "bitrate_high": + return `video bitrate ${issue.actualKbps} kbps is above its ${issue.maxKbps} kbps`; + case "loudness_off": + return `loudness ${issue.actualLufs} LUFS, target ${issue.targetLufs} LUFS`; + case "true_peak_high": + return `true peak ${issue.actualDb} dBTP is above its ${issue.maxDb} dBTP`; + case "resolution_mismatch": + return `frame ${issue.actualWidth}×${issue.actualHeight} instead of ${issue.width}×${issue.height}`; + case "fps_mismatch": + return `${issue.actualFps} fps is above its ${issue.maxFps} fps`; + case "codec_mismatch": + return "video is not H.264"; + } +} + +/** "Not exported for TikTok: 720 s is longer than its 600 s limit; trim the video first." */ +export function describeSkipped( + profile: Pick, + issues: readonly MediaCheckIssue[], +): string { + const advice = issues.some((issue) => issue.code === "too_short") + ? "use a longer video" + : "trim the video first"; + return `Not exported for ${profile.name}: ${issues.map(describeIssue).join("; ")}; ${advice}.`; +} + +function seconds(ms: number): string { + return `${Number((ms / 1000).toFixed(1))} s`; +} + +function round(value: number, digits: number): number { + return Number(value.toFixed(digits)); +} diff --git a/packages/adapters/src/media/transcription.test.ts b/packages/adapters/src/media/transcription.test.ts index 4b5f0ae..f47ac43 100644 --- a/packages/adapters/src/media/transcription.test.ts +++ b/packages/adapters/src/media/transcription.test.ts @@ -150,6 +150,7 @@ async function jobContext(source: Omit, input: unknow outputs.push({ ...file, body: await readFile(file.path, "utf8") }); return { artifactId: `out-${outputs.length}` } as MediaOutput; }, + addSkipped: async () => undefined, }; return { context, outputs, progress }; } diff --git a/packages/adapters/src/media/video-edit-plan.test.ts b/packages/adapters/src/media/video-edit-plan.test.ts index 4927f9e..ce64277 100644 --- a/packages/adapters/src/media/video-edit-plan.test.ts +++ b/packages/adapters/src/media/video-edit-plan.test.ts @@ -1,8 +1,10 @@ import type { EditVideoStep } from "@rakazo/contracts"; +import { MEDIA_PLATFORM_PROFILES } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; import type { MediaProbe } from "./ffmpeg.js"; +import { platformTarget } from "./platform-export.js"; import type { PlanInput } from "./video-edit-plan.js"; -import { atempoFilters, planVideoEdit, subtitleStyle } from "./video-edit-plan.js"; +import { atempoFilters, planLoudnessFix, planVideoEdit, subtitleStyle } from "./video-edit-plan.js"; function probe( overrides: Partial> & { @@ -298,3 +300,83 @@ describe("planVideoEdit", () => { expect(graphOf(evened.args)).toBe("[0:v:0]crop=720:404:0:0,format=yuv420p[v1]"); }); }); + +describe("platform export plans", () => { + const fast = { ...source, probe: probe({ fps: 120, durationMs: 20_000 }) }; + + it.each(Object.values(MEDIA_PLATFORM_PROFILES))( + "renders $name to its frame, frame rate, bitrate and loudness", + (profile) => { + const target = platformTarget(profile, "crop", 20_000); + const result = planVideoEdit({ + source: fast, + steps: [], + target, + outputPath: "/jobs/1/output.mp4", + }); + const { width, height, fps, maxBitrateKbps } = profile.video; + const graph = graphOf(result.args); + expect(graph).toContain(`fps=${fps}`); + expect(graph).toMatch(new RegExp(`scale=${width}:${height}[,:]`)); + expect(graph).toContain( + `loudnorm=I=${profile.audio.targetLufs}:TP=${profile.audio.truePeakDb}:LRA=11,atrim=end=20`, + ); + expect(target.maxBitrateKbps).toBe(maxBitrateKbps); + const maxrate = result.args.indexOf("-maxrate"); + expect(result.args.slice(maxrate, maxrate + 4)).toEqual([ + "-maxrate", + `${maxBitrateKbps}k`, + "-bufsize", + `${maxBitrateKbps * 2}k`, + ]); + expect(result).toMatchObject({ kind: "video", width, height, durationMs: 20_000 }); + }, + ); + + it("keeps a slower frame rate, fits by the export's fit, and skips loudness without audio", () => { + const target = platformTarget(MEDIA_PLATFORM_PROFILES.tiktok, "pad", 10_000); + const graph = graphOf( + planVideoEdit({ + source: { ...source, probe: probe({ fps: 24, hasAudio: false }) }, + steps: [], + target, + outputPath: "/jobs/1/output.mp4", + }).args, + ); + expect(graph).not.toContain("fps="); + expect(graph).toContain("pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black"); + expect(graph).not.toContain("loudnorm"); + // An unknown (variable) frame rate gets the platform's. + const variable = graphOf( + planVideoEdit({ + source: { ...source, probe: probe({ fps: 0 }) }, + steps: [], + target, + outputPath: "/jobs/1/output.mp4", + }).args, + ); + expect(variable).toContain("fps=30"); + }); + + it("plans a linear second loudness pass that copies the video", () => { + const args = planLoudnessFix({ + inputPath: "/jobs/1/tiktok.mp4", + outputPath: "/jobs/1/tiktok-loudness.mp4", + durationMs: 12_500, + target: { integratedLufs: -14, truePeakDb: -1 }, + measured: { + integratedLufs: -17.4, + truePeakDb: -4.2, + rangeLu: 5.1, + thresholdLufs: -27.6, + offsetLu: 0.3, + }, + }); + expect(args).toContain("file:/jobs/1/tiktok.mp4"); + expect(graphOf(args)).toBe( + "[0:a:0]loudnorm=I=-14:TP=-1:LRA=11:measured_I=-17.4:measured_TP=-4.2:measured_LRA=5.1:measured_thresh=-27.6:offset=0.3:linear=true,atrim=end=12.5,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a1]", + ); + expect(args.join(" ")).toContain("-map 0:v:0 -map [a1] -c:v copy -c:a aac -b:a 160k"); + expect(args.at(-1)).toBe("file:/jobs/1/tiktok-loudness.mp4"); + }); +}); diff --git a/packages/adapters/src/media/video-edit-plan.ts b/packages/adapters/src/media/video-edit-plan.ts index f39f8d5..68caa4d 100644 --- a/packages/adapters/src/media/video-edit-plan.ts +++ b/packages/adapters/src/media/video-edit-plan.ts @@ -1,7 +1,7 @@ import type { EditVideoStep, MediaFit } from "@rakazo/contracts"; import { MEDIA_PRESETS } from "@rakazo/contracts"; -import type { MediaProbe } from "./ffmpeg.js"; -import { ffmpegInputArgs, ffmpegSeconds } from "./ffmpeg.js"; +import type { LoudnessMeasurement, MediaProbe } from "./ffmpeg.js"; +import { ffmpegInputArgs, ffmpegSeconds, loudnormFilter } from "./ffmpeg.js"; /** * Turns an ordered list of edit steps into one ffmpeg invocation with a single filter_complex @@ -22,9 +22,22 @@ export interface VideoEditPlanRequest { clips?: ReadonlyMap; /** Subtitle files named by subtitles steps, by artifact id: safe names relative to ffmpeg's cwd. */ subtitleFiles?: ReadonlyMap; + /** A platform's limits, applied after the steps. */ + target?: ExportTarget; outputPath: string; } +/** The frame, frame rate cap, bitrate cap and loudness a platform export is rendered to. */ +export interface ExportTarget { + width: number; + height: number; + fit: MediaFit; + maxFps: number; + maxBitrateKbps: number; + integratedLufs: number; + truePeakDb: number; +} + export interface VideoEditPlan { args: string[]; kind: "video" | "audio"; @@ -46,7 +59,8 @@ export const VIDEO_ENCODE_ARGS = [ "-movflags", "+faststart", ]; -const AUDIO_ENCODE_ARGS = ["-c:a", "aac", "-b:a", "160k"]; +export const AUDIO_BITRATE_KBPS = 160; +const AUDIO_ENCODE_ARGS = ["-c:a", "aac", "-b:a", `${AUDIO_BITRATE_KBPS}k`]; const AUDIO_ONLY_ENCODE_ARGS = ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"]; /** Location and other camera metadata stay private; chapters do not survive cuts anyway. */ const STRIP_METADATA_ARGS = ["-map_metadata", "-1", "-map_chapters", "-1"]; @@ -229,6 +243,8 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan { `poster at ${seconds(posterAt)} s is past the end of the ${seconds(timeline.seconds)} s result.`, ); } + const { target } = request; + if (target && !audioOnly) applyTarget(graph, timeline, target); const durationMs = Math.round(timeline.seconds * 1000); const output = `file:${request.outputPath}`; if (audioOnly || !timeline.video) { @@ -268,6 +284,7 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan { `[${videoOut}]`, ...(audioOut ? ["-map", `[${audioOut}]`, ...AUDIO_ENCODE_ARGS] : []), ...VIDEO_ENCODE_ARGS, + ...(target && !audioOnly ? bitrateCapArgs(target.maxBitrateKbps) : []), ...STRIP_METADATA_ARGS, "-f", "mp4", @@ -281,6 +298,64 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan { }; } +/** + * Fits the result to a platform: frame rate cap, frame size and loudness. Loudness is one + * dynamic loudnorm pass, which lands within about 1 LU of the target on most material without + * decoding the source twice; the export measures the result and corrects it when it misses. + */ +function applyTarget(graph: FilterGraph, timeline: Timeline, target: ExportTarget) { + if (timeline.video) { + if (!timeline.fps || timeline.fps > target.maxFps + 0.01) { + timeline.video.filters.push(`fps=${number(target.maxFps)}`); + timeline.fps = target.maxFps; + } + timeline.video = fitFrame(graph, timeline.video, timeline, target, target.fit); + } + timeline.width = target.width; + timeline.height = target.height; + // loudnorm pads its last frame; trim the audio back to the video's length. + timeline.audio?.filters.push( + loudnormFilter(target), + `atrim=end=${ffmpegSeconds(timeline.seconds)}`, + ); +} + +/** CRF quality, capped so no stretch of the video runs above the platform's bitrate. */ +function bitrateCapArgs(maxBitrateKbps: number): string[] { + const kbps = Math.max(1, Math.round(maxBitrateKbps)); + return ["-maxrate", `${kbps}k`, "-bufsize", `${kbps * 2}k`]; +} + +/** + * Second loudness pass over a rendered MP4: linear loudnorm with the measured values on the + * audio, the video copied as it is. + */ +export function planLoudnessFix(request: { + inputPath: string; + outputPath: string; + durationMs: number; + target: { integratedLufs: number; truePeakDb: number }; + measured: LoudnessMeasurement; +}): string[] { + const filters = [ + loudnormFilter(request.target, request.measured), + `atrim=end=${ffmpegSeconds(request.durationMs / 1000)}`, + AUDIO_FORMAT, + ]; + return [ + ...ffmpegInputArgs(request.inputPath, { mimeType: "video/mp4" }), + "-filter_complex", + `[0:a:0]${filters.join(",")}[a1]`, + ...["-map", "0:v:0", "-map", "[a1]", "-c:v", "copy"], + ...AUDIO_ENCODE_ARGS, + ...["-movflags", "+faststart"], + ...STRIP_METADATA_ARGS, + "-f", + "mp4", + `file:${request.outputPath}`, + ]; +} + interface Segment { start: number; end: number; diff --git a/packages/adapters/src/media/video-operations.postgres.test.ts b/packages/adapters/src/media/video-operations.postgres.test.ts index 7f5592d..5cdd51d 100644 --- a/packages/adapters/src/media/video-operations.postgres.test.ts +++ b/packages/adapters/src/media/video-operations.postgres.test.ts @@ -176,6 +176,63 @@ describePostgres("video operations in the media runtime (PostgreSQL + ffmpeg)", ]); }); + it("exports for a platform with its check and shows a skipped platform on the card", async () => { + const { runtime, scope, store } = await setup(); + const source = await store(clips.landscape, "holiday.mp4", "video/mp4"); + const started = await runtime.start({ + ...scope, + operation: "export_video", + input: { artifact_id: source, platforms: ["tiktok", "snapchat-spotlight"] }, + }); + await runtime.runJob(started.jobId); + const job = await runtime.get(started.jobId); + expect(job).toMatchObject({ status: "done", title: "TikTok · Snapchat Spotlight" }); + expect(job?.outputs).toEqual([ + expect.objectContaining({ + name: "holiday-tiktok.mp4", + platform: "tiktok", + width: 1080, + height: 1920, + check: { status: "pass", issues: [] }, + }), + ]); + const stored = await prisma.artifact.findUniqueOrThrow({ + where: { id: job!.outputs[0]!.artifactId }, + }); + expect(stored).toMatchObject({ parentArtifactId: source, width: 1080, height: 1920 }); + expect(await card(started.messageId, started.jobId)).toMatchObject({ + status: "done", + outputs: job?.outputs, + skipped: [ + { + platform: "snapchat-spotlight", + issues: [{ code: "too_short", minMs: 5000, actualMs: 3000 }], + }, + ], + }); + }); + + it("fails an export that fits none of its platforms and keeps the reason on the card", async () => { + const { runtime, scope, store } = await setup(); + const source = await store(clips.landscape, "short.mp4", "video/mp4"); + const started = await runtime.start({ + ...scope, + operation: "export_video", + input: { artifact_id: source, platforms: ["snapchat-spotlight"] }, + }); + await runtime.runJob(started.jobId); + expect(await runtime.get(started.jobId)).toMatchObject({ + status: "failed", + error: + "Not exported for Snapchat Spotlight: 3 s is shorter than its 5 s minimum; use a longer video.", + outputs: [], + }); + expect(await card(started.messageId, started.jobId)).toMatchObject({ + status: "failed", + skipped: [{ platform: "snapchat-spotlight", issues: [{ code: "too_short" }] }], + }); + }); + it("refuses appended clips and subtitles the user cannot read", async () => { const { runtime, scope, store } = await setup(); const other = await setup(); diff --git a/packages/adapters/src/media/video-operations.test.ts b/packages/adapters/src/media/video-operations.test.ts index 1d8d6f9..ddd3ad0 100644 --- a/packages/adapters/src/media/video-operations.test.ts +++ b/packages/adapters/src/media/video-operations.test.ts @@ -1,13 +1,15 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; +import { MEDIA_PLATFORM_PROFILES, MediaPlatformIdSchema } from "@rakazo/contracts"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { builtinAgentTools } from "../builtin-tools.js"; import { createMediaOperations } from "../media-operations.js"; import { MEDIA_CONTINUE_AFTER_PROPERTY } from "../media-tools.js"; import { parametersFor } from "../pi-runtime.js"; -import { detectFfmpeg, probeMedia, runFfmpeg } from "./ffmpeg.js"; +import { detectFfmpeg, measureLoudness, probeMedia, runFfmpeg } from "./ffmpeg.js"; import type { TestArtifact, TestClips } from "./media-test-support.js"; import { createTestClips, fakeMediaJobContext } from "./media-test-support.js"; +import { LOUDNESS_TOLERANCE_LU } from "./platform-export.js"; import { cleanFileName, createVideoOperations, subtitleFormat } from "./video-operations.js"; const ffmpeg = await detectFfmpeg(); @@ -48,13 +50,22 @@ describe("video operation registrations", () => { expect(exported.artifactIds?.(exportInput)).toEqual(["src"]); }); - it("titles exports by aspect ratio unless the bot gave a title", () => { + it("titles exports by platform and aspect ratio unless the bot gave a title", () => { const exported = operation("export_video"); expect( exported.title( exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical", "square"] }), ), ).toBe("9:16 · 1:1"); + expect( + exported.title( + exported.inputSchema.parse({ + artifact_id: "a", + platforms: ["tiktok", "youtube-shorts"], + presets: ["square"], + }), + ), + ).toBe("TikTok · YouTube Shorts · 1:1"); expect( exported.title( exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical"], title: "Reel" }), @@ -70,6 +81,18 @@ describe("video operation registrations", () => { expect(() => parametersFor(tool!)).not.toThrow(); } }); + + it("tells the model every platform id with its longest video", () => { + const tool = builtinAgentTools.find((entry) => entry.name === "export_video")!; + const properties = tool.inputSchema.properties as Record< + string, + { items?: { enum?: string[] } } + >; + expect(properties.platforms?.items?.enum).toEqual(MediaPlatformIdSchema.options); + for (const profile of Object.values(MEDIA_PLATFORM_PROFILES)) { + expect(tool.description).toContain(`${profile.id} ${profile.duration.hardMaxMs / 1000} s`); + } + }); }); describe("subtitle and file name helpers", () => { @@ -251,6 +274,74 @@ describe.skipIf(!ffmpeg)("video operations with ffmpeg", () => { expect(progress.at(-1)).toBe(1); }); + it("exports for a platform at its frame, frame rate and loudness, and skips one it is too short for", async () => { + const fast = join(clips.dir, "fast.mp4"); + await runFfmpeg([ + ...["-f", "lavfi", "-i", "testsrc=size=320x180:rate=60:duration=4"], + ...["-f", "lavfi", "-i", "sine=frequency=330:sample_rate=48000:duration=4"], + ...["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"], + ...["-c:a", "aac", "-shortest", `file:${fast}`], + ]); + const registration = operation("export_video"); + const job = await fakeMediaJobContext( + registration.inputSchema.parse({ + artifact_id: "fast", + platforms: ["tiktok", "snapchat-spotlight"], + }), + { fast: { path: fast, name: "Fast clip.mp4", mimeType: "video/mp4" } }, + ); + try { + const result = await registration.handler.run(job.context); + expect(job.skipped).toEqual([ + { + platform: "snapchat-spotlight", + issues: [{ code: "too_short", minMs: 5000, actualMs: expect.any(Number) }], + }, + ]); + expect(job.outputs).toHaveLength(1); + const [output] = job.outputs; + expect(output).toMatchObject({ + name: "Fast clip-tiktok.mp4", + platform: "tiktok", + width: 1080, + height: 1920, + parentArtifactId: "fast", + check: { status: "pass", issues: [] }, + }); + expect(output!.posterSize).toBeGreaterThan(0); + const probe = await probeMedia(output!.path, { mimeType: "video/mp4" }); + expect(probe.video).toMatchObject({ codec: "h264", width: 1080, height: 1920, fps: 30 }); + expect(Math.abs(probe.durationMs - 4000)).toBeLessThan(100); + const loudness = await measureLoudness( + output!.path, + { integratedLufs: -14, truePeakDb: -1 }, + { mimeType: "video/mp4" }, + ); + expect(Math.abs(loudness.integratedLufs + 14)).toBeLessThanOrEqual(LOUDNESS_TOLERANCE_LU); + expect(result.summary).toContain("Fast clip-tiktok.mp4 for TikTok (1080×1920, 4 s)"); + expect(result.summary).toContain( + "Not exported for Snapchat Spotlight: 4 s is shorter than its 5 s minimum; use a longer video.", + ); + expect(job.progress.at(-1)).toBe(1); + } finally { + await rm(job.tempDir, { recursive: true, force: true }); + } + }); + + it("fails without rendering when the video fits none of the platforms", async () => { + const registration = operation("export_video"); + const job = await fakeMediaJobContext( + registration.inputSchema.parse({ artifact_id: "land", platforms: ["snapchat-spotlight"] }), + artifacts, + ); + await expect(registration.handler.run(job.context)).rejects.toThrow( + "Not exported for Snapchat Spotlight: 3 s is shorter than its 5 s minimum; use a longer video.", + ); + expect(job.skipped.map((target) => target.platform)).toEqual(["snapchat-spotlight"]); + expect(job.outputs).toEqual([]); + await rm(job.tempDir, { recursive: true, force: true }); + }); + it("fails clearly on ranges past the end, non-subtitle files and non-videos", async () => { await expect( run("edit_video", { diff --git a/packages/adapters/src/media/video-operations.ts b/packages/adapters/src/media/video-operations.ts index e4e1e6d..180bcff 100644 --- a/packages/adapters/src/media/video-operations.ts +++ b/packages/adapters/src/media/video-operations.ts @@ -1,24 +1,49 @@ -import { copyFile, mkdtemp, open, stat } from "node:fs/promises"; +import { copyFile, mkdtemp, open, rename, stat } from "node:fs/promises"; import { join } from "node:path"; -import type { MediaJobContext, MediaJobInputFile, MediaOperationResult } from "@rakazo/adapter-kit"; -import type { EditVideoInput, ExportVideoInput, MediaOutput } from "@rakazo/contracts"; +import type { + MediaJobContext, + MediaJobInputFile, + MediaJobOutputFile, + MediaOperationResult, +} from "@rakazo/adapter-kit"; +import type { + EditVideoInput, + ExportVideoInput, + MediaFit, + MediaOutput, + MediaPlatformId, + MediaSkippedTarget, + PlatformProfile, +} from "@rakazo/contracts"; import { ARTIFACT_MAX_BYTES_BY_KIND, EditVideoInputSchema, ExportVideoInputSchema, + MEDIA_PLATFORM_PROFILES, MEDIA_PRESETS, } from "@rakazo/contracts"; import type { MediaOperationRegistration } from "../media-runtime.js"; -import type { FfmpegInfo } from "./ffmpeg.js"; +import type { FfmpegInfo, MediaProbe } from "./ffmpeg.js"; import { detectFfmpeg, + measureLoudness, outputTooLargeMessage, probeMedia, renderPoster, runFfmpeg, } from "./ffmpeg.js"; +import type { ExportMeasurement } from "./platform-export.js"; +import { + checkExport, + describeIssue, + describeSkipped, + durationIssues, + needsLoudnessFix, + platformTarget, + sizeFixKbps, +} from "./platform-export.js"; import type { PlanInput, VideoEditPlan } from "./video-edit-plan.js"; -import { planVideoEdit } from "./video-edit-plan.js"; +import { planLoudnessFix, planVideoEdit } from "./video-edit-plan.js"; export interface VideoOperationsDeps { /** Finds ffmpeg; defaults to PATH lookup. */ @@ -66,7 +91,11 @@ export function createVideoOperations( operation: "export_video", inputSchema: ExportVideoInputSchema, title: (input) => - input.title ?? input.presets.map((preset) => MEDIA_PRESETS[preset].aspect).join(" · "), + input.title ?? + [ + ...(input.platforms ?? []).map((platform) => MEDIA_PLATFORM_PROFILES[platform].name), + ...(input.presets ?? []).map((preset) => MEDIA_PRESETS[preset].aspect), + ].join(" · "), available, artifactIds: (input) => [input.artifact_id], handler: { @@ -142,39 +171,160 @@ async function runEditVideo(context: MediaJobContext): Promise { const input = ExportVideoInputSchema.parse(context.input); - const presets = [...new Set(input.presets)]; + const platforms = [...new Set(input.platforms ?? [])]; + const presets = [...new Set(input.presets ?? [])]; const workDir = await mkdtemp(join(context.tempDir, "export-")); const progress = createProgressReporter(context); const source = await loadVideo(context, input.artifact_id); const base = cleanFileName(withoutExtension(source.name), "video"); + // A platform the video is too long or too short for is not rendered; the bot trims first. + const skipped: MediaSkippedTarget[] = []; + const rendered: MediaPlatformId[] = []; + for (const platform of platforms) { + const issues = durationIssues(MEDIA_PLATFORM_PROFILES[platform], source.probe.durationMs); + if (issues.length) skipped.push({ platform, issues }); + else rendered.push(platform); + } + for (const target of skipped) await context.addSkipped(target); + const notExported = skipped.map(({ platform, issues }) => + describeSkipped(MEDIA_PLATFORM_PROFILES[platform], issues), + ); + const count = rendered.length + presets.length; + if (count === 0) throw new Error(notExported.join(" ")); + const share = 1 / count; + const report = (index: number) => progress.range(index * share, (index + 1) * share); const outputs: MediaOutput[] = []; - for (const [index, preset] of presets.entries()) { + for (const platform of rendered) { + outputs.push( + await exportForPlatform(context, source, MEDIA_PLATFORM_PROFILES[platform], { + fit: input.fit, + name: `${base}-${platform}.mp4`, + workDir, + report: report(outputs.length), + }), + ); + } + for (const preset of presets) { const outputPath = join(workDir, `${preset}.mp4`); const plan = planVideoEdit({ source, steps: [{ op: "reframe", preset, fit: input.fit }], outputPath, }); - const share = 1 / presets.length; outputs.push( - await encode( - context, - plan, - outputPath, - workDir, - progress.range(index * share, (index + 1) * share), - { - name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`, - parentArtifactId: source.artifactId, - preset, - }, - ), + await encode(context, plan, outputPath, workDir, report(outputs.length), { + name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`, + parentArtifactId: source.artifactId, + preset, + }), ); } await progress.done(); - return { summary: `Exported ${source.name} as ${outputs.map(describe).join(", ")}.` }; + const checks = outputs.flatMap((output) => + output.platform && output.check && output.check.status !== "pass" + ? [ + `${MEDIA_PLATFORM_PROFILES[output.platform].name} check ${output.check.status}: ${output.check.issues.map(describeIssue).join("; ")}.`, + ] + : [], + ); + return { + summary: [ + `Exported ${source.name} as ${outputs.map(describe).join(", ")}.`, + ...checks, + ...notExported, + ].join(" "), + }; } +/** + * Renders a video to a platform's frame, frame rate, bitrate and loudness, measures the file + * and stores it with the check. A file over the size limit is encoded once more at a lower + * bitrate, and audio off the loudness target gets one more, linear loudness pass. + */ +async function exportForPlatform( + context: MediaJobContext, + source: PlanInput & MediaJobInputFile, + profile: PlatformProfile, + options: { fit: MediaFit; name: string; workDir: string; report: (fraction: number) => void }, +): Promise { + const { workDir, report } = options; + const outputPath = join(workDir, `${profile.id}.mp4`); + let target = platformTarget(profile, options.fit, source.probe.durationMs); + const renderTarget = async () => { + const plan = planVideoEdit({ source, steps: [], target, outputPath }); + await render(context, plan, outputPath, workDir, report); + return plan; + }; + let plan = await renderTarget(); + let measured = await measureExport(context, outputPath, profile); + const lowerKbps = sizeFixKbps(profile, measured.result, target.maxBitrateKbps); + if (lowerKbps !== null) { + target = { ...target, maxBitrateKbps: lowerKbps }; + plan = await renderTarget(); + measured = await measureExport(context, outputPath, profile); + } + if (needsLoudnessFix(profile, measured.result) && measured.result.loudness) { + const fixedPath = join(workDir, `${profile.id}-loudness.mp4`); + await runFfmpeg( + planLoudnessFix({ + inputPath: outputPath, + outputPath: fixedPath, + durationMs: measured.result.durationMs, + target, + measured: measured.result.loudness, + }), + { + signal: context.signal, + cwd: workDir, + maxOutputBytes: ARTIFACT_MAX_BYTES_BY_KIND.video, + outputs: [fixedPath], + }, + ); + await rename(fixedPath, outputPath); + measured = await measureExport(context, outputPath, profile); + } + return store(context, plan, outputPath, measured.probe, report, { + name: options.name, + parentArtifactId: source.artifactId, + platform: profile.id, + check: checkExport(profile, measured.result), + }); +} + +async function measureExport( + context: MediaJobContext, + path: string, + profile: PlatformProfile, +): Promise<{ probe: MediaProbe; result: ExportMeasurement }> { + const options = { signal: context.signal, mimeType: "video/mp4" }; + const probe = await probeMedia(path, options); + const loudness = probe.hasAudio + ? await measureLoudness( + path, + { integratedLufs: profile.audio.targetLufs, truePeakDb: profile.audio.truePeakDb }, + options, + ) + : null; + return { + probe, + result: { + width: probe.video?.width ?? 0, + height: probe.video?.height ?? 0, + fps: probe.video?.fps ?? 0, + codec: probe.video?.codec ?? "", + durationMs: probe.durationMs, + sizeBytes: (await stat(path)).size, + ...(probe.video?.bitRateKbps !== undefined ? { videoKbps: probe.video.bitRateKbps } : {}), + loudness, + }, + }; +} + +type OutputDetails = Pick< + MediaJobOutputFile, + "name" | "parentArtifactId" | "preset" | "platform" | "check" +>; + /** Runs a plan, checks the result, renders its poster and stores it as an output. */ async function encode( context: MediaJobContext, @@ -182,8 +332,24 @@ async function encode( outputPath: string, workDir: string, report: (fraction: number) => void, - file: Pick[0], "name" | "parentArtifactId" | "preset">, + file: OutputDetails, ): Promise { + await render(context, plan, outputPath, workDir, report); + const probe = await probeMedia(outputPath, { + signal: context.signal, + mimeType: plan.kind === "audio" ? "audio/mp4" : "video/mp4", + }); + return store(context, plan, outputPath, probe, report, file); +} + +/** Runs a plan's ffmpeg, refusing a result larger than an artifact may be. */ +async function render( + context: MediaJobContext, + plan: VideoEditPlan, + outputPath: string, + workDir: string, + report: (fraction: number) => void, +): Promise { const maxBytes = ARTIFACT_MAX_BYTES_BY_KIND[plan.kind]; await runFfmpeg(plan.args, { signal: context.signal, @@ -194,10 +360,17 @@ async function encode( outputs: [outputPath], }); if ((await stat(outputPath)).size > maxBytes) throw new Error(outputTooLargeMessage(maxBytes)); - const probe = await probeMedia(outputPath, { - signal: context.signal, - mimeType: plan.kind === "audio" ? "audio/mp4" : "video/mp4", - }); +} + +/** Renders a video's poster and stores the file as an output. */ +async function store( + context: MediaJobContext, + plan: VideoEditPlan, + outputPath: string, + probe: MediaProbe, + report: (fraction: number) => void, + file: OutputDetails, +): Promise { if (plan.kind === "audio") { const output = await context.addOutput({ ...file, @@ -294,13 +467,16 @@ function withoutExtension(name: string): string { return name.replace(/\.[A-Za-z0-9]{1,5}$/, ""); } -/** "name (1080×1920, 12.5 s)" for the bot's summary; the runtime adds the artifact ids. */ +/** "name for TikTok (1080×1920, 12.5 s)" for the bot's summary; the runtime adds the ids. */ function describe(output: MediaOutput): string { + const name = output.platform + ? `${output.name} for ${MEDIA_PLATFORM_PROFILES[output.platform].name}` + : output.name; const details = [ output.width && output.height ? `${output.width}×${output.height}` : "", output.durationMs !== undefined ? `${Number((output.durationMs / 1000).toFixed(1))} s` : "", ].filter(Boolean); - return details.length ? `${output.name} (${details.join(", ")})` : output.name; + return details.length ? `${name} (${details.join(", ")})` : name; } /** Maps ffmpeg progress onto the card: monotonic, written in order, never failing the job. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9f1ea52..8f451e1 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -17,6 +17,8 @@ export * from "./mcp.js"; export * from "./media.js"; export * from "./media-generation.js"; export * from "./media-jobs.js"; +export * from "./media-platforms.js"; +export * from "./media-presets.js"; export * from "./media-settings.js"; export * from "./media-transcription.js"; export * from "./media-video.js"; diff --git a/packages/contracts/src/media-generation.ts b/packages/contracts/src/media-generation.ts index b834196..959f027 100644 --- a/packages/contracts/src/media-generation.ts +++ b/packages/contracts/src/media-generation.ts @@ -1,6 +1,6 @@ import * as z from "zod"; import { Id } from "./ids.js"; -import { MediaFitSchema, MediaPresetIdSchema } from "./media.js"; +import { MediaFitSchema, MediaPresetIdSchema } from "./media-presets.js"; import { MediaTitleSchema } from "./media-video.js"; export const MEDIA_IMAGE_MAX_COUNT = 4; diff --git a/packages/contracts/src/media-platforms.test.ts b/packages/contracts/src/media-platforms.test.ts new file mode 100644 index 0000000..881250d --- /dev/null +++ b/packages/contracts/src/media-platforms.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + MEDIA_PLATFORM_PROFILES, + MediaCheckSchema, + MediaPlatformIdSchema, + PlatformProfileSchema, +} from "./media-platforms.js"; +import { MEDIA_PRESETS } from "./media-presets.js"; + +describe("platform profiles", () => { + it("ships one valid profile per platform id, keyed by its id", () => { + expect(Object.keys(MEDIA_PLATFORM_PROFILES)).toEqual(MediaPlatformIdSchema.options); + for (const [id, profile] of Object.entries(MEDIA_PLATFORM_PROFILES)) { + expect(PlatformProfileSchema.parse(profile)).toEqual(profile); + expect(profile.id).toBe(id); + expect(profile.sourceUrls.every((url) => url.startsWith("https://"))).toBe(true); + } + }); + + it("uses only the aspect ratios the presets know, at a size of that ratio", () => { + const aspects = new Set(Object.values(MEDIA_PRESETS).map((preset) => preset.aspect)); + for (const profile of Object.values(MEDIA_PLATFORM_PROFILES)) { + expect(aspects.has(profile.video.aspect)).toBe(true); + } + const tiktok = MEDIA_PLATFORM_PROFILES.tiktok; + expect( + PlatformProfileSchema.safeParse({ ...tiktok, video: { ...tiktok.video, aspect: "3:2" } }) + .success, + ).toBe(false); + expect( + PlatformProfileSchema.safeParse({ ...tiktok, video: { ...tiktok.video, width: 1920 } }) + .success, + ).toBe(false); + expect( + PlatformProfileSchema.safeParse({ + ...tiktok, + duration: { hardMinMs: 5000, hardMaxMs: 4000 }, + }).success, + ).toBe(false); + }); + + it("keeps the research provenance on every profile", () => { + for (const profile of Object.values(MEDIA_PLATFORM_PROFILES)) { + expect(profile.reviewedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(profile.unverified).toBe(true); + } + }); +}); + +describe("platform check", () => { + it("carries language-neutral issue codes with their numbers", () => { + expect( + MediaCheckSchema.parse({ + status: "fail", + issues: [ + { code: "too_long", maxMs: 600_000, actualMs: 720_000 }, + { code: "codec_mismatch" }, + ], + }).issues, + ).toHaveLength(2); + expect( + MediaCheckSchema.safeParse({ status: "warn", issues: [{ code: "too_blurry" }] }).success, + ).toBe(false); + expect( + MediaCheckSchema.safeParse({ status: "fail", issues: [{ code: "too_long", maxMs: 1 }] }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/contracts/src/media-platforms.ts b/packages/contracts/src/media-platforms.ts new file mode 100644 index 0000000..568c21c --- /dev/null +++ b/packages/contracts/src/media-platforms.ts @@ -0,0 +1,282 @@ +import * as z from "zod"; +import type { MediaPreset } from "./media-presets.js"; +import { MEDIA_PRESETS } from "./media-presets.js"; + +/** + * Platforms a video can be exported for. An export renders each one to the platform's + * technical limits and checks the file against them, so an upload is not rejected or degraded. + */ +export const MediaPlatformIdSchema = z.enum([ + "tiktok", + "instagram-reels", + "instagram-feed-4x5", + "youtube-shorts", + "youtube", + "facebook-reels", + "linkedin", + "x-twitter", + "pinterest-video-pin", + "snapchat-spotlight", +]); +export type MediaPlatformId = z.infer; + +const PRESET_ASPECTS = Object.values(MEDIA_PRESETS).map((preset) => preset.aspect) as [ + MediaPreset["aspect"], + ...MediaPreset["aspect"][], +]; + +const Percent = z.number().min(0).max(50); + +/** + * The technical limits of a platform. Content, disclosure and caption rules need a review flow + * and are not part of a profile. + */ +export const PlatformProfileSchema = z + .object({ + id: MediaPlatformIdSchema, + /** Brand name, shown untranslated. */ + name: z.string().min(1).max(40), + /** Where the limits come from. */ + sourceUrls: z.array(z.httpUrl()).min(1), + reviewedAt: z.iso.date(), + /** True until someone checked the limits against the platform's current documentation. */ + unverified: z.boolean(), + video: z.object({ + aspect: z.enum(PRESET_ASPECTS), + width: z.number().int().positive().multipleOf(2), + height: z.number().int().positive().multipleOf(2), + /** Highest frame rate; slower sources keep theirs. */ + fps: z.number().positive().max(120), + codec: z.literal("h264"), + maxBitrateKbps: z.number().int().positive(), + maxFileSizeMb: z.number().positive(), + }), + duration: z + .object({ + hardMinMs: z.number().int().nonnegative(), + hardMaxMs: z.number().int().positive(), + }) + .refine((duration) => duration.hardMinMs < duration.hardMaxMs, "hardMinMs must be lower"), + /** Edges the platform covers with its own buttons and captions, in percent of the frame. */ + safeZones: z.object({ + topPct: Percent, + bottomPct: Percent, + leftPct: Percent, + rightPct: Percent, + }), + audio: z.object({ + targetLufs: z.number().min(-40).max(0), + truePeakDb: z.number().min(-10).max(0), + }), + }) + .refine((profile) => { + const [across, down] = profile.video.aspect.split(":").map(Number); + return Math.abs(profile.video.width / profile.video.height - across! / down!) < 0.01; + }, "width and height must match the aspect ratio"); +export type PlatformProfile = z.infer; + +/** + * What a platform check found, with the numbers the UI words it with. Durations in ms, sizes in + * MB (10^6 bytes), bitrates in kbps, loudness in LUFS, peaks in dBTP. + */ +export const MediaCheckIssueSchema = z.discriminatedUnion("code", [ + z.object({ code: z.literal("too_long"), maxMs: z.number(), actualMs: z.number() }), + z.object({ code: z.literal("too_short"), minMs: z.number(), actualMs: z.number() }), + z.object({ code: z.literal("file_too_large"), maxMb: z.number(), actualMb: z.number() }), + z.object({ code: z.literal("bitrate_high"), maxKbps: z.number(), actualKbps: z.number() }), + z.object({ code: z.literal("loudness_off"), targetLufs: z.number(), actualLufs: z.number() }), + z.object({ code: z.literal("true_peak_high"), maxDb: z.number(), actualDb: z.number() }), + z.object({ + code: z.literal("resolution_mismatch"), + width: z.number(), + height: z.number(), + actualWidth: z.number(), + actualHeight: z.number(), + }), + z.object({ code: z.literal("fps_mismatch"), maxFps: z.number(), actualFps: z.number() }), + z.object({ code: z.literal("codec_mismatch") }), +]); +export type MediaCheckIssue = z.infer; + +/** `fail`: the platform would reject the file. `warn`: it would accept and degrade it. */ +export const MediaCheckSchema = z.object({ + status: z.enum(["pass", "warn", "fail"]), + /** Most severe first. */ + issues: z.array(MediaCheckIssueSchema).max(20), +}); +export type MediaCheck = z.infer; + +/** A platform an export did not render, e.g. because the video is too long for it. */ +export const MediaSkippedTargetSchema = z.object({ + platform: MediaPlatformIdSchema, + issues: z.array(MediaCheckIssueSchema).min(1).max(20), +}); +export type MediaSkippedTarget = z.infer; + +const VERTICAL_SAFE_ZONES = { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 15 }; +const FRAMED_SAFE_ZONES = { topPct: 5, bottomPct: 10, leftPct: 5, rightPct: 5 }; +const STREAMING_LOUDNESS = { targetLufs: -14, truePeakDb: -1 }; +const VERTICAL_1080 = { aspect: "9:16", width: 1080, height: 1920 } as const; +const REVIEWED_AT = "2026-10-03"; + +/** + * Researched limits, all unverified: each needs a check against the linked documentation before + * it is trusted. YouTube allows 60 fps per its encoding guide; the others are capped at 30. + */ +export const MEDIA_PLATFORM_PROFILES: Readonly> = { + tiktok: { + id: "tiktok", + name: "TikTok", + sourceUrls: ["https://support.tiktok.com/en/using-tiktok/creating-videos/upload-videos"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 8000, maxFileSizeMb: 287 }, + duration: { hardMinMs: 3000, hardMaxMs: 600_000 }, + safeZones: VERTICAL_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + "instagram-reels": { + id: "instagram-reels", + name: "Instagram Reels", + sourceUrls: ["https://help.instagram.com/", "https://www.facebook.com/business/ads-guide/"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 5000, maxFileSizeMb: 1024 }, + duration: { hardMinMs: 3000, hardMaxMs: 90_000 }, + safeZones: VERTICAL_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + "instagram-feed-4x5": { + id: "instagram-feed-4x5", + name: "Instagram Feed", + sourceUrls: ["https://help.instagram.com/", "https://www.facebook.com/business/ads-guide/"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { + aspect: "4:5", + width: 1080, + height: 1350, + fps: 30, + codec: "h264", + maxBitrateKbps: 5000, + maxFileSizeMb: 1024, + }, + duration: { hardMinMs: 3000, hardMaxMs: 60_000 }, + safeZones: FRAMED_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + "youtube-shorts": { + id: "youtube-shorts", + name: "YouTube Shorts", + sourceUrls: [ + "https://support.google.com/youtube/answer/15424877", + "https://support.google.com/youtube/answer/10059070", + "https://support.google.com/youtube/answer/1722171", + ], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { + ...VERTICAL_1080, + fps: 60, + codec: "h264", + maxBitrateKbps: 12_000, + maxFileSizeMb: 2048, + }, + duration: { hardMinMs: 1000, hardMaxMs: 180_000 }, + safeZones: VERTICAL_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + youtube: { + id: "youtube", + name: "YouTube", + sourceUrls: [ + "https://support.google.com/youtube/answer/1722171", + "https://support.google.com/youtube/answer/71673", + ], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { + aspect: "16:9", + width: 1920, + height: 1080, + fps: 60, + codec: "h264", + maxBitrateKbps: 8000, + maxFileSizeMb: 4096, + }, + duration: { hardMinMs: 1000, hardMaxMs: 900_000 }, + safeZones: FRAMED_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + "facebook-reels": { + id: "facebook-reels", + name: "Facebook Reels", + sourceUrls: ["https://www.facebook.com/help/", "https://www.facebook.com/business/ads-guide/"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 5000, maxFileSizeMb: 1024 }, + duration: { hardMinMs: 3000, hardMaxMs: 90_000 }, + safeZones: VERTICAL_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + linkedin: { + id: "linkedin", + name: "LinkedIn", + sourceUrls: ["https://www.linkedin.com/help/linkedin/"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { + aspect: "16:9", + width: 1920, + height: 1080, + fps: 30, + codec: "h264", + maxBitrateKbps: 8000, + maxFileSizeMb: 5000, + }, + duration: { hardMinMs: 3000, hardMaxMs: 600_000 }, + safeZones: FRAMED_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + "x-twitter": { + id: "x-twitter", + name: "X", + sourceUrls: ["https://help.x.com/en/using-x/x-videos"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { + aspect: "16:9", + width: 1280, + height: 720, + fps: 30, + codec: "h264", + maxBitrateKbps: 5000, + maxFileSizeMb: 512, + }, + duration: { hardMinMs: 1000, hardMaxMs: 140_000 }, + safeZones: FRAMED_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + "pinterest-video-pin": { + id: "pinterest-video-pin", + name: "Pinterest", + sourceUrls: ["https://help.pinterest.com/en/business"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 8000, maxFileSizeMb: 2048 }, + duration: { hardMinMs: 4000, hardMaxMs: 300_000 }, + safeZones: VERTICAL_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, + "snapchat-spotlight": { + id: "snapchat-spotlight", + name: "Snapchat Spotlight", + sourceUrls: ["https://help.snapchat.com/hc/en-us"], + reviewedAt: REVIEWED_AT, + unverified: true, + video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 5000, maxFileSizeMb: 250 }, + duration: { hardMinMs: 5000, hardMaxMs: 60_000 }, + safeZones: VERTICAL_SAFE_ZONES, + audio: STREAMING_LOUDNESS, + }, +}; diff --git a/packages/contracts/src/media-presets.ts b/packages/contracts/src/media-presets.ts new file mode 100644 index 0000000..9f0949c --- /dev/null +++ b/packages/contracts/src/media-presets.ts @@ -0,0 +1,28 @@ +import * as z from "zod"; + +/** + * Output formats for video cuts and exports. Width and height are the encoded frame; every + * preset encodes H.264 + AAC in MP4 so the result plays everywhere it is posted. + */ +export const MEDIA_PRESETS = { + vertical: { aspect: "9:16", width: 1080, height: 1920 }, + landscape: { aspect: "16:9", width: 1920, height: 1080 }, + square: { aspect: "1:1", width: 1080, height: 1080 }, + portrait: { aspect: "4:5", width: 1080, height: 1350 }, + classic: { aspect: "4:3", width: 1440, height: 1080 }, + cinema: { aspect: "21:9", width: 2560, height: 1080 }, +} as const; +export const MediaPresetIdSchema = z.enum([ + "vertical", + "landscape", + "square", + "portrait", + "classic", + "cinema", +]); +export type MediaPresetId = z.infer; +export type MediaPreset = (typeof MEDIA_PRESETS)[MediaPresetId]; + +/** How a frame of another aspect ratio fills a preset. */ +export const MediaFitSchema = z.enum(["crop", "pad", "blur"]); +export type MediaFit = z.infer; diff --git a/packages/contracts/src/media-video.test.ts b/packages/contracts/src/media-video.test.ts index eb3fc17..0499442 100644 --- a/packages/contracts/src/media-video.test.ts +++ b/packages/contracts/src/media-video.test.ts @@ -42,15 +42,42 @@ describe("edit_video input", () => { }); describe("export_video input", () => { - it("needs at least one preset and defaults to cropping", () => { + it("needs at least one preset or platform and defaults to cropping", () => { expect(ExportVideoInputSchema.parse({ artifact_id: "a", presets: ["square"] })).toEqual({ artifact_id: "a", presets: ["square"], fit: "crop", }); + expect(ExportVideoInputSchema.parse({ artifact_id: "a", platforms: ["tiktok"] })).toEqual({ + artifact_id: "a", + platforms: ["tiktok"], + fit: "crop", + }); expect(ExportVideoInputSchema.safeParse({ artifact_id: "a", presets: [] }).success).toBe(false); + expect(ExportVideoInputSchema.safeParse({ artifact_id: "a" }).success).toBe(false); expect(ExportVideoInputSchema.safeParse({ artifact_id: "a", presets: ["imax"] }).success).toBe( false, ); + expect( + ExportVideoInputSchema.safeParse({ artifact_id: "a", platforms: ["myspace"] }).success, + ).toBe(false); + }); + + it("takes at most six targets across platforms and presets", () => { + const platforms = ["tiktok", "youtube-shorts", "instagram-reels", "linkedin"]; + expect( + ExportVideoInputSchema.safeParse({ + artifact_id: "a", + platforms, + presets: ["square", "portrait"], + }).success, + ).toBe(true); + const tooMany = ExportVideoInputSchema.safeParse({ + artifact_id: "a", + platforms, + presets: ["square", "portrait", "classic"], + }); + expect(tooMany.success).toBe(false); + expect(tooMany.error?.issues[0]?.path).toEqual(["platforms"]); }); }); diff --git a/packages/contracts/src/media-video.ts b/packages/contracts/src/media-video.ts index a12bf51..29fb141 100644 --- a/packages/contracts/src/media-video.ts +++ b/packages/contracts/src/media-video.ts @@ -1,6 +1,7 @@ import * as z from "zod"; import { Id } from "./ids.js"; -import { MediaFitSchema, MediaPresetIdSchema } from "./media.js"; +import { MediaPlatformIdSchema } from "./media-platforms.js"; +import { MediaFitSchema, MediaPresetIdSchema } from "./media-presets.js"; /** * Inputs of the `edit_video` and `export_video` media operations, which are also the arguments @@ -71,10 +72,25 @@ export const EditVideoInputSchema = z }); export type EditVideoInput = z.infer; -export const ExportVideoInputSchema = z.object({ - artifact_id: Id, - presets: z.array(MediaPresetIdSchema).min(1).max(6), - fit: MediaFitSchema.default("crop"), - title: MediaTitleSchema.optional(), -}); +export const MAX_EXPORT_TARGETS = 6; + +/** One output per platform and per preset: at least one, at most MAX_EXPORT_TARGETS in all. */ +export const ExportVideoInputSchema = z + .object({ + artifact_id: Id, + platforms: z.array(MediaPlatformIdSchema).max(MAX_EXPORT_TARGETS).optional(), + presets: z.array(MediaPresetIdSchema).max(MAX_EXPORT_TARGETS).optional(), + fit: MediaFitSchema.default("crop"), + title: MediaTitleSchema.optional(), + }) + .refine( + (input) => { + const targets = (input.platforms?.length ?? 0) + (input.presets?.length ?? 0); + return targets >= 1 && targets <= MAX_EXPORT_TARGETS; + }, + { + path: ["platforms"], + message: `name 1 to ${MAX_EXPORT_TARGETS} platforms and presets in all`, + }, + ); export type ExportVideoInput = z.infer; diff --git a/packages/contracts/src/media.ts b/packages/contracts/src/media.ts index 3654e9e..ad27f51 100644 --- a/packages/contracts/src/media.ts +++ b/packages/contracts/src/media.ts @@ -1,5 +1,11 @@ import * as z from "zod"; import { Id } from "./ids.js"; +import { + MediaCheckSchema, + MediaPlatformIdSchema, + MediaSkippedTargetSchema, +} from "./media-platforms.js"; +import { MediaPresetIdSchema } from "./media-presets.js"; /** What a media job does. Generation runs on the configured image/video backend, edits run locally. */ export const MediaOperationSchema = z.enum([ @@ -32,33 +38,6 @@ export const MEDIA_JOB_ACTIVE_STATUSES = [ export const MediaOutputKindSchema = z.enum(["image", "video", "audio", "file"]); export type MediaOutputKind = z.infer; -/** - * Output formats for video cuts and exports. Width and height are the encoded frame; every - * preset encodes H.264 + AAC in MP4 so the result plays everywhere it is posted. - */ -export const MEDIA_PRESETS = { - vertical: { aspect: "9:16", width: 1080, height: 1920 }, - landscape: { aspect: "16:9", width: 1920, height: 1080 }, - square: { aspect: "1:1", width: 1080, height: 1080 }, - portrait: { aspect: "4:5", width: 1080, height: 1350 }, - classic: { aspect: "4:3", width: 1440, height: 1080 }, - cinema: { aspect: "21:9", width: 2560, height: 1080 }, -} as const; -export const MediaPresetIdSchema = z.enum([ - "vertical", - "landscape", - "square", - "portrait", - "classic", - "cinema", -]); -export type MediaPresetId = z.infer; -export type MediaPreset = (typeof MEDIA_PRESETS)[MediaPresetId]; - -/** How a frame of another aspect ratio fills a preset. */ -export const MediaFitSchema = z.enum(["crop", "pad", "blur"]); -export type MediaFit = z.infer; - export const MediaOutputSchema = z.object({ artifactId: Id, kind: MediaOutputKindSchema, @@ -72,6 +51,9 @@ export const MediaOutputSchema = z.object({ /** The artifact this output was made from, for version history. */ parentArtifactId: Id.optional(), preset: MediaPresetIdSchema.optional(), + /** The platform it was exported for, and how it measured against that platform's limits. */ + platform: MediaPlatformIdSchema.optional(), + check: MediaCheckSchema.optional(), }); export type MediaOutput = z.infer; @@ -86,6 +68,8 @@ export const MediaJobBlockSchema = z.object({ progress: z.number().min(0).max(1).optional(), error: z.string().optional(), outputs: z.array(MediaOutputSchema), + /** Platforms an export did not render, each with the reason. */ + skipped: z.array(MediaSkippedTargetSchema).optional(), }); export type MediaJobBlock = z.infer; From 45856b1af9be9c3b93a6caf42d6c272d09cdceaf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:53:01 +0000 Subject: [PATCH 2/3] Offer platforms in the media card's export menu and show their checks The card's Export menu now lists the platforms first, by brand name and untranslated, then the aspect ratios after a separator; the platform an output was made for is left out, like its own shape. A platform export shows the platform's name under the player, or, when its check did not pass, one line for the first issue instead ("Too long for TikTok (max 10:00)"), in the warning or destructive color. Skipped platforms get the same kind of line, and they stand in for the error of a job that skipped every platform, so the card does not say the same thing twice in two languages. The E2E spec seeds a platform export with a failed check and a skipped platform, and captures the card and the open menu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/media-card.spec.ts | 141 +++++++++++++++--- .../src/components/media/MediaCard.test.tsx | 63 ++++++++ apps/web/src/components/media/MediaCard.tsx | 55 ++++++- .../components/media/media-platforms.test.ts | 75 ++++++++++ .../src/components/media/media-platforms.ts | 72 +++++++++ apps/web/src/locales/de/messages.po | 40 +++++ apps/web/src/locales/en/messages.po | 40 +++++ apps/web/src/locales/es/messages.po | 40 +++++ apps/web/src/locales/hi/messages.po | 40 +++++ apps/web/src/locales/ko/messages.po | 40 +++++ apps/web/src/locales/pt-BR/messages.po | 40 +++++ apps/web/src/locales/ru/messages.po | 40 +++++ apps/web/src/locales/tr/messages.po | 40 +++++ apps/web/src/locales/zh-CN/messages.po | 40 +++++ 14 files changed, 739 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/media/media-platforms.test.ts create mode 100644 apps/web/src/components/media/media-platforms.ts diff --git a/apps/web/e2e/media-card.spec.ts b/apps/web/e2e/media-card.spec.ts index 3b46bbe..6697d4a 100644 --- a/apps/web/e2e/media-card.spec.ts +++ b/apps/web/e2e/media-card.spec.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import { @@ -92,6 +95,38 @@ async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlo } } +/** CI has no ffmpeg or transcription server; the card asks what the space can run. */ +async function offerFollowUps(page: Page) { + await page.route("**/rpc/media/capabilities", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + json: { + generate_image: false, + generate_video: false, + edit_image: false, + edit_video: false, + export_video: true, + transcribe: true, + }, + }), + }), + ); +} + +/** Records follow-up jobs the card starts instead of running them. */ +async function recordStartedJobs(page: Page): Promise { + const started: unknown[] = []; + await page.route("**/rpc/media/jobs/start", async (route) => { + started.push((route.request().postDataJSON() as { json: unknown }).json); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }), + }); + }); + return started; +} + test("renders a media job card while it runs and when it is done", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card"); @@ -150,30 +185,8 @@ test("renders a media job card while it runs and when it is done", async ({ page ], }, ); - // CI has no transcription server; the card asks what the space can run. - await page.route("**/rpc/media/capabilities", (route) => - route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - json: { - generate_image: false, - generate_video: false, - edit_image: false, - edit_video: false, - export_video: true, - transcribe: true, - }, - }), - }), - ); - const started: unknown[] = []; - await page.route("**/rpc/media/jobs/start", async (route) => { - started.push((route.request().postDataJSON() as { json: unknown }).json); - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }), - }); - }); + await offerFollowUps(page); + const started = await recordStartedJobs(page); await page.reload(); const card = page.getByTestId("media-card"); @@ -209,3 +222,83 @@ test("renders a media job card while it runs and when it is done", async ({ page input: { artifact_id: voice.id, title: "voice-note.wav" }, }); }); + +test("a platform export names its platform, shows a failed check and exports for others", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `media-platform-${stamp}@rakazo.test`, "password12", "Media Platform"); + await completeOnboarding(page); + const botId = activeBotId(page); + + const fixture = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "media-clip.mp4", + ); + const clip = await rpc<{ id: string; size: number }>(page, "artifacts/create", { + botId, + name: "clip-tiktok.mp4", + mimeType: "video/mp4", + contentBase64: readFileSync(fixture).toString("base64"), + }); + await seedMediaMessage(page, botId, () => ({ + kind: "media", + jobId: "media-job-platforms", + operation: "export_video", + status: "done", + title: "TikTok · Snapchat Spotlight", + outputs: [ + { + artifactId: clip.id, + kind: "video", + mimeType: "video/mp4", + name: "clip-tiktok.mp4", + size: clip.size, + width: 1080, + height: 1920, + durationMs: 1000, + platform: "tiktok", + check: { + status: "fail", + issues: [{ code: "file_too_large", maxMb: 287, actualMb: 301.2 }], + }, + }, + ], + skipped: [ + { + platform: "snapchat-spotlight", + issues: [{ code: "too_short", minMs: 5000, actualMs: 1000 }], + }, + ], + })); + await offerFollowUps(page); + const started = await recordStartedJobs(page); + await page.reload(); + + const card = page.getByTestId("media-card"); + await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 }); + await expect(card).toContainText("Too large for TikTok (max 287 MB)"); + await expect(card).toContainText("Too short for Snapchat Spotlight (min 0:05)"); + await captureScreenshot(page, testInfo, "media-card-platform-check"); + + await card.getByRole("button", { name: "Export" }).click(); + const menu = page.getByRole("menu"); + await expect(menu.getByRole("menuitem", { name: "YouTube Shorts" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Instagram Reels" })).toBeVisible(); + // Not the platform or the shape it already has. + await expect(menu.getByRole("menuitem", { name: "TikTok" })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: "9:16" })).toHaveCount(0); + await expect(menu.getByRole("separator")).toHaveCount(1); + await expect(menu.getByRole("menuitem", { name: "1:1" })).toBeVisible(); + await captureScreenshot(page, testInfo, "media-card-platform-export-menu"); + + await menu.getByRole("menuitem", { name: "YouTube Shorts" }).click(); + await expect.poll(() => started.length).toBe(1); + expect(started[0]).toEqual({ + botId, + messageId: expect.stringMatching(/^media-message-\d+$/), + operation: "export_video", + input: { artifact_id: clip.id, platforms: ["youtube-shorts"] }, + }); +}); diff --git a/apps/web/src/components/media/MediaCard.test.tsx b/apps/web/src/components/media/MediaCard.test.tsx index f1d4ce0..eb9513d 100644 --- a/apps/web/src/components/media/MediaCard.test.tsx +++ b/apps/web/src/components/media/MediaCard.test.tsx @@ -8,6 +8,10 @@ vi.mock("@lingui/react/macro", () => { parts.reduce((text, part, index) => `${text}${part}${values[index] ?? ""}`, ""); return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children }; }); +vi.mock("@lingui/core/macro", () => ({ + t: (parts: TemplateStringsArray, ...values: unknown[]) => + parts.reduce((out, part, index) => `${out}${String(values[index - 1])}${part}`), +})); vi.mock("../../pages/shell/message-cards", () => ({ ArtifactImage: ({ artifactId, name }: { artifactId: string; name: string }) => ( {name} @@ -147,6 +151,65 @@ describe("media card", () => { expect(html).not.toContain("Transcribe"); }); + it("names the platform of an export, and its first problem only when the check did not pass", () => { + const tiktok = { + ...video, + artifactId: "tiktok-1", + platform: "tiktok" as const, + check: { status: "pass" as const, issues: [] }, + }; + const shorts = { + ...video, + artifactId: "shorts-1", + platform: "youtube-shorts" as const, + check: { + status: "warn" as const, + issues: [ + { code: "loudness_off" as const, targetLufs: -14, actualLufs: -18 }, + { code: "true_peak_high" as const, maxDb: -1, actualDb: 0 }, + ], + }, + }; + const html = renderToString( + , + ); + expect(html).toContain('

TikTok

'); + expect(html).toContain( + '

Too quiet for YouTube Shorts (target -14 LUFS)

', + ); + expect(html).not.toContain("Peaks too high"); + expect(html.match(/text-ui-body-sm text-(muted-foreground|warning|destructive)/g)).toHaveLength( + 2, + ); + }); + + it("lists skipped platforms, which also explain a job that rendered nothing", () => { + const skipped = [ + { + platform: "snapchat-spotlight" as const, + issues: [{ code: "too_long" as const, maxMs: 60_000, actualMs: 75_000 }], + }, + ]; + const partial = renderToString( + , + ); + expect(partial).toContain("Too long for Snapchat Spotlight (max 1:00)"); + const failed = renderToString( + , + ); + expect(failed).toContain("Too long for Snapchat Spotlight (max 1:00)"); + expect(failed).not.toContain("Not exported"); + }); + it("shows why a job failed", () => { const html = renderToString( , diff --git a/apps/web/src/components/media/MediaCard.tsx b/apps/web/src/components/media/MediaCard.tsx index 80e7db4..85375eb 100644 --- a/apps/web/src/components/media/MediaCard.tsx +++ b/apps/web/src/components/media/MediaCard.tsx @@ -4,6 +4,7 @@ import type { MediaJobStatus, MediaOperation, MediaOutput, + MediaPlatformId, MediaPresetId, } from "@rakazo/contracts"; import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts"; @@ -14,6 +15,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@rakazo/ui-web"; import type { LucideIcon } from "lucide-react"; @@ -26,12 +28,14 @@ import { ArtifactImage } from "../../pages/shell/message-cards"; import { ArtifactFileCard } from "../ArtifactFileCard"; import { Shimmer } from "../ai/primitives"; import { MediaPlayer } from "./MediaPlayer"; +import { exportPlatformsFor, platformIssueLine, platformName } from "./media-platforms"; const CARD_CLASS = "w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 text-start shadow-xs"; export type MediaCardAction = | { kind: "export"; artifactId: string; preset: MediaPresetId } + | { kind: "export"; artifactId: string; platform: MediaPlatformId } | { kind: "transcribe"; artifactId: string; name: string } | { kind: "cancel" }; @@ -114,6 +118,7 @@ function OutputActions({ }) { const { t } = useLingui(); const name = output.name; + const platforms = output.kind === "video" ? exportPlatformsFor(output) : []; const presets = output.kind === "video" ? exportPresetsFor(output) : []; return (
@@ -129,7 +134,7 @@ function OutputActions({ > - {onAction && available.export && presets.length > 0 ? ( + {onAction && available.export && platforms.length + presets.length > 0 ? ( + {platforms.map((platform) => ( + + onAction({ kind: "export", artifactId: output.artifactId, platform }) + } + > + {platformName(platform)} + + ))} + {platforms.length > 0 && presets.length > 0 ? : null} {presets.map((preset) => ( + {issue ? platformIssueLine(output.platform, issue) : platformName(output.platform)} +

+ ); +} + /** * The live card of a media job: what it makes, how far it is, and the results with the * follow-ups that fit them (export other formats, transcribe). Nothing else is shown. @@ -191,6 +227,10 @@ export function MediaCardView({ const percent = Math.round((block.progress ?? 0) * 100); const images = block.outputs.filter((output) => output.kind === "image"); const others = block.outputs.filter((output) => output.kind !== "image"); + const skipped = block.skipped ?? []; + // When nothing was rendered because every platform was skipped, the skipped lines say why. + const showError = + block.status === "failed" && block.error && !(skipped.length && !block.outputs.length); const act = onAction ? (action: MediaCardAction) => { @@ -251,7 +291,7 @@ export function MediaCardView({ />
) : null} - {block.status === "failed" && block.error ? ( + {showError ? (

{block.error}

@@ -279,6 +319,7 @@ export function MediaCardView({ width={output.width} height={output.height} /> + {active ? null : ( )} @@ -293,6 +334,11 @@ export function MediaCardView({ )} ))} + {skipped.map((target) => ( +

+ {platformIssueLine(target.platform, target.issues[0]!)} +

+ ))} {actionError ? (

{actionError} @@ -383,7 +429,10 @@ export function MediaCard({ ...(action.kind === "export" ? { operation: "export_video", - input: { artifact_id: action.artifactId, presets: [action.preset] }, + input: + "platform" in action + ? { artifact_id: action.artifactId, platforms: [action.platform] } + : { artifact_id: action.artifactId, presets: [action.preset] }, } : // The new card is titled with the file it transcribes. { diff --git a/apps/web/src/components/media/media-platforms.test.ts b/apps/web/src/components/media/media-platforms.test.ts new file mode 100644 index 0000000..4906f0c --- /dev/null +++ b/apps/web/src/components/media/media-platforms.test.ts @@ -0,0 +1,75 @@ +import type { MediaCheckIssue } from "@rakazo/contracts"; +import { describe, expect, it, vi } from "vitest"; + +// The macros compile at build time; here the English source comes back with its values filled in. +vi.mock("@lingui/core/macro", () => ({ + t: (parts: TemplateStringsArray, ...values: unknown[]) => + parts.reduce((out, part, index) => `${out}${String(values[index - 1])}${part}`), +})); + +import { + clockDuration, + exportPlatformsFor, + platformIssueLine, + platformName, +} from "./media-platforms"; + +describe("export platforms", () => { + it("offers every platform but the one the video was exported for, by brand name", () => { + expect(exportPlatformsFor({})).toHaveLength(10); + expect(exportPlatformsFor({ platform: "tiktok" })).not.toContain("tiktok"); + expect(exportPlatformsFor({}).slice(0, 4).map(platformName)).toEqual([ + "TikTok", + "Instagram Reels", + "Instagram Feed", + "YouTube Shorts", + ]); + }); +}); + +describe("platform issue lines", () => { + it("formats limits as a clock", () => { + expect(clockDuration(600_000)).toBe("10:00"); + expect(clockDuration(5000)).toBe("0:05"); + expect(clockDuration(3_723_000)).toBe("1:02:03"); + }); + + it.each<[MediaCheckIssue, string]>([ + [{ code: "too_long", maxMs: 600_000, actualMs: 720_000 }, "Too long for TikTok (max 10:00)"], + [{ code: "too_short", minMs: 3000, actualMs: 1000 }, "Too short for TikTok (min 0:03)"], + [{ code: "file_too_large", maxMb: 287, actualMb: 301.2 }, "Too large for TikTok (max 287 MB)"], + [ + { code: "bitrate_high", maxKbps: 8000, actualKbps: 9500 }, + "Bitrate too high for TikTok (max 8,000 kbps)", + ], + [ + { code: "loudness_off", targetLufs: -14, actualLufs: -18.2 }, + "Too quiet for TikTok (target -14 LUFS)", + ], + [ + { code: "loudness_off", targetLufs: -14, actualLufs: -9.5 }, + "Too loud for TikTok (target -14 LUFS)", + ], + [ + { code: "true_peak_high", maxDb: -1, actualDb: 0.2 }, + "Peaks too high for TikTok (max -1 dBTP)", + ], + [ + { + code: "resolution_mismatch", + width: 1080, + height: 1920, + actualWidth: 720, + actualHeight: 1280, + }, + "Wrong size for TikTok (needs 1080×1920)", + ], + [ + { code: "fps_mismatch", maxFps: 30, actualFps: 60 }, + "Frame rate too high for TikTok (max 30 fps)", + ], + [{ code: "codec_mismatch" }, "Wrong format for TikTok (needs H.264)"], + ])("words %j in one short line", (issue, line) => { + expect(platformIssueLine("tiktok", issue)).toBe(line); + }); +}); diff --git a/apps/web/src/components/media/media-platforms.ts b/apps/web/src/components/media/media-platforms.ts new file mode 100644 index 0000000..ad60f6a --- /dev/null +++ b/apps/web/src/components/media/media-platforms.ts @@ -0,0 +1,72 @@ +import { i18n } from "@lingui/core"; +import { t } from "@lingui/core/macro"; +import type { MediaCheckIssue, MediaOutput, MediaPlatformId } from "@rakazo/contracts"; +import { MEDIA_PLATFORM_PROFILES, MediaPlatformIdSchema } from "@rakazo/contracts"; + +/** Platforms a video can be exported for: all but the one it was exported for. */ +export function exportPlatformsFor(output: Pick): MediaPlatformId[] { + return MediaPlatformIdSchema.options.filter((id) => id !== output.platform); +} + +/** The platform's brand name, never translated. */ +export function platformName(platform: MediaPlatformId): string { + return MEDIA_PLATFORM_PROFILES[platform].name; +} + +/** "10:00", or "1:02:03" past an hour. */ +export function clockDuration(ms: number): string { + const total = Math.round(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = String(total % 60).padStart(2, "0"); + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` + : `${minutes}:${seconds}`; +} + +function amount(value: number): string { + return new Intl.NumberFormat(i18n.locale || "en", { maximumFractionDigits: 1 }).format(value); +} + +/** One short line for a check issue, e.g. "Too long for TikTok (max 10:00)". */ +export function platformIssueLine(platform: MediaPlatformId, issue: MediaCheckIssue): string { + const name = platformName(platform); + switch (issue.code) { + case "too_long": { + const max = clockDuration(issue.maxMs); + return t`Too long for ${name} (max ${max})`; + } + case "too_short": { + const min = clockDuration(issue.minMs); + return t`Too short for ${name} (min ${min})`; + } + case "file_too_large": { + const max = amount(issue.maxMb); + return t`Too large for ${name} (max ${max} MB)`; + } + case "bitrate_high": { + const max = amount(issue.maxKbps); + return t`Bitrate too high for ${name} (max ${max} kbps)`; + } + case "loudness_off": { + const target = amount(issue.targetLufs); + return issue.actualLufs < issue.targetLufs + ? t`Too quiet for ${name} (target ${target} LUFS)` + : t`Too loud for ${name} (target ${target} LUFS)`; + } + case "true_peak_high": { + const max = amount(issue.maxDb); + return t`Peaks too high for ${name} (max ${max} dBTP)`; + } + case "resolution_mismatch": { + const size = `${issue.width}×${issue.height}`; + return t`Wrong size for ${name} (needs ${size})`; + } + case "fps_mismatch": { + const max = amount(issue.maxFps); + return t`Frame rate too high for ${name} (max ${max} fps)`; + } + case "codec_mismatch": + return t`Wrong format for ${name} (needs H.264)`; + } +} diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index 3a4c2c7..475ab5f 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -830,6 +830,10 @@ msgstr "Bearer-Token" msgid "Bell" msgstr "Glöckchen" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "Bitrate zu hoch für {name} (maximal {max} kbps)" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "In deinen Browser-Einstellungen blockiert" @@ -2539,6 +2543,10 @@ msgstr "Passwort vergessen?" msgid "Forward" msgstr "Vorwärts" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "Bildrate zu hoch für {name} (maximal {max} fps)" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "Sommersprossen" @@ -3833,6 +3841,10 @@ msgstr "Pfote" msgid "Peach" msgstr "Pfirsich" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "Pegelspitzen zu hoch für {name} (maximal {max} dBTP)" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "Pinguin" @@ -5093,6 +5105,26 @@ msgstr "Heute" msgid "Today, {heuteActionCount} need you" msgstr "Heute, {heuteActionCount} brauchen dich" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "Zu groß für {name} (maximal {max} MB)" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "Zu lang für {name} (maximal {max})" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "Zu laut für {name} (Zielwert {target} LUFS)" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "Zu leise für {name} (Zielwert {target} LUFS)" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "Zu kurz für {name} (mindestens {min})" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "Toolquellen" @@ -5516,6 +5548,14 @@ msgstr "Arbeitet…" msgid "Working…" msgstr "Wird verarbeitet…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "Falsches Format für {name} (braucht H.264)" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "Falsche Größe für {name} (braucht {size})" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "Gestern" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 85a0267..f7c88f4 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -830,6 +830,10 @@ msgstr "Bearer token" msgid "Bell" msgstr "Bell" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "Bitrate too high for {name} (max {max} kbps)" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "Blocked in your browser settings" @@ -2539,6 +2543,10 @@ msgstr "Forgot password?" msgid "Forward" msgstr "Forward" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "Frame rate too high for {name} (max {max} fps)" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "Freckles" @@ -3833,6 +3841,10 @@ msgstr "Paw" msgid "Peach" msgstr "Peach" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "Peaks too high for {name} (max {max} dBTP)" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "Penguin" @@ -5093,6 +5105,26 @@ msgstr "Today" msgid "Today, {heuteActionCount} need you" msgstr "Today, {heuteActionCount} need you" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "Too large for {name} (max {max} MB)" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "Too long for {name} (max {max})" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "Too loud for {name} (target {target} LUFS)" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "Too quiet for {name} (target {target} LUFS)" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "Too short for {name} (min {min})" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "Tool sources" @@ -5516,6 +5548,14 @@ msgstr "Working…" msgid "Working…" msgstr "Working…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "Wrong format for {name} (needs H.264)" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "Wrong size for {name} (needs {size})" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "Yesterday" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 214143b..18419fe 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -830,6 +830,10 @@ msgstr "Token Bearer" msgid "Bell" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "" @@ -2539,6 +2543,10 @@ msgstr "¿Olvidaste la contraseña?" msgid "Forward" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "" @@ -3833,6 +3841,10 @@ msgstr "" msgid "Peach" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "" @@ -5093,6 +5105,26 @@ msgstr "" msgid "Today, {heuteActionCount} need you" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "Fuentes de herramientas" @@ -5516,6 +5548,14 @@ msgstr "Trabajando…" msgid "Working…" msgstr "Trabajando…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "Ayer" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 51e1b09..ca3108b 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -830,6 +830,10 @@ msgstr "बियरर टोकन" msgid "Bell" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "" @@ -2539,6 +2543,10 @@ msgstr "पासवर्ड भूल गए?" msgid "Forward" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "" @@ -3833,6 +3841,10 @@ msgstr "" msgid "Peach" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "" @@ -5093,6 +5105,26 @@ msgstr "" msgid "Today, {heuteActionCount} need you" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "टूल स्रोत" @@ -5516,6 +5548,14 @@ msgstr "काम चल रहा है…" msgid "Working…" msgstr "काम चल रहा है…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "कल" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 478118c..fef5901 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -830,6 +830,10 @@ msgstr "Bearer 토큰" msgid "Bell" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "" @@ -2539,6 +2543,10 @@ msgstr "비밀번호를 잊으셨나요?" msgid "Forward" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "" @@ -3833,6 +3841,10 @@ msgstr "" msgid "Peach" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "" @@ -5093,6 +5105,26 @@ msgstr "" msgid "Today, {heuteActionCount} need you" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "도구 소스" @@ -5516,6 +5548,14 @@ msgstr "처리 중…" msgid "Working…" msgstr "처리 중…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "어제" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 0b3193e..310aaef 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -830,6 +830,10 @@ msgstr "Token portador" msgid "Bell" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "" @@ -2539,6 +2543,10 @@ msgstr "Esqueceu a senha?" msgid "Forward" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "" @@ -3833,6 +3841,10 @@ msgstr "" msgid "Peach" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "" @@ -5093,6 +5105,26 @@ msgstr "" msgid "Today, {heuteActionCount} need you" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "Fontes de ferramentas" @@ -5516,6 +5548,14 @@ msgstr "Funcionando" msgid "Working…" msgstr "Funcionando" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "Ontem" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 026f751..9cc5bf3 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -830,6 +830,10 @@ msgstr "Bearer-токен" msgid "Bell" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "" @@ -2539,6 +2543,10 @@ msgstr "Забыли пароль?" msgid "Forward" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "" @@ -3833,6 +3841,10 @@ msgstr "" msgid "Peach" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "" @@ -5093,6 +5105,26 @@ msgstr "" msgid "Today, {heuteActionCount} need you" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "Источники инструментов" @@ -5516,6 +5548,14 @@ msgstr "Выполняется…" msgid "Working…" msgstr "Выполняется…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "Вчера" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index 9b782c4..b696484 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -830,6 +830,10 @@ msgstr "Bearer token" msgid "Bell" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "" @@ -2539,6 +2543,10 @@ msgstr "Parolanızı mı unuttunuz?" msgid "Forward" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "" @@ -3833,6 +3841,10 @@ msgstr "" msgid "Peach" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "" @@ -5093,6 +5105,26 @@ msgstr "" msgid "Today, {heuteActionCount} need you" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "Araç kaynakları" @@ -5516,6 +5548,14 @@ msgstr "Çalışıyor…" msgid "Working…" msgstr "Çalışıyor…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "Dün" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 4932ef1..42ccc7e 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -830,6 +830,10 @@ msgstr "Bearer token" msgid "Bell" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Bitrate too high for {name} (max {max} kbps)" +msgstr "" + #: src/components/NotificationSettingsPanel.tsx msgid "Blocked in your browser settings" msgstr "" @@ -2539,6 +2543,10 @@ msgstr "忘记密码?" msgid "Forward" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Frame rate too high for {name} (max {max} fps)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Freckles" msgstr "" @@ -3833,6 +3841,10 @@ msgstr "" msgid "Peach" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Peaks too high for {name} (max {max} dBTP)" +msgstr "" + #: src/components/OoAtelier.tsx msgid "Penguin" msgstr "" @@ -5093,6 +5105,26 @@ msgstr "" msgid "Today, {heuteActionCount} need you" msgstr "" +#: src/components/media/media-platforms.ts +msgid "Too large for {name} (max {max} MB)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too long for {name} (max {max})" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too loud for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too quiet for {name} (target {target} LUFS)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Too short for {name} (min {min})" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Tool sources" msgstr "工具源" @@ -5516,6 +5548,14 @@ msgstr "处理中…" msgid "Working…" msgstr "处理中…" +#: src/components/media/media-platforms.ts +msgid "Wrong format for {name} (needs H.264)" +msgstr "" + +#: src/components/media/media-platforms.ts +msgid "Wrong size for {name} (needs {size})" +msgstr "" + #: src/pages/shell/roster-format.ts msgid "Yesterday" msgstr "昨天" From 94074829032e8d24924abc90442d342cb317b555 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:57:39 +0000 Subject: [PATCH 3/3] fix(media): keep one skipped entry per platform on the card A job that recovers after a crash runs again and reports the same skipped platforms; the newest reason per platform now replaces the earlier one instead of listing it twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .../src/media-runtime.postgres.test.ts | 19 +++++++++++++++++++ packages/adapters/src/media-runtime.ts | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/adapters/src/media-runtime.postgres.test.ts b/packages/adapters/src/media-runtime.postgres.test.ts index 2852dbe..b6b7d74 100644 --- a/packages/adapters/src/media-runtime.postgres.test.ts +++ b/packages/adapters/src/media-runtime.postgres.test.ts @@ -335,6 +335,25 @@ describePostgres("media job runtime (PostgreSQL, fake operations)", () => { expect(existsSync(tempDir)).toBe(false); }); + it("keeps one skipped entry per platform when a job reports it again", async () => { + const tooLong = { code: "too_long", maxMs: 600_000, actualMs: 700_000 } as const; + const tooShort = { code: "too_short", minMs: 3_000, actualMs: 1_000 } as const; + const ctx = await setup({ + run: async (context) => { + await context.addSkipped({ platform: "tiktok", issues: [tooLong] }); + await context.addSkipped({ platform: "tiktok", issues: [tooShort] }); + await context.addSkipped({ platform: "youtube", issues: [tooShort] }); + return { summary: "Skipped." }; + }, + }); + const { jobId } = await ctx.start({ prompt: "fox" }); + await ctx.runtime.runJob(jobId); + expect((await ctx.card(jobId)).skipped).toEqual([ + { platform: "tiktok", issues: [tooShort] }, + { platform: "youtube", issues: [tooShort] }, + ]); + }); + it("refuses outputs outside the temp dir and sources of another user", async () => { const other = await setup(); const foreign = await other.sourceArtifact(); diff --git a/packages/adapters/src/media-runtime.ts b/packages/adapters/src/media-runtime.ts index 4817154..4eb7702 100644 --- a/packages/adapters/src/media-runtime.ts +++ b/packages/adapters/src/media-runtime.ts @@ -370,7 +370,15 @@ export function createMediaRuntime( if (!message) return { exists: false, outputs: appended }; const previous = mediaBlockOf(message.blocks, job.id); const outputs = [...(previous?.outputs ?? []), ...appended]; - const skipped = [...(previous?.skipped ?? []), ...(added.skipped ?? [])]; + // A recovered job runs again, so the newest reason per platform replaces an earlier one. + const skipped = [ + ...new Map( + [...(previous?.skipped ?? []), ...(added.skipped ?? [])].map((entry) => [ + entry.platform, + entry, + ]), + ).values(), + ]; const next = cardBlock(job, outputs, skipped); const current = Array.isArray(message.blocks) ? (message.blocks as MessageBlock[]) : []; const blocks = previous