Merge claude/media-platform-export into claude/media-smart-cuts
Both slices extend the edit plan request, the video operation imports, the edit_video contract imports and the media card E2E; the conflicts are unions of both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
2993013046
@ -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");
|
||||||
@ -236,3 +249,83 @@ test("shows a finished video analysis as a done card without files", async ({ pa
|
|||||||
await expect(card.getByRole("link")).toHaveCount(0);
|
await expect(card.getByRole("link")).toHaveCount(0);
|
||||||
await captureScreenshot(page, testInfo, "media-card-analysis");
|
await captureScreenshot(page, testInfo, "media-card-analysis");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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} />
|
||||||
@ -164,6 +168,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" })} />,
|
||||||
|
|||||||
@ -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";
|
||||||
@ -35,12 +37,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" };
|
||||||
|
|
||||||
@ -124,6 +128,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">
|
||||||
@ -139,7 +144,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}
|
||||||
@ -150,6 +155,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}
|
||||||
@ -178,6 +194,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.
|
||||||
@ -201,6 +237,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) => {
|
||||||
@ -261,7 +301,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>
|
||||||
@ -289,6 +329,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} />
|
||||||
)}
|
)}
|
||||||
@ -303,6 +344,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}
|
||||||
@ -393,7 +439,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.
|
||||||
{
|
{
|
||||||
|
|||||||
75
apps/web/src/components/media/media-platforms.test.ts
Normal file
75
apps/web/src/components/media/media-platforms.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/web/src/components/media/media-platforms.ts
Normal file
72
apps/web/src/components/media/media-platforms.ts
Normal 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)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -830,6 +830,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"
|
||||||
@ -2539,6 +2543,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"
|
||||||
@ -3833,6 +3841,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"
|
||||||
@ -5093,6 +5105,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"
|
||||||
@ -5516,6 +5548,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"
|
||||||
|
|||||||
@ -830,6 +830,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"
|
||||||
@ -2539,6 +2543,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"
|
||||||
@ -3833,6 +3841,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"
|
||||||
@ -5093,6 +5105,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"
|
||||||
@ -5516,6 +5548,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"
|
||||||
|
|||||||
@ -830,6 +830,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 ""
|
||||||
@ -2539,6 +2543,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 ""
|
||||||
@ -3833,6 +3841,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 ""
|
||||||
@ -5093,6 +5105,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"
|
||||||
@ -5516,6 +5548,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"
|
||||||
|
|||||||
@ -830,6 +830,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 ""
|
||||||
@ -2539,6 +2543,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 ""
|
||||||
@ -3833,6 +3841,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 ""
|
||||||
@ -5093,6 +5105,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 "टूल स्रोत"
|
||||||
@ -5516,6 +5548,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 "कल"
|
||||||
|
|||||||
@ -830,6 +830,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 ""
|
||||||
@ -2539,6 +2543,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 ""
|
||||||
@ -3833,6 +3841,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 ""
|
||||||
@ -5093,6 +5105,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 "도구 소스"
|
||||||
@ -5516,6 +5548,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 "어제"
|
||||||
|
|||||||
@ -830,6 +830,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 ""
|
||||||
@ -2539,6 +2543,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 ""
|
||||||
@ -3833,6 +3841,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 ""
|
||||||
@ -5093,6 +5105,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"
|
||||||
@ -5516,6 +5548,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"
|
||||||
|
|||||||
@ -830,6 +830,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 ""
|
||||||
@ -2539,6 +2543,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 ""
|
||||||
@ -3833,6 +3841,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 ""
|
||||||
@ -5093,6 +5105,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 "Источники инструментов"
|
||||||
@ -5516,6 +5548,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 "Вчера"
|
||||||
|
|||||||
@ -830,6 +830,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 ""
|
||||||
@ -2539,6 +2543,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 ""
|
||||||
@ -3833,6 +3841,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 ""
|
||||||
@ -5093,6 +5105,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ı"
|
||||||
@ -5516,6 +5548,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"
|
||||||
|
|||||||
@ -830,6 +830,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 ""
|
||||||
@ -2539,6 +2543,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 ""
|
||||||
@ -3833,6 +3841,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 ""
|
||||||
@ -5093,6 +5105,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 "工具源"
|
||||||
@ -5516,6 +5548,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 "昨天"
|
||||||
|
|||||||
@ -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. */
|
||||||
@ -176,6 +181,8 @@ export interface MediaJobContext {
|
|||||||
): Promise<MediaJobInputFile | null>;
|
): Promise<MediaJobInputFile | null>;
|
||||||
/** 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 {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
ExportVideoInputSchema,
|
ExportVideoInputSchema,
|
||||||
GenerateImageInputSchema,
|
GenerateImageInputSchema,
|
||||||
GenerateVideoInputSchema,
|
GenerateVideoInputSchema,
|
||||||
|
MEDIA_PLATFORM_PROFILES,
|
||||||
MODEL_TIERS,
|
MODEL_TIERS,
|
||||||
SecretAskPurpose,
|
SecretAskPurpose,
|
||||||
SecretHttpRequest,
|
SecretHttpRequest,
|
||||||
@ -38,6 +39,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",
|
||||||
@ -568,7 +574,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),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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";
|
||||||
@ -161,7 +163,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,
|
||||||
@ -171,6 +183,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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,8 +363,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 },
|
||||||
@ -360,7 +374,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))
|
||||||
@ -390,7 +413,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) => {
|
||||||
@ -412,7 +435,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 };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -819,10 +842,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");
|
||||||
@ -834,6 +862,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 {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
@ -210,6 +212,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 }[];
|
||||||
@ -256,6 +259,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,
|
||||||
@ -263,6 +267,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) } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,6 +501,94 @@ export async function extractAudio(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EBU R128 values of a file's first audio track, as ffmpeg's loudnorm filter measures them. */
|
||||||
|
export interface LoudnessMeasurement {
|
||||||
|
/** Integrated loudness in LUFS; -Infinity for digital silence. */
|
||||||
|
integratedLufs: number;
|
||||||
|
truePeakDb: number;
|
||||||
|
rangeLu: number;
|
||||||
|
thresholdLufs: number;
|
||||||
|
/** Gain loudnorm's second pass adds after limiting, for the targets it measured against. */
|
||||||
|
offsetLu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOUDNESS_TIMEOUT_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
/** loudnorm's filter string: the target, and the first pass's numbers for a linear second pass. */
|
||||||
|
export function loudnormFilter(
|
||||||
|
target: { integratedLufs: number; truePeakDb: number },
|
||||||
|
measured?: LoudnessMeasurement,
|
||||||
|
): string {
|
||||||
|
const options = [
|
||||||
|
`I=${target.integratedLufs}`,
|
||||||
|
`TP=${target.truePeakDb}`,
|
||||||
|
// A wide loudness range keeps loudnorm linear (no pumping) for most speech and music.
|
||||||
|
"LRA=11",
|
||||||
|
...(measured
|
||||||
|
? [
|
||||||
|
`measured_I=${measured.integratedLufs}`,
|
||||||
|
`measured_TP=${measured.truePeakDb}`,
|
||||||
|
`measured_LRA=${measured.rangeLu}`,
|
||||||
|
`measured_thresh=${measured.thresholdLufs}`,
|
||||||
|
`offset=${measured.offsetLu}`,
|
||||||
|
"linear=true",
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
return `loudnorm=${options.join(":")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Measures the loudness of a file with an audio track against a target. */
|
||||||
|
export async function measureLoudness(
|
||||||
|
input: string,
|
||||||
|
target: { integratedLufs: number; truePeakDb: number },
|
||||||
|
options: { signal?: AbortSignal; mimeType?: string } = {},
|
||||||
|
): Promise<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,
|
||||||
|
|||||||
@ -104,6 +104,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 };
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
@ -52,12 +52,13 @@ export interface TestArtifact {
|
|||||||
parentArtifactId?: string;
|
parentArtifactId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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",
|
||||||
@ -111,10 +112,15 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
188
packages/adapters/src/media/platform-export.test.ts
Normal file
188
packages/adapters/src/media/platform-export.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
221
packages/adapters/src/media/platform-export.ts
Normal file
221
packages/adapters/src/media/platform-export.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -151,6 +151,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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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
|
||||||
@ -32,9 +32,22 @@ export interface VideoEditPlanRequest {
|
|||||||
* into a null output, so they can report on it (silencedetect writing a file, say).
|
* into a null output, so they can report on it (silencedetect writing a file, say).
|
||||||
*/
|
*/
|
||||||
analyzeAudio?: readonly string[];
|
analyzeAudio?: readonly 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";
|
||||||
@ -56,7 +69,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"];
|
||||||
@ -246,6 +260,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) {
|
||||||
@ -303,6 +319,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",
|
||||||
@ -316,6 +333,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;
|
||||||
|
|||||||
@ -178,6 +178,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();
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
@ -1,25 +1,50 @@
|
|||||||
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 { detectSpeech } from "./silence.js";
|
import { detectSpeech } from "./silence.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. */
|
||||||
@ -67,7 +92,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: {
|
||||||
@ -154,39 +183,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,
|
||||||
@ -194,8 +344,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,
|
||||||
@ -206,10 +372,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,
|
||||||
@ -306,13 +479,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. */
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export * from "./media.js";
|
|||||||
export * from "./media-analysis.js";
|
export * from "./media-analysis.js";
|
||||||
export * from "./media-generation.js";
|
export * from "./media-generation.js";
|
||||||
export * from "./media-jobs.js";
|
export * from "./media-jobs.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";
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
69
packages/contracts/src/media-platforms.test.ts
Normal file
69
packages/contracts/src/media-platforms.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
282
packages/contracts/src/media-platforms.ts
Normal file
282
packages/contracts/src/media-platforms.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
28
packages/contracts/src/media-presets.ts
Normal file
28
packages/contracts/src/media-presets.ts
Normal 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>;
|
||||||
@ -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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
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 { RemoveSilenceStepSchema } from "./media-analysis.js";
|
import { RemoveSilenceStepSchema } from "./media-analysis.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
|
||||||
@ -73,10 +74,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>;
|
||||||
|
|||||||
@ -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([
|
||||||
@ -33,33 +39,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,
|
||||||
@ -73,6 +52,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>;
|
||||||
|
|
||||||
@ -87,6 +69,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>;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user