Merge remote-tracking branch 'origin/main' into claude/simplify-reminder-settings-ppn3l5

This commit is contained in:
Claude 2026-10-05 16:37:39 +00:00
commit 6ca08b2d6a
No known key found for this signature in database
44 changed files with 2418 additions and 207 deletions

View File

@ -245,7 +245,6 @@ export async function openUserSettings(
| "integrations" | "integrations"
| "models" | "models"
| "memory" | "memory"
| "profile"
| "voice" | "voice"
| "notifications" | "notifications"
| "usage" | "usage"

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

@ -1,5 +1,5 @@
/** /**
* E2E: Settings → About you edits a fact in place: its wording, category and the spaces it * E2E: Settings → General → About you edits a fact in place: its wording, category and the spaces it
* shows in. * shows in.
*/ */
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
@ -15,7 +15,7 @@ test("a fact's wording, category and spaces are edited in place", async ({ page
addFacts: [{ category: "preferences", value: "Prefers tea" }], addFacts: [{ category: "preferences", value: "Prefers tea" }],
}); });
const settings = await openUserSettings(page, "profile"); const settings = await openUserSettings(page);
const profile = settings.getByTestId("user-profile-settings"); const profile = settings.getByTestId("user-profile-settings");
const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" }); const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" });
await tea.getByRole("button", { name: "Edit" }).click(); await tea.getByRole("button", { name: "Edit" }).click();

View File

@ -1,6 +1,6 @@
/** /**
* E2E: onboarding fills the user profile without asking (time zone, locale and language from * E2E: onboarding fills the user profile without asking (time zone, locale and language from
* the browser) and records the focus answer as a fact; Settings → About you shows every fact * the browser) and records the focus answer as a fact; Settings → General → About you shows every fact
* with its origin, confirms a pending one, and forgets one or all of them. * with its origin, confirms a pending one, and forgets one or all of them.
*/ */
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
@ -58,8 +58,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
addFacts: [{ category: "health", value: "Allergic to peanuts", source: "onboarding" }], addFacts: [{ category: "health", value: "Allergic to peanuts", source: "onboarding" }],
}); });
const settings = await openUserSettings(page, "profile"); const settings = await openUserSettings(page);
await expect(settings).toHaveAttribute("data-settings-section", "profile"); await expect(settings).toHaveAttribute("data-settings-section", "general");
const profile = settings.getByTestId("user-profile-settings"); const profile = settings.getByTestId("user-profile-settings");
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna"); await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
await expect(profile.getByTestId("profile-field-language")).toContainText("English"); await expect(profile.getByTestId("profile-field-language")).toContainText("English");
@ -99,6 +99,6 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await profile.getByRole("button", { name: "Forget everything" }).click(); await profile.getByRole("button", { name: "Forget everything" }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Forget", exact: true }).click(); await page.getByRole("alertdialog").getByRole("button", { name: "Forget", exact: true }).click();
await expect(profile).toContainText("Nothing yet"); await expect(profile).toHaveCount(0);
expect(await rpc<{ facts: unknown[] }>(page, "profile/get", null)).toMatchObject({ facts: [] }); expect(await rpc<{ facts: unknown[] }>(page, "profile/get", null)).toMatchObject({ facts: [] });
}); });

View File

@ -151,54 +151,51 @@ export function UserProfileSettings() {
</Button> </Button>
); );
if (!error && (!state || (fields.length === 0 && groups.length === 0))) return null;
return ( return (
<div data-testid="user-profile-settings" className="space-y-5"> <section
data-testid="user-profile-settings"
className="space-y-4 rounded-xl border border-border px-4 py-4"
>
<h3 className="text-ui-body font-medium text-foreground">
<Trans>About you</Trans>
</h3>
{error ? ( {error ? (
<p role="alert" className="text-ui-body-sm text-destructive"> <p role="alert" className="text-ui-body-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
{state && fields.length === 0 && groups.length === 0 ? (
<p className="text-ui-body-sm text-muted-foreground">
<Trans>Nothing yet</Trans>
</p>
) : null}
{fields.length > 0 ? ( {fields.length > 0 ? (
<section className="rounded-xl border border-border px-4 py-4"> <ul className="space-y-3">
<ul className="space-y-3"> {fields.map(({ field, value }) => (
{fields.map(({ field, value }) => ( <Row
<Row key={field}
key={field} testId={`profile-field-${field}`}
testId={`profile-field-${field}`} value={
value={ field === "language"
field === "language" ? displayLanguageTag(value, uiLocale)
? displayLanguageTag(value, uiLocale) : field === "locale"
: field === "locale" ? displayRegionTag(value, uiLocale)
? displayRegionTag(value, uiLocale) : value
: value }
} detail={fieldLabels[field]}
detail={fieldLabels[field]} actions={(describedBy) =>
actions={(describedBy) => deleteButton(
deleteButton( describedBy,
describedBy, () => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])),
() => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])), )
) }
} />
/> ))}
))} </ul>
</ul>
</section>
) : null} ) : null}
{groups.map((group) => ( {groups.map((group) => (
<section <div key={group.category} data-testid={`user-facts-${group.category}`}>
key={group.category} <h4 className="text-ui-body-sm text-muted-foreground">
data-testid={`user-facts-${group.category}`}
className="rounded-xl border border-border px-4 py-4"
>
<h3 className="text-ui-body font-medium text-foreground">
{categoryLabels[group.category]} {categoryLabels[group.category]}
</h3> </h4>
<ul className="mt-3 space-y-3"> <ul className="mt-2 space-y-3">
{group.facts.map((fact) => {group.facts.map((fact) =>
fact.id === editingId ? ( fact.id === editingId ? (
<UserFactEditor <UserFactEditor
@ -269,7 +266,7 @@ export function UserProfileSettings() {
), ),
)} )}
</ul> </ul>
</section> </div>
))} ))}
{fields.length > 0 || groups.length > 0 ? ( {fields.length > 0 || groups.length > 0 ? (
<Button variant="destructive" disabled={busy} onClick={() => setForgetOpen(true)}> <Button variant="destructive" disabled={busy} onClick={() => setForgetOpen(true)}>
@ -300,6 +297,6 @@ export function UserProfileSettings() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </section>
); );
} }

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

@ -311,7 +311,7 @@ msgstr "Über Gruppen"
msgid "About spaces" msgid "About spaces"
msgstr "Über Spaces" msgstr "Über Spaces"
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "Über dich" msgstr "Über dich"
@ -837,6 +837,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"
@ -2546,6 +2550,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"
@ -3626,10 +3634,6 @@ msgstr "Nichts läuft mehr"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "Noch nichts gemerkt" msgstr "Noch nichts gemerkt"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr "Noch nichts"
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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"
@ -5099,6 +5107,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"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr "About groups"
msgid "About spaces" msgid "About spaces"
msgstr "About spaces" msgstr "About spaces"
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "About you" msgstr "About you"
@ -837,6 +837,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"
@ -2546,6 +2550,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"
@ -3626,10 +3634,6 @@ msgstr "Nothing is still running"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "Nothing remembered yet" msgstr "Nothing remembered yet"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr "Nothing yet"
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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"
@ -5099,6 +5107,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"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr ""
msgid "About spaces" msgid "About spaces"
msgstr "" msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "" msgstr ""
@ -837,6 +837,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 ""
@ -2546,6 +2550,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 ""
@ -3626,10 +3634,6 @@ msgstr "No queda nada en ejecución"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "Aún no hay recuerdos" msgstr "Aún no hay recuerdos"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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 ""
@ -5099,6 +5107,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"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr ""
msgid "About spaces" msgid "About spaces"
msgstr "" msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "" msgstr ""
@ -837,6 +837,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 ""
@ -2546,6 +2550,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 ""
@ -3626,10 +3634,6 @@ msgstr "कुछ भी नहीं चल रहा"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "अभी तक कुछ याद नहीं किया गया" msgstr "अभी तक कुछ याद नहीं किया गया"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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 ""
@ -5099,6 +5107,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 "टूल स्रोत"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr ""
msgid "About spaces" msgid "About spaces"
msgstr "" msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "" msgstr ""
@ -837,6 +837,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 ""
@ -2546,6 +2550,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 ""
@ -3626,10 +3634,6 @@ msgstr "실행 중인 작업 없음"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "아직 기억한 내용이 없습니다" msgstr "아직 기억한 내용이 없습니다"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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 ""
@ -5099,6 +5107,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 "도구 소스"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr ""
msgid "About spaces" msgid "About spaces"
msgstr "" msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "" msgstr ""
@ -837,6 +837,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 ""
@ -2546,6 +2550,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 ""
@ -3626,10 +3634,6 @@ msgstr "Nada ainda está em execução"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "Nada memorizado ainda" msgstr "Nada memorizado ainda"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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 ""
@ -5099,6 +5107,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"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr "О группах"
msgid "About spaces" msgid "About spaces"
msgstr "О пространствах" msgstr "О пространствах"
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "" msgstr ""
@ -837,6 +837,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 ""
@ -2546,6 +2550,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 ""
@ -3626,10 +3634,6 @@ msgstr ""
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "Пока ничего не запомнено" msgstr "Пока ничего не запомнено"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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 ""
@ -5099,6 +5107,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 "Источники инструментов"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr ""
msgid "About spaces" msgid "About spaces"
msgstr "" msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "" msgstr ""
@ -837,6 +837,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 ""
@ -2546,6 +2550,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 ""
@ -3626,10 +3634,6 @@ msgstr "Hâlâ çalışan bir şey yok"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "Henüz hatırlanan bir şey yok" msgstr "Henüz hatırlanan bir şey yok"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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 ""
@ -5099,6 +5107,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ı"
@ -5518,6 +5546,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

@ -311,7 +311,7 @@ msgstr ""
msgid "About spaces" msgid "About spaces"
msgstr "" msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/components/UserProfileSettings.tsx
msgid "About you" msgid "About you"
msgstr "" msgstr ""
@ -837,6 +837,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 ""
@ -2546,6 +2550,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 ""
@ -3626,10 +3634,6 @@ msgstr "没有仍在运行的任务"
msgid "Nothing remembered yet" msgid "Nothing remembered yet"
msgstr "暂无记忆" msgstr "暂无记忆"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx #: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
msgid "Notifications" msgid "Notifications"
@ -3846,6 +3850,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 ""
@ -5099,6 +5107,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 "工具源"
@ -5518,6 +5546,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

@ -15,6 +15,7 @@ import { SuccessPop } from "../components/ai/primitives";
import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint"; import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
import { DesktopUpdateSection } from "../components/DesktopUpdates"; import { DesktopUpdateSection } from "../components/DesktopUpdates";
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection"; import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
import { UserProfileSettings } from "../components/UserProfileSettings";
import { authClient } from "../lib/auth"; import { authClient } from "../lib/auth";
import { getActiveUiLocale, setUiLocale } from "../lib/i18n"; import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
import { import {
@ -65,6 +66,8 @@ export function GeneralSettingsPanels({
{email ? <p className="mt-1 text-ui-body-sm text-muted-foreground/70">{email}</p> : null} {email ? <p className="mt-1 text-ui-body-sm text-muted-foreground/70">{email}</p> : null}
</section> </section>
<UserProfileSettings />
<ChangePasswordSection email={email} /> <ChangePasswordSection email={email} />
{messagingEnabled && onOpenMessaging ? ( {messagingEnabled && onOpenMessaging ? (

View File

@ -12,7 +12,6 @@ import {
Monitor, Monitor,
Settings, Settings,
ShieldCheck, ShieldCheck,
UserRound,
Volume2, Volume2,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
@ -20,7 +19,6 @@ import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { MediaSettings } from "../components/MediaSettings"; import { MediaSettings } from "../components/MediaSettings";
import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel"; import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel";
import { UserProfileSettings } from "../components/UserProfileSettings";
import { import {
ComputerSettingsPanel, ComputerSettingsPanel,
GeneralSettingsPanels, GeneralSettingsPanels,
@ -38,7 +36,6 @@ export type SettingsSection =
| "integrations" | "integrations"
| "models" | "models"
| "memory" | "memory"
| "profile"
| "voice" | "voice"
| "media" | "media"
| "notifications" | "notifications"
@ -108,7 +105,6 @@ export function SettingsOverlay({
{ id: "integrations", label: t`Integrations`, icon: LayoutGrid }, { id: "integrations", label: t`Integrations`, icon: LayoutGrid },
{ id: "models", label: t`Models`, icon: Cpu }, { id: "models", label: t`Models`, icon: Cpu },
{ id: "memory", label: t`Memory`, icon: Brain }, { id: "memory", label: t`Memory`, icon: Brain },
{ id: "profile", label: t`About you`, icon: UserRound },
{ id: "voice", label: t`Voice`, icon: Volume2 }, { id: "voice", label: t`Voice`, icon: Volume2 },
...(isDeploymentOwner ? [{ id: "media" as const, label: t`Media`, icon: Clapperboard }] : []), ...(isDeploymentOwner ? [{ id: "media" as const, label: t`Media`, icon: Clapperboard }] : []),
{ id: "notifications", label: t`Notifications`, icon: Bell }, { id: "notifications", label: t`Notifications`, icon: Bell },
@ -247,7 +243,6 @@ export function SettingsOverlay({
onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined} onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined}
/> />
) : null} ) : null}
{section === "profile" ? <UserProfileSettings /> : null}
{section === "media" && isDeploymentOwner ? <MediaSettings /> : null} {section === "media" && isDeploymentOwner ? <MediaSettings /> : null}
{section === "notifications" ? <NotificationSettingsPanel /> : null} {section === "notifications" ? <NotificationSettingsPanel /> : null}
{section === "usage" ? ( {section === "usage" ? (

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, name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`,
plan, parentArtifactId: source.artifactId,
outputPath, preset,
workDir, }),
progress.range(index * share, (index + 1) * share),
{
name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`,
parentArtifactId: source.artifactId,
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;
artifact_id: Id,
presets: z.array(MediaPresetIdSchema).min(1).max(6), /** One output per platform and per preset: at least one, at most MAX_EXPORT_TARGETS in all. */
fit: MediaFitSchema.default("crop"), export const ExportVideoInputSchema = z
title: MediaTitleSchema.optional(), .object({
}); artifact_id: Id,
platforms: z.array(MediaPlatformIdSchema).max(MAX_EXPORT_TARGETS).optional(),
presets: z.array(MediaPresetIdSchema).max(MAX_EXPORT_TARGETS).optional(),
fit: MediaFitSchema.default("crop"),
title: MediaTitleSchema.optional(),
})
.refine(
(input) => {
const targets = (input.platforms?.length ?? 0) + (input.presets?.length ?? 0);
return targets >= 1 && targets <= MAX_EXPORT_TARGETS;
},
{
path: ["platforms"],
message: `name 1 to ${MAX_EXPORT_TARGETS} platforms and presets in all`,
},
);
export type ExportVideoInput = z.infer<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>;