feat(media): export videos for named platforms and check each file against its limits (#158)

* Export videos for named platforms and check each file against its limits

Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Offer platforms in the media card's export menu and show their checks

The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(media): keep one skipped entry per platform on the card

A job that recovers after a crash runs again and reports the same
skipped platforms; the newest reason per platform now replaces the
earlier one instead of listing it twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 16:49:18 +02:00 committed by GitHub
parent e55207f5d2
commit 23d70971f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2363 additions and 110 deletions

View File

@ -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 type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { 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<unknown[]> {
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) => { test("renders a media job card while it runs and when it is done", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card"); 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 offerFollowUps(page);
await page.route("**/rpc/media/capabilities", (route) => const started = await recordStartedJobs(page);
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 page.reload(); await page.reload();
const card = page.getByTestId("media-card"); 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" }, 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"] },
});
});

View File

@ -8,6 +8,10 @@ vi.mock("@lingui/react/macro", () => {
parts.reduce((text, part, index) => `${text}${part}${values[index] ?? ""}`, ""); parts.reduce((text, part, index) => `${text}${part}${values[index] ?? ""}`, "");
return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children }; 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", () => ({ vi.mock("../../pages/shell/message-cards", () => ({
ArtifactImage: ({ artifactId, name }: { artifactId: string; name: string }) => ( ArtifactImage: ({ artifactId, name }: { artifactId: string; name: string }) => (
<img data-artifact={artifactId} alt={name} /> <img data-artifact={artifactId} alt={name} />
@ -147,6 +151,65 @@ describe("media card", () => {
expect(html).not.toContain("Transcribe"); 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(
<MediaCardView
block={block({ operation: "export_video", outputs: [tiktok, shorts, video] })}
/>,
);
expect(html).toContain('<p class="text-ui-body-sm text-muted-foreground">TikTok</p>');
expect(html).toContain(
'<p class="text-ui-body-sm text-warning">Too quiet for YouTube Shorts (target -14 LUFS)</p>',
);
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(
<MediaCardView block={block({ operation: "export_video", outputs: [video], skipped })} />,
);
expect(partial).toContain("Too long for Snapchat Spotlight (max 1:00)");
const failed = renderToString(
<MediaCardView
block={block({
operation: "export_video",
status: "failed",
error: "Not exported for Snapchat Spotlight: 75 s is longer than its 60 s limit",
skipped,
})}
/>,
);
expect(failed).toContain("Too long for Snapchat Spotlight (max 1:00)");
expect(failed).not.toContain("Not exported");
});
it("shows why a job failed", () => { it("shows why a job failed", () => {
const html = renderToString( const html = renderToString(
<MediaCardView block={block({ status: "failed", error: "GPU server is unreachable" })} />, <MediaCardView block={block({ status: "failed", error: "GPU server is unreachable" })} />,

View File

@ -4,6 +4,7 @@ import type {
MediaJobStatus, MediaJobStatus,
MediaOperation, MediaOperation,
MediaOutput, MediaOutput,
MediaPlatformId,
MediaPresetId, MediaPresetId,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts"; import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts";
@ -14,6 +15,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@rakazo/ui-web"; } from "@rakazo/ui-web";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
@ -26,12 +28,14 @@ import { ArtifactImage } from "../../pages/shell/message-cards";
import { ArtifactFileCard } from "../ArtifactFileCard"; import { ArtifactFileCard } from "../ArtifactFileCard";
import { Shimmer } from "../ai/primitives"; import { Shimmer } from "../ai/primitives";
import { MediaPlayer } from "./MediaPlayer"; import { MediaPlayer } from "./MediaPlayer";
import { exportPlatformsFor, platformIssueLine, platformName } from "./media-platforms";
const CARD_CLASS = const CARD_CLASS =
"w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 text-start shadow-xs"; "w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 text-start shadow-xs";
export type MediaCardAction = export type MediaCardAction =
| { kind: "export"; artifactId: string; preset: MediaPresetId } | { kind: "export"; artifactId: string; preset: MediaPresetId }
| { kind: "export"; artifactId: string; platform: MediaPlatformId }
| { kind: "transcribe"; artifactId: string; name: string } | { kind: "transcribe"; artifactId: string; name: string }
| { kind: "cancel" }; | { kind: "cancel" };
@ -114,6 +118,7 @@ function OutputActions({
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const name = output.name; const name = output.name;
const platforms = output.kind === "video" ? exportPlatformsFor(output) : [];
const presets = output.kind === "video" ? exportPresetsFor(output) : []; const presets = output.kind === "video" ? exportPresetsFor(output) : [];
return ( return (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
@ -129,7 +134,7 @@ function OutputActions({
> >
<Download /> <Download />
</a> </a>
{onAction && available.export && presets.length > 0 ? ( {onAction && available.export && platforms.length + presets.length > 0 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={busy} disabled={busy}
@ -140,6 +145,17 @@ function OutputActions({
<ChevronDown /> <ChevronDown />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-auto min-w-24"> <DropdownMenuContent className="w-auto min-w-24">
{platforms.map((platform) => (
<DropdownMenuItem
key={platform}
onClick={() =>
onAction({ kind: "export", artifactId: output.artifactId, platform })
}
>
{platformName(platform)}
</DropdownMenuItem>
))}
{platforms.length > 0 && presets.length > 0 ? <DropdownMenuSeparator /> : null}
{presets.map((preset) => ( {presets.map((preset) => (
<DropdownMenuItem <DropdownMenuItem
key={preset} key={preset}
@ -168,6 +184,26 @@ function OutputActions({
); );
} }
/** The platform an output was exported for, or its first problem there. */
function PlatformLine({ output }: { output: MediaOutput }) {
if (!output.platform) return null;
const issue = output.check?.status === "pass" ? undefined : output.check?.issues[0];
return (
<p
className={cn(
"text-ui-body-sm",
!issue
? "text-muted-foreground"
: output.check?.status === "fail"
? "text-destructive"
: "text-warning",
)}
>
{issue ? platformIssueLine(output.platform, issue) : platformName(output.platform)}
</p>
);
}
/** /**
* The live card of a media job: what it makes, how far it is, and the results with the * 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. * 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 percent = Math.round((block.progress ?? 0) * 100);
const images = block.outputs.filter((output) => output.kind === "image"); const images = block.outputs.filter((output) => output.kind === "image");
const others = 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 const act = onAction
? (action: MediaCardAction) => { ? (action: MediaCardAction) => {
@ -251,7 +291,7 @@ export function MediaCardView({
/> />
</div> </div>
) : null} ) : null}
{block.status === "failed" && block.error ? ( {showError ? (
<p className="mt-2 text-ui-body-sm text-destructive" dir="auto"> <p className="mt-2 text-ui-body-sm text-destructive" dir="auto">
{block.error} {block.error}
</p> </p>
@ -279,6 +319,7 @@ export function MediaCardView({
width={output.width} width={output.width}
height={output.height} height={output.height}
/> />
<PlatformLine output={output} />
{active ? null : ( {active ? null : (
<OutputActions output={output} available={available} busy={busy} onAction={act} /> <OutputActions output={output} available={available} busy={busy} onAction={act} />
)} )}
@ -293,6 +334,11 @@ export function MediaCardView({
)} )}
</div> </div>
))} ))}
{skipped.map((target) => (
<p key={target.platform} className="mt-2 text-ui-body-sm text-destructive">
{platformIssueLine(target.platform, target.issues[0]!)}
</p>
))}
{actionError ? ( {actionError ? (
<p role="alert" className="mt-2 text-ui-body-sm text-destructive" dir="auto"> <p role="alert" className="mt-2 text-ui-body-sm text-destructive" dir="auto">
{actionError} {actionError}
@ -383,7 +429,10 @@ export function MediaCard({
...(action.kind === "export" ...(action.kind === "export"
? { ? {
operation: "export_video", 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. : // The new card is titled with the file it transcribes.
{ {

View File

@ -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);
});
});

View File

@ -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<MediaOutput, "platform">): 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)`;
}
}

View File

@ -836,6 +836,10 @@ msgstr "Bearer-Token"
msgid "Bell" msgid "Bell"
msgstr "Glöckchen" 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 #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "In deinen Browser-Einstellungen blockiert" msgstr "In deinen Browser-Einstellungen blockiert"
@ -2551,6 +2555,10 @@ msgstr "Passwort vergessen?"
msgid "Forward" msgid "Forward"
msgstr "Vorwärts" 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 #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "Sommersprossen" msgstr "Sommersprossen"
@ -3857,6 +3865,10 @@ msgstr "Pfote"
msgid "Peach" msgid "Peach"
msgstr "Pfirsich" 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 #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "Pinguin" msgstr "Pinguin"
@ -5116,6 +5128,26 @@ msgstr "Heute"
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "Heute, {heuteActionCount} brauchen dich" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "Toolquellen" msgstr "Toolquellen"
@ -5535,6 +5567,14 @@ msgstr "Arbeitet…"
msgid "Working…" msgid "Working…"
msgstr "Wird verarbeitet…" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "Gestern" msgstr "Gestern"

View File

@ -836,6 +836,10 @@ msgstr "Bearer token"
msgid "Bell" msgid "Bell"
msgstr "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 #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "Blocked in your browser settings" msgstr "Blocked in your browser settings"
@ -2551,6 +2555,10 @@ msgstr "Forgot password?"
msgid "Forward" msgid "Forward"
msgstr "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 #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "Freckles" msgstr "Freckles"
@ -3857,6 +3865,10 @@ msgstr "Paw"
msgid "Peach" msgid "Peach"
msgstr "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 #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "Penguin" msgstr "Penguin"
@ -5116,6 +5128,26 @@ msgstr "Today"
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "Tool sources" msgstr "Tool sources"
@ -5535,6 +5567,14 @@ msgstr "Working…"
msgid "Working…" msgid "Working…"
msgstr "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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "Yesterday" msgstr "Yesterday"

View File

@ -836,6 +836,10 @@ msgstr "Token Bearer"
msgid "Bell" msgid "Bell"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Bitrate too high for {name} (max {max} kbps)"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "" msgstr ""
@ -2551,6 +2555,10 @@ msgstr "¿Olvidaste la contraseña?"
msgid "Forward" msgid "Forward"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Frame rate too high for {name} (max {max} fps)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "" msgstr ""
@ -3857,6 +3865,10 @@ msgstr ""
msgid "Peach" msgid "Peach"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Peaks too high for {name} (max {max} dBTP)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "" msgstr ""
@ -5116,6 +5128,26 @@ msgstr ""
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "Fuentes de herramientas" msgstr "Fuentes de herramientas"
@ -5535,6 +5567,14 @@ msgstr "Trabajando…"
msgid "Working…" msgid "Working…"
msgstr "Trabajando…" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "Ayer" msgstr "Ayer"

View File

@ -836,6 +836,10 @@ msgstr "बियरर टोकन"
msgid "Bell" msgid "Bell"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Bitrate too high for {name} (max {max} kbps)"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "" msgstr ""
@ -2551,6 +2555,10 @@ msgstr "पासवर्ड भूल गए?"
msgid "Forward" msgid "Forward"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Frame rate too high for {name} (max {max} fps)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "" msgstr ""
@ -3857,6 +3865,10 @@ msgstr ""
msgid "Peach" msgid "Peach"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Peaks too high for {name} (max {max} dBTP)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "" msgstr ""
@ -5116,6 +5128,26 @@ msgstr ""
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "टूल स्रोत" msgstr "टूल स्रोत"
@ -5535,6 +5567,14 @@ msgstr "काम चल रहा है…"
msgid "Working…" msgid "Working…"
msgstr "काम चल रहा है…" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "कल" msgstr "कल"

View File

@ -836,6 +836,10 @@ msgstr "Bearer 토큰"
msgid "Bell" msgid "Bell"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Bitrate too high for {name} (max {max} kbps)"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "" msgstr ""
@ -2551,6 +2555,10 @@ msgstr "비밀번호를 잊으셨나요?"
msgid "Forward" msgid "Forward"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Frame rate too high for {name} (max {max} fps)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "" msgstr ""
@ -3857,6 +3865,10 @@ msgstr ""
msgid "Peach" msgid "Peach"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Peaks too high for {name} (max {max} dBTP)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "" msgstr ""
@ -5116,6 +5128,26 @@ msgstr ""
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "도구 소스" msgstr "도구 소스"
@ -5535,6 +5567,14 @@ msgstr "처리 중…"
msgid "Working…" msgid "Working…"
msgstr "처리 중…" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "어제" msgstr "어제"

View File

@ -836,6 +836,10 @@ msgstr "Token portador"
msgid "Bell" msgid "Bell"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Bitrate too high for {name} (max {max} kbps)"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "" msgstr ""
@ -2551,6 +2555,10 @@ msgstr "Esqueceu a senha?"
msgid "Forward" msgid "Forward"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Frame rate too high for {name} (max {max} fps)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "" msgstr ""
@ -3857,6 +3865,10 @@ msgstr ""
msgid "Peach" msgid "Peach"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Peaks too high for {name} (max {max} dBTP)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "" msgstr ""
@ -5116,6 +5128,26 @@ msgstr ""
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "Fontes de ferramentas" msgstr "Fontes de ferramentas"
@ -5535,6 +5567,14 @@ msgstr "Funcionando"
msgid "Working…" msgid "Working…"
msgstr "Funcionando" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "Ontem" msgstr "Ontem"

View File

@ -836,6 +836,10 @@ msgstr "Bearer-токен"
msgid "Bell" msgid "Bell"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Bitrate too high for {name} (max {max} kbps)"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "" msgstr ""
@ -2551,6 +2555,10 @@ msgstr "Забыли пароль?"
msgid "Forward" msgid "Forward"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Frame rate too high for {name} (max {max} fps)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "" msgstr ""
@ -3857,6 +3865,10 @@ msgstr ""
msgid "Peach" msgid "Peach"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Peaks too high for {name} (max {max} dBTP)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "" msgstr ""
@ -5116,6 +5128,26 @@ msgstr ""
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "Источники инструментов" msgstr "Источники инструментов"
@ -5535,6 +5567,14 @@ msgstr "Выполняется…"
msgid "Working…" msgid "Working…"
msgstr "Выполняется…" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "Вчера" msgstr "Вчера"

View File

@ -836,6 +836,10 @@ msgstr "Bearer token"
msgid "Bell" msgid "Bell"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Bitrate too high for {name} (max {max} kbps)"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "" msgstr ""
@ -2551,6 +2555,10 @@ msgstr "Parolanızı mı unuttunuz?"
msgid "Forward" msgid "Forward"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Frame rate too high for {name} (max {max} fps)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "" msgstr ""
@ -3857,6 +3865,10 @@ msgstr ""
msgid "Peach" msgid "Peach"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Peaks too high for {name} (max {max} dBTP)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "" msgstr ""
@ -5116,6 +5128,26 @@ msgstr ""
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "Araç kaynakları" msgstr "Araç kaynakları"
@ -5535,6 +5567,14 @@ msgstr "Çalışıyor…"
msgid "Working…" msgid "Working…"
msgstr "Çalışıyor…" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "Dün" msgstr "Dün"

View File

@ -836,6 +836,10 @@ msgstr "Bearer token"
msgid "Bell" msgid "Bell"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Bitrate too high for {name} (max {max} kbps)"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx #: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings" msgid "Blocked in your browser settings"
msgstr "" msgstr ""
@ -2551,6 +2555,10 @@ msgstr "忘记密码?"
msgid "Forward" msgid "Forward"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Frame rate too high for {name} (max {max} fps)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Freckles" msgid "Freckles"
msgstr "" msgstr ""
@ -3857,6 +3865,10 @@ msgstr ""
msgid "Peach" msgid "Peach"
msgstr "" msgstr ""
#: src/components/media/media-platforms.ts
msgid "Peaks too high for {name} (max {max} dBTP)"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Penguin" msgid "Penguin"
msgstr "" msgstr ""
@ -5116,6 +5128,26 @@ msgstr ""
msgid "Today, {heuteActionCount} need you" msgid "Today, {heuteActionCount} need you"
msgstr "" 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 #: src/pages/PluginsOverlay.tsx
msgid "Tool sources" msgid "Tool sources"
msgstr "工具源" msgstr "工具源"
@ -5535,6 +5567,14 @@ msgstr "处理中…"
msgid "Working…" msgid "Working…"
msgstr "处理中…" 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 #: src/pages/shell/roster-format.ts
msgid "Yesterday" msgid "Yesterday"
msgstr "昨天" msgstr "昨天"

View File

@ -1,9 +1,12 @@
import type { import type {
MediaCheck,
MediaJobStatus, MediaJobStatus,
MediaOperation, MediaOperation,
MediaOutput, MediaOutput,
MediaOutputKind, MediaOutputKind,
MediaPlatformId,
MediaPresetId, MediaPresetId,
MediaSkippedTarget,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import type { AdapterDescriptor } from "./types.js"; import type { AdapterDescriptor } from "./types.js";
@ -137,6 +140,8 @@ export interface MediaJobOutputFile {
posterPath?: string; posterPath?: string;
parentArtifactId?: string; parentArtifactId?: string;
preset?: MediaPresetId; preset?: MediaPresetId;
platform?: MediaPlatformId;
check?: MediaCheck;
} }
/** An artifact the job may read, copied into the job's temp directory. */ /** An artifact the job may read, copied into the job's temp directory. */
@ -168,6 +173,8 @@ export interface MediaJobContext {
fetchArtifact(artifactId: string): Promise<MediaJobInputFile>; fetchArtifact(artifactId: string): Promise<MediaJobInputFile>;
/** Stores a produced file as an artifact and adds it to the media card. */ /** Stores a produced file as an artifact and adds it to the media card. */
addOutput(file: MediaJobOutputFile): Promise<MediaOutput>; addOutput(file: MediaJobOutputFile): Promise<MediaOutput>;
/** Shows a platform the job did not render on the media card, with the reason. */
addSkipped(target: MediaSkippedTarget): Promise<void>;
} }
export interface MediaOperationResult { export interface MediaOperationResult {

View File

@ -7,6 +7,7 @@ import {
ExportVideoInputSchema, ExportVideoInputSchema,
GenerateImageInputSchema, GenerateImageInputSchema,
GenerateVideoInputSchema, GenerateVideoInputSchema,
MEDIA_PLATFORM_PROFILES,
MODEL_TIERS, MODEL_TIERS,
SecretAskPurpose, SecretAskPurpose,
SecretHttpRequest, SecretHttpRequest,
@ -34,6 +35,11 @@ export const SUBAGENT_MODEL_TIER_DESCRIPTION =
const VIDEO_PRESETS_AND_FITS = 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."; "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([ export const DELEGATION_TOOL_NAMES = new Set([
"run_subagent", "run_subagent",
"spawn_bot", "spawn_bot",
@ -564,7 +570,7 @@ export const builtinAgentTools: ConnectorTool[] = [
}, },
{ {
name: "export_video", 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), inputSchema: mediaParameters(ExportVideoInputSchema),
}, },
{ {

View File

@ -335,6 +335,25 @@ describePostgres("media job runtime (PostgreSQL, fake operations)", () => {
expect(existsSync(tempDir)).toBe(false); 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 () => { it("refuses outputs outside the temp dir and sources of another user", async () => {
const other = await setup(); const other = await setup();
const foreign = await other.sourceArtifact(); const foreign = await other.sourceArtifact();

View File

@ -32,12 +32,14 @@ import type {
MediaJobStatus, MediaJobStatus,
MediaOperation, MediaOperation,
MediaOutput, MediaOutput,
MediaSkippedTarget,
MessageBlock, MessageBlock,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { import {
attachmentKindOf, attachmentKindOf,
MEDIA_JOB_ACTIVE_STATUSES, MEDIA_JOB_ACTIVE_STATUSES,
MediaOperationSchema, MediaOperationSchema,
MediaSkippedTargetSchema,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { attachmentExtensionForMimeType, inferAttachmentMimeType } from "@rakazo/core"; import { attachmentExtensionForMimeType, inferAttachmentMimeType } from "@rakazo/core";
import type { MediaJob, PrismaClient, ThreadEvents } from "@rakazo/db"; import type { MediaJob, PrismaClient, ThreadEvents } from "@rakazo/db";
@ -160,7 +162,17 @@ function issuesText(error: z.ZodError): string {
.join("; "); .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 { return {
kind: "media", kind: "media",
jobId: job.id, jobId: job.id,
@ -170,6 +182,7 @@ function cardBlock(job: MediaJob, outputs: MediaOutput[]): MediaJobBlock {
...(job.progress === null ? {} : { progress: job.progress }), ...(job.progress === null ? {} : { progress: job.progress }),
...(job.error === null ? {} : { error: job.error }), ...(job.error === null ? {} : { error: job.error }),
outputs, outputs,
...(skipped.length ? { skipped } : {}),
}; };
} }
@ -346,8 +359,9 @@ export function createMediaRuntime(
const writeCard = async ( const writeCard = async (
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
job: MediaJob, job: MediaJob,
appended: MediaOutput[] = [], added: CardAdditions = {},
): Promise<{ exists: boolean; seq?: number; outputs: MediaOutput[] }> => { ): Promise<{ exists: boolean; seq?: number; outputs: MediaOutput[] }> => {
const appended = added.outputs ?? [];
if (!job.messageId) return { exists: false, outputs: appended }; if (!job.messageId) return { exists: false, outputs: appended };
const message = await tx.message.findFirst({ const message = await tx.message.findFirst({
where: { id: job.messageId, threadId: job.threadId }, where: { id: job.messageId, threadId: job.threadId },
@ -356,7 +370,16 @@ export function createMediaRuntime(
if (!message) return { exists: false, outputs: appended }; if (!message) return { exists: false, outputs: appended };
const previous = mediaBlockOf(message.blocks, job.id); const previous = mediaBlockOf(message.blocks, job.id);
const outputs = [...(previous?.outputs ?? []), ...appended]; 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 current = Array.isArray(message.blocks) ? (message.blocks as MessageBlock[]) : [];
const blocks = previous const blocks = previous
? current.map((block) => (block === previous ? next : block)) ? current.map((block) => (block === previous ? next : block))
@ -386,7 +409,7 @@ export function createMediaRuntime(
jobId: string, jobId: string,
token: string | null, token: string | null,
data: Prisma.MediaJobUpdateManyMutationInput, data: Prisma.MediaJobUpdateManyMutationInput,
appended: MediaOutput[] = [], added: CardAdditions = {},
) => { ) => {
const committed = await withTransactionRetry(() => const committed = await withTransactionRetry(() =>
deps.prisma.$transaction(async (tx) => { deps.prisma.$transaction(async (tx) => {
@ -408,7 +431,7 @@ export function createMediaRuntime(
const updated = await tx.mediaJob.updateMany({ where: fence, data }); const updated = await tx.mediaJob.updateMany({ where: fence, data });
if (!updated.count) return null; if (!updated.count) return null;
const job = await tx.mediaJob.findUniqueOrThrow({ where: { id: jobId } }); 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 }; return { job, card };
}), }),
); );
@ -801,10 +824,15 @@ export function createMediaRuntime(
...(posterArtifactId ? { posterArtifactId } : {}), ...(posterArtifactId ? { posterArtifactId } : {}),
...(parent ? { parentArtifactId: parent.id } : {}), ...(parent ? { parentArtifactId: parent.id } : {}),
...(file.preset ? { preset: file.preset } : {}), ...(file.preset ? { preset: file.preset } : {}),
...(file.platform ? { platform: file.platform } : {}),
...(file.check ? { check: file.check } : {}),
}; };
const written = await updateJob(jobId, token, { outputArtifactIds: { push: stored } }, [ const written = await updateJob(
output, jobId,
]); token,
{ outputArtifactIds: { push: stored } },
{ outputs: [output] },
);
if (!written) { if (!written) {
abort("lease"); abort("lease");
throw new Error("Media job lost its lease"); throw new Error("Media job lost its lease");
@ -816,6 +844,17 @@ export function createMediaRuntime(
throw error; 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 { try {

View File

@ -10,7 +10,9 @@ import {
FfmpegError, FfmpegError,
ffmpegInputArgs, ffmpegInputArgs,
ffmpegSeconds, ffmpegSeconds,
measureLoudness,
packetDurationMs, packetDurationMs,
parseLoudnormOutput,
parseProbeOutput, parseProbeOutput,
probeMedia, probeMedia,
renderPoster, renderPoster,
@ -18,6 +20,7 @@ import {
} from "./ffmpeg.js"; } from "./ffmpeg.js";
import type { TestClips } from "./media-test-support.js"; import type { TestClips } from "./media-test-support.js";
import { createTestClips } from "./media-test-support.js"; import { createTestClips } from "./media-test-support.js";
import { planLoudnessFix } from "./video-edit-plan.js";
const ffmpeg = await detectFfmpeg(); 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", () => { it("reads legacy rotate tags, skips cover art and falls back to stream durations", () => {
const probe = parseProbeOutput( const probe = parseProbeOutput(
JSON.stringify({ 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", () => { describe("ffmpeg arguments", () => {
it("opens inputs through the file protocol with the demuxer of their MIME type", () => { it("opens inputs through the file protocol with the demuxer of their MIME type", () => {
expect( expect(
@ -151,6 +200,28 @@ describe.skipIf(!ffmpeg)("ffmpeg integration", () => {
expect(silent.video).toMatchObject({ width: 180, height: 320 }); 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 () => { it("refuses a playlist disguised as an MP4 instead of following its URLs", async () => {
const playlist = join(clips.dir, "playlist.mp4"); const playlist = join(clips.dir, "playlist.mp4");
await writeFile( await writeFile(

View File

@ -27,6 +27,8 @@ export interface MediaProbe {
fps: number; fps: number;
/** Pixel aspect ratio of the displayed frame; 1 for square pixels. */ /** Pixel aspect ratio of the displayed frame; 1 for square pixels. */
sampleAspectRatio: number; sampleAspectRatio: number;
/** Average bitrate of the stream when the container records it. */
bitRateKbps?: number;
}; };
audio?: { codec: string; sampleRate: number; channels: number }; audio?: { codec: string; sampleRate: number; channels: number };
hasAudio: boolean; hasAudio: boolean;
@ -212,6 +214,7 @@ interface ProbeStream {
sample_rate?: string; sample_rate?: string;
channels?: number; channels?: number;
duration?: string; duration?: string;
bit_rate?: string;
disposition?: { attached_pic?: number }; disposition?: { attached_pic?: number };
tags?: { rotate?: string }; tags?: { rotate?: string };
side_data_list?: { rotation?: number }[]; side_data_list?: { rotation?: number }[];
@ -265,6 +268,7 @@ function probeVideo(stream: ProbeStream, width: number, height: number) {
const sideways = rotation === 90 || rotation === 270; const sideways = rotation === 90 || rotation === 270;
const sar = ratio(stream.sample_aspect_ratio, ":") || 1; const sar = ratio(stream.sample_aspect_ratio, ":") || 1;
const fps = ratio(stream.avg_frame_rate, "/") || ratio(stream.r_frame_rate, "/"); const fps = ratio(stream.avg_frame_rate, "/") || ratio(stream.r_frame_rate, "/");
const bitRate = positive(Number(stream.bit_rate));
return { return {
codec: stream.codec_name ?? "", codec: stream.codec_name ?? "",
width: sideways ? height : width, width: sideways ? height : width,
@ -272,6 +276,7 @@ function probeVideo(stream: ProbeStream, width: number, height: number) {
rotation, rotation,
fps: fps > 0 && fps <= MAX_PLAUSIBLE_FPS ? fps : 0, fps: fps > 0 && fps <= MAX_PLAUSIBLE_FPS ? fps : 0,
sampleAspectRatio: sideways ? 1 / sar : sar, 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<LoudnessMeasurement> {
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<string, string>;
try {
data = JSON.parse(log.slice(start, end + 1)) as Record<string, string>;
} 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. */ /** Writes one JPEG frame at atMs (default 0), falling back to the first frame past the end. */
export async function renderPoster( export async function renderPoster(
input: string, input: string,

View File

@ -103,6 +103,7 @@ async function run(
}; };
return output; return output;
}, },
addSkipped: async () => undefined,
}; };
const result = await operation(name).handler.run(context); const result = await operation(name).handler.run(context);
return { result, outputs, progress }; return { result, outputs, progress };

View File

@ -2,7 +2,7 @@ import { copyFile, mkdtemp, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { MediaJobContext, MediaJobInputFile, MediaJobOutputFile } from "@rakazo/adapter-kit"; 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 { attachmentKindOf } from "@rakazo/contracts";
import { runFfmpeg } from "./ffmpeg.js"; import { runFfmpeg } from "./ffmpeg.js";
@ -50,12 +50,13 @@ export interface TestArtifact {
kind?: MediaOutputKind; 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<string, TestArtifact>) { export async function fakeMediaJobContext(input: unknown, artifacts: Record<string, TestArtifact>) {
const tempDir = await mkdtemp(join(tmpdir(), "evoo-media-job-")); const tempDir = await mkdtemp(join(tmpdir(), "evoo-media-job-"));
const controller = new AbortController(); const controller = new AbortController();
const progress: number[] = []; const progress: number[] = [];
const outputs: (MediaJobOutputFile & { size: number; posterSize?: number })[] = []; const outputs: (MediaJobOutputFile & { size: number; posterSize?: number })[] = [];
const skipped: MediaSkippedTarget[] = [];
const context: MediaJobContext = { const context: MediaJobContext = {
jobId: "job-1", jobId: "job-1",
spaceId: "space-1", spaceId: "space-1",
@ -99,8 +100,13 @@ export async function fakeMediaJobContext(input: unknown, artifacts: Record<stri
...(file.posterPath ? { posterArtifactId: `poster-${index}` } : {}), ...(file.posterPath ? { posterArtifactId: `poster-${index}` } : {}),
...(file.parentArtifactId ? { parentArtifactId: file.parentArtifactId } : {}), ...(file.parentArtifactId ? { parentArtifactId: file.parentArtifactId } : {}),
...(file.preset ? { preset: file.preset } : {}), ...(file.preset ? { preset: file.preset } : {}),
...(file.platform ? { platform: file.platform } : {}),
...(file.check ? { check: file.check } : {}),
}; };
}, },
addSkipped: async (target) => {
skipped.push(target);
},
}; };
return { context, controller, progress, outputs, tempDir }; return { context, controller, progress, outputs, skipped, tempDir };
} }

View File

@ -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> = {}): 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);
});
});

View File

@ -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<MediaCheckIssue["code"]>([
"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<PlatformProfile, "name">,
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));
}

View File

@ -150,6 +150,7 @@ async function jobContext(source: Omit<MediaJobInputFile, "path">, input: unknow
outputs.push({ ...file, body: await readFile(file.path, "utf8") }); outputs.push({ ...file, body: await readFile(file.path, "utf8") });
return { artifactId: `out-${outputs.length}` } as MediaOutput; return { artifactId: `out-${outputs.length}` } as MediaOutput;
}, },
addSkipped: async () => undefined,
}; };
return { context, outputs, progress }; return { context, outputs, progress };
} }

View File

@ -1,8 +1,10 @@
import type { EditVideoStep } from "@rakazo/contracts"; import type { EditVideoStep } from "@rakazo/contracts";
import { MEDIA_PLATFORM_PROFILES } from "@rakazo/contracts";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { MediaProbe } from "./ffmpeg.js"; import type { MediaProbe } from "./ffmpeg.js";
import { platformTarget } from "./platform-export.js";
import type { PlanInput } from "./video-edit-plan.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( function probe(
overrides: Partial<NonNullable<MediaProbe["video"]>> & { overrides: Partial<NonNullable<MediaProbe["video"]>> & {
@ -298,3 +300,83 @@ describe("planVideoEdit", () => {
expect(graphOf(evened.args)).toBe("[0:v:0]crop=720:404:0:0,format=yuv420p[v1]"); 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");
});
});

View File

@ -1,7 +1,7 @@
import type { EditVideoStep, MediaFit } from "@rakazo/contracts"; import type { EditVideoStep, MediaFit } from "@rakazo/contracts";
import { MEDIA_PRESETS } from "@rakazo/contracts"; import { MEDIA_PRESETS } from "@rakazo/contracts";
import type { MediaProbe } from "./ffmpeg.js"; import type { LoudnessMeasurement, MediaProbe } from "./ffmpeg.js";
import { ffmpegInputArgs, ffmpegSeconds } 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 * 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<string, PlanInput>; clips?: ReadonlyMap<string, PlanInput>;
/** Subtitle files named by subtitles steps, by artifact id: safe names relative to ffmpeg's cwd. */ /** Subtitle files named by subtitles steps, by artifact id: safe names relative to ffmpeg's cwd. */
subtitleFiles?: ReadonlyMap<string, string>; subtitleFiles?: ReadonlyMap<string, string>;
/** A platform's limits, applied after the steps. */
target?: ExportTarget;
outputPath: string; 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 { export interface VideoEditPlan {
args: string[]; args: string[];
kind: "video" | "audio"; kind: "video" | "audio";
@ -46,7 +59,8 @@ export const VIDEO_ENCODE_ARGS = [
"-movflags", "-movflags",
"+faststart", "+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"]; 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. */ /** Location and other camera metadata stay private; chapters do not survive cuts anyway. */
const STRIP_METADATA_ARGS = ["-map_metadata", "-1", "-map_chapters", "-1"]; 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.`, `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 durationMs = Math.round(timeline.seconds * 1000);
const output = `file:${request.outputPath}`; const output = `file:${request.outputPath}`;
if (audioOnly || !timeline.video) { if (audioOnly || !timeline.video) {
@ -268,6 +284,7 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
`[${videoOut}]`, `[${videoOut}]`,
...(audioOut ? ["-map", `[${audioOut}]`, ...AUDIO_ENCODE_ARGS] : []), ...(audioOut ? ["-map", `[${audioOut}]`, ...AUDIO_ENCODE_ARGS] : []),
...VIDEO_ENCODE_ARGS, ...VIDEO_ENCODE_ARGS,
...(target && !audioOnly ? bitrateCapArgs(target.maxBitrateKbps) : []),
...STRIP_METADATA_ARGS, ...STRIP_METADATA_ARGS,
"-f", "-f",
"mp4", "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 { interface Segment {
start: number; start: number;
end: number; end: number;

View File

@ -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 () => { it("refuses appended clips and subtitles the user cannot read", async () => {
const { runtime, scope, store } = await setup(); const { runtime, scope, store } = await setup();
const other = await setup(); const other = await setup();

View File

@ -1,13 +1,15 @@
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { MEDIA_PLATFORM_PROFILES, MediaPlatformIdSchema } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { builtinAgentTools } from "../builtin-tools.js"; import { builtinAgentTools } from "../builtin-tools.js";
import { createMediaOperations } from "../media-operations.js"; import { createMediaOperations } from "../media-operations.js";
import { MEDIA_CONTINUE_AFTER_PROPERTY } from "../media-tools.js"; import { MEDIA_CONTINUE_AFTER_PROPERTY } from "../media-tools.js";
import { parametersFor } from "../pi-runtime.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 type { TestArtifact, TestClips } from "./media-test-support.js";
import { createTestClips, fakeMediaJobContext } 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"; import { cleanFileName, createVideoOperations, subtitleFormat } from "./video-operations.js";
const ffmpeg = await detectFfmpeg(); const ffmpeg = await detectFfmpeg();
@ -48,13 +50,22 @@ describe("video operation registrations", () => {
expect(exported.artifactIds?.(exportInput)).toEqual(["src"]); 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"); const exported = operation("export_video");
expect( expect(
exported.title( exported.title(
exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical", "square"] }), exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical", "square"] }),
), ),
).toBe("9:16 · 1:1"); ).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( expect(
exported.title( exported.title(
exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical"], title: "Reel" }), exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical"], title: "Reel" }),
@ -70,6 +81,18 @@ describe("video operation registrations", () => {
expect(() => parametersFor(tool!)).not.toThrow(); 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", () => { describe("subtitle and file name helpers", () => {
@ -251,6 +274,74 @@ describe.skipIf(!ffmpeg)("video operations with ffmpeg", () => {
expect(progress.at(-1)).toBe(1); 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 () => { it("fails clearly on ranges past the end, non-subtitle files and non-videos", async () => {
await expect( await expect(
run("edit_video", { run("edit_video", {

View File

@ -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 { join } from "node:path";
import type { MediaJobContext, MediaJobInputFile, MediaOperationResult } from "@rakazo/adapter-kit"; import type {
import type { EditVideoInput, ExportVideoInput, MediaOutput } from "@rakazo/contracts"; MediaJobContext,
MediaJobInputFile,
MediaJobOutputFile,
MediaOperationResult,
} from "@rakazo/adapter-kit";
import type {
EditVideoInput,
ExportVideoInput,
MediaFit,
MediaOutput,
MediaPlatformId,
MediaSkippedTarget,
PlatformProfile,
} from "@rakazo/contracts";
import { import {
ARTIFACT_MAX_BYTES_BY_KIND, ARTIFACT_MAX_BYTES_BY_KIND,
EditVideoInputSchema, EditVideoInputSchema,
ExportVideoInputSchema, ExportVideoInputSchema,
MEDIA_PLATFORM_PROFILES,
MEDIA_PRESETS, MEDIA_PRESETS,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import type { MediaOperationRegistration } from "../media-runtime.js"; import type { MediaOperationRegistration } from "../media-runtime.js";
import type { FfmpegInfo } from "./ffmpeg.js"; import type { FfmpegInfo, MediaProbe } from "./ffmpeg.js";
import { import {
detectFfmpeg, detectFfmpeg,
measureLoudness,
outputTooLargeMessage, outputTooLargeMessage,
probeMedia, probeMedia,
renderPoster, renderPoster,
runFfmpeg, runFfmpeg,
} from "./ffmpeg.js"; } 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 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 { export interface VideoOperationsDeps {
/** Finds ffmpeg; defaults to PATH lookup. */ /** Finds ffmpeg; defaults to PATH lookup. */
@ -66,7 +91,11 @@ export function createVideoOperations(
operation: "export_video", operation: "export_video",
inputSchema: ExportVideoInputSchema, inputSchema: ExportVideoInputSchema,
title: (input) => 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, available,
artifactIds: (input) => [input.artifact_id], artifactIds: (input) => [input.artifact_id],
handler: { handler: {
@ -142,39 +171,160 @@ async function runEditVideo(context: MediaJobContext): Promise<MediaOperationRes
async function runExportVideo(context: MediaJobContext): Promise<MediaOperationResult> { async function runExportVideo(context: MediaJobContext): Promise<MediaOperationResult> {
const input = ExportVideoInputSchema.parse(context.input); 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 workDir = await mkdtemp(join(context.tempDir, "export-"));
const progress = createProgressReporter(context); const progress = createProgressReporter(context);
const source = await loadVideo(context, input.artifact_id); const source = await loadVideo(context, input.artifact_id);
const base = cleanFileName(withoutExtension(source.name), "video"); 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[] = []; 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 outputPath = join(workDir, `${preset}.mp4`);
const plan = planVideoEdit({ const plan = planVideoEdit({
source, source,
steps: [{ op: "reframe", preset, fit: input.fit }], steps: [{ op: "reframe", preset, fit: input.fit }],
outputPath, outputPath,
}); });
const share = 1 / presets.length;
outputs.push( outputs.push(
await encode( await encode(context, plan, outputPath, workDir, report(outputs.length), {
context,
plan,
outputPath,
workDir,
progress.range(index * share, (index + 1) * share),
{
name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`, name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`,
parentArtifactId: source.artifactId, parentArtifactId: source.artifactId,
preset, preset,
}, }),
),
); );
} }
await progress.done(); 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<MediaOutput> {
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. */ /** Runs a plan, checks the result, renders its poster and stores it as an output. */
async function encode( async function encode(
context: MediaJobContext, context: MediaJobContext,
@ -182,8 +332,24 @@ async function encode(
outputPath: string, outputPath: string,
workDir: string, workDir: string,
report: (fraction: number) => void, report: (fraction: number) => void,
file: Pick<Parameters<MediaJobContext["addOutput"]>[0], "name" | "parentArtifactId" | "preset">, file: OutputDetails,
): Promise<MediaOutput> { ): Promise<MediaOutput> {
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<void> {
const maxBytes = ARTIFACT_MAX_BYTES_BY_KIND[plan.kind]; const maxBytes = ARTIFACT_MAX_BYTES_BY_KIND[plan.kind];
await runFfmpeg(plan.args, { await runFfmpeg(plan.args, {
signal: context.signal, signal: context.signal,
@ -194,10 +360,17 @@ async function encode(
outputs: [outputPath], outputs: [outputPath],
}); });
if ((await stat(outputPath)).size > maxBytes) throw new Error(outputTooLargeMessage(maxBytes)); 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<MediaOutput> {
if (plan.kind === "audio") { if (plan.kind === "audio") {
const output = await context.addOutput({ const output = await context.addOutput({
...file, ...file,
@ -294,13 +467,16 @@ function withoutExtension(name: string): string {
return name.replace(/\.[A-Za-z0-9]{1,5}$/, ""); 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 { function describe(output: MediaOutput): string {
const name = output.platform
? `${output.name} for ${MEDIA_PLATFORM_PROFILES[output.platform].name}`
: output.name;
const details = [ const details = [
output.width && output.height ? `${output.width}×${output.height}` : "", output.width && output.height ? `${output.width}×${output.height}` : "",
output.durationMs !== undefined ? `${Number((output.durationMs / 1000).toFixed(1))} s` : "", output.durationMs !== undefined ? `${Number((output.durationMs / 1000).toFixed(1))} s` : "",
].filter(Boolean); ].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. */ /** Maps ffmpeg progress onto the card: monotonic, written in order, never failing the job. */

View File

@ -18,6 +18,8 @@ export * from "./media.js";
export * from "./media-generation.js"; export * from "./media-generation.js";
export * from "./media-jobs.js"; export * from "./media-jobs.js";
export * from "./media-library.js"; export * from "./media-library.js";
export * from "./media-platforms.js";
export * from "./media-presets.js";
export * from "./media-settings.js"; export * from "./media-settings.js";
export * from "./media-transcription.js"; export * from "./media-transcription.js";
export * from "./media-video.js"; export * from "./media-video.js";

View File

@ -1,6 +1,6 @@
import * as z from "zod"; import * as z from "zod";
import { Id } from "./ids.js"; import { Id } from "./ids.js";
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js"; import { MediaFitSchema, MediaPresetIdSchema } from "./media-presets.js";
import { MediaTitleSchema } from "./media-video.js"; import { MediaTitleSchema } from "./media-video.js";
export const MEDIA_IMAGE_MAX_COUNT = 4; export const MEDIA_IMAGE_MAX_COUNT = 4;

View File

@ -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<string>(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);
});
});

View File

@ -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<typeof MediaPlatformIdSchema>;
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<typeof PlatformProfileSchema>;
/**
* 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<typeof MediaCheckIssueSchema>;
/** `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<typeof MediaCheckSchema>;
/** 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<typeof MediaSkippedTargetSchema>;
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<Record<MediaPlatformId, PlatformProfile>> = {
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,
},
};

View File

@ -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<typeof MediaPresetIdSchema>;
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<typeof MediaFitSchema>;

View File

@ -42,15 +42,42 @@ describe("edit_video input", () => {
}); });
describe("export_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({ expect(ExportVideoInputSchema.parse({ artifact_id: "a", presets: ["square"] })).toEqual({
artifact_id: "a", artifact_id: "a",
presets: ["square"], presets: ["square"],
fit: "crop", 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", presets: [] }).success).toBe(false);
expect(ExportVideoInputSchema.safeParse({ artifact_id: "a" }).success).toBe(false);
expect(ExportVideoInputSchema.safeParse({ artifact_id: "a", presets: ["imax"] }).success).toBe( expect(ExportVideoInputSchema.safeParse({ artifact_id: "a", presets: ["imax"] }).success).toBe(
false, 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"]);
}); });
}); });

View File

@ -1,6 +1,7 @@
import * as z from "zod"; import * as z from "zod";
import { Id } from "./ids.js"; 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 * 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<typeof EditVideoInputSchema>; export type EditVideoInput = z.infer<typeof EditVideoInputSchema>;
export const ExportVideoInputSchema = z.object({ 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, artifact_id: Id,
presets: z.array(MediaPresetIdSchema).min(1).max(6), platforms: z.array(MediaPlatformIdSchema).max(MAX_EXPORT_TARGETS).optional(),
presets: z.array(MediaPresetIdSchema).max(MAX_EXPORT_TARGETS).optional(),
fit: MediaFitSchema.default("crop"), fit: MediaFitSchema.default("crop"),
title: MediaTitleSchema.optional(), 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<typeof ExportVideoInputSchema>; export type ExportVideoInput = z.infer<typeof ExportVideoInputSchema>;

View File

@ -1,5 +1,11 @@
import * as z from "zod"; import * as z from "zod";
import { Id } from "./ids.js"; 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. */ /** What a media job does. Generation runs on the configured image/video backend, edits run locally. */
export const MediaOperationSchema = z.enum([ 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 const MediaOutputKindSchema = z.enum(["image", "video", "audio", "file"]);
export type MediaOutputKind = z.infer<typeof MediaOutputKindSchema>; export type MediaOutputKind = z.infer<typeof MediaOutputKindSchema>;
/**
* 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<typeof MediaPresetIdSchema>;
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<typeof MediaFitSchema>;
export const MediaOutputSchema = z.object({ export const MediaOutputSchema = z.object({
artifactId: Id, artifactId: Id,
kind: MediaOutputKindSchema, kind: MediaOutputKindSchema,
@ -72,6 +51,9 @@ export const MediaOutputSchema = z.object({
/** The artifact this output was made from, for version history. */ /** The artifact this output was made from, for version history. */
parentArtifactId: Id.optional(), parentArtifactId: Id.optional(),
preset: MediaPresetIdSchema.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<typeof MediaOutputSchema>; export type MediaOutput = z.infer<typeof MediaOutputSchema>;
@ -86,6 +68,8 @@ export const MediaJobBlockSchema = z.object({
progress: z.number().min(0).max(1).optional(), progress: z.number().min(0).max(1).optional(),
error: z.string().optional(), error: z.string().optional(),
outputs: z.array(MediaOutputSchema), outputs: z.array(MediaOutputSchema),
/** Platforms an export did not render, each with the reason. */
skipped: z.array(MediaSkippedTargetSchema).optional(),
}); });
export type MediaJobBlock = z.infer<typeof MediaJobBlockSchema>; export type MediaJobBlock = z.infer<typeof MediaJobBlockSchema>;