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 d42f5fa..dec4450 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -836,6 +836,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" @@ -2551,6 +2555,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" @@ -3857,6 +3865,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" @@ -5116,6 +5128,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" @@ -5535,6 +5567,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 4f56b03..e7f8af5 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -836,6 +836,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" @@ -2551,6 +2555,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" @@ -3857,6 +3865,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" @@ -5116,6 +5128,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" @@ -5535,6 +5567,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 42f6f72..d7e1dfc 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -836,6 +836,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 "" @@ -2551,6 +2555,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 "" @@ -3857,6 +3865,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 "" @@ -5116,6 +5128,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" @@ -5535,6 +5567,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 4a29ccb..5f3916b 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -836,6 +836,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 "" @@ -2551,6 +2555,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 "" @@ -3857,6 +3865,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 "" @@ -5116,6 +5128,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 "टूल स्रोत" @@ -5535,6 +5567,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 8eeedda..d80d4a0 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -836,6 +836,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 "" @@ -2551,6 +2555,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 "" @@ -3857,6 +3865,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 "" @@ -5116,6 +5128,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 "도구 소스" @@ -5535,6 +5567,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 0a46646..a053432 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -836,6 +836,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 "" @@ -2551,6 +2555,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 "" @@ -3857,6 +3865,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 "" @@ -5116,6 +5128,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" @@ -5535,6 +5567,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 5c2559d..cee96f9 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -836,6 +836,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 "" @@ -2551,6 +2555,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 "" @@ -3857,6 +3865,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 "" @@ -5116,6 +5128,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 "Источники инструментов" @@ -5535,6 +5567,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 8212fde..4f13318 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -836,6 +836,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 "" @@ -2551,6 +2555,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 "" @@ -3857,6 +3865,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 "" @@ -5116,6 +5128,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ı" @@ -5535,6 +5567,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 31ec6e5..b8db110 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -836,6 +836,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 "" @@ -2551,6 +2555,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 "" @@ -3857,6 +3865,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 "" @@ -5116,6 +5128,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 "工具源" @@ -5535,6 +5567,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/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 ddf8695..14127db 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.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 d699a40..4eb7702 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,16 @@ 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); + // 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 ? current.map((block) => (block === previous ? next : block)) @@ -386,7 +409,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 +431,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 +824,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 +844,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 c184eb7..bf22a3e 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; @@ -212,6 +214,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 }[]; @@ -265,6 +268,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, @@ -272,6 +276,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) } : {}), }; } @@ -505,6 +510,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 5f265fe..928f122 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,6 +18,8 @@ export * from "./media.js"; export * from "./media-generation.js"; export * from "./media-jobs.js"; export * from "./media-library.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;