Merge remote-tracking branch 'origin/main' into claude/voice-picker-carousel-426nfu
This commit is contained in:
commit
e0c7cf9f84
@ -245,7 +245,6 @@ export async function openUserSettings(
|
||||
| "integrations"
|
||||
| "models"
|
||||
| "memory"
|
||||
| "profile"
|
||||
| "voice"
|
||||
| "notifications"
|
||||
| "usage"
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
@ -92,6 +95,38 @@ async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlo
|
||||
}
|
||||
}
|
||||
|
||||
/** CI has no ffmpeg or transcription server; the card asks what the space can run. */
|
||||
async function offerFollowUps(page: Page) {
|
||||
await page.route("**/rpc/media/capabilities", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
generate_image: false,
|
||||
generate_video: false,
|
||||
edit_image: false,
|
||||
edit_video: false,
|
||||
export_video: true,
|
||||
transcribe: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Records follow-up jobs the card starts instead of running them. */
|
||||
async function recordStartedJobs(page: Page): Promise<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) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card");
|
||||
@ -150,30 +185,8 @@ test("renders a media job card while it runs and when it is done", async ({ page
|
||||
],
|
||||
},
|
||||
);
|
||||
// CI has no transcription server; the card asks what the space can run.
|
||||
await page.route("**/rpc/media/capabilities", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
generate_image: false,
|
||||
generate_video: false,
|
||||
edit_image: false,
|
||||
edit_video: false,
|
||||
export_video: true,
|
||||
transcribe: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const started: unknown[] = [];
|
||||
await page.route("**/rpc/media/jobs/start", async (route) => {
|
||||
started.push((route.request().postDataJSON() as { json: unknown }).json);
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }),
|
||||
});
|
||||
});
|
||||
await offerFollowUps(page);
|
||||
const started = await recordStartedJobs(page);
|
||||
await page.reload();
|
||||
|
||||
const card = page.getByTestId("media-card");
|
||||
@ -209,3 +222,83 @@ test("renders a media job card while it runs and when it is done", async ({ page
|
||||
input: { artifact_id: voice.id, title: "voice-note.wav" },
|
||||
});
|
||||
});
|
||||
|
||||
test("a platform export names its platform, shows a failed check and exports for others", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `media-platform-${stamp}@rakazo.test`, "password12", "Media Platform");
|
||||
await completeOnboarding(page);
|
||||
const botId = activeBotId(page);
|
||||
|
||||
const fixture = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"fixtures",
|
||||
"media-clip.mp4",
|
||||
);
|
||||
const clip = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
|
||||
botId,
|
||||
name: "clip-tiktok.mp4",
|
||||
mimeType: "video/mp4",
|
||||
contentBase64: readFileSync(fixture).toString("base64"),
|
||||
});
|
||||
await seedMediaMessage(page, botId, () => ({
|
||||
kind: "media",
|
||||
jobId: "media-job-platforms",
|
||||
operation: "export_video",
|
||||
status: "done",
|
||||
title: "TikTok · Snapchat Spotlight",
|
||||
outputs: [
|
||||
{
|
||||
artifactId: clip.id,
|
||||
kind: "video",
|
||||
mimeType: "video/mp4",
|
||||
name: "clip-tiktok.mp4",
|
||||
size: clip.size,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationMs: 1000,
|
||||
platform: "tiktok",
|
||||
check: {
|
||||
status: "fail",
|
||||
issues: [{ code: "file_too_large", maxMb: 287, actualMb: 301.2 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
skipped: [
|
||||
{
|
||||
platform: "snapchat-spotlight",
|
||||
issues: [{ code: "too_short", minMs: 5000, actualMs: 1000 }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
await offerFollowUps(page);
|
||||
const started = await recordStartedJobs(page);
|
||||
await page.reload();
|
||||
|
||||
const card = page.getByTestId("media-card");
|
||||
await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 });
|
||||
await expect(card).toContainText("Too large for TikTok (max 287 MB)");
|
||||
await expect(card).toContainText("Too short for Snapchat Spotlight (min 0:05)");
|
||||
await captureScreenshot(page, testInfo, "media-card-platform-check");
|
||||
|
||||
await card.getByRole("button", { name: "Export" }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu.getByRole("menuitem", { name: "YouTube Shorts" })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Instagram Reels" })).toBeVisible();
|
||||
// Not the platform or the shape it already has.
|
||||
await expect(menu.getByRole("menuitem", { name: "TikTok" })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: "9:16" })).toHaveCount(0);
|
||||
await expect(menu.getByRole("separator")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem", { name: "1:1" })).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "media-card-platform-export-menu");
|
||||
|
||||
await menu.getByRole("menuitem", { name: "YouTube Shorts" }).click();
|
||||
await expect.poll(() => started.length).toBe(1);
|
||||
expect(started[0]).toEqual({
|
||||
botId,
|
||||
messageId: expect.stringMatching(/^media-message-\d+$/),
|
||||
operation: "export_video",
|
||||
input: { artifact_id: clip.id, platforms: ["youtube-shorts"] },
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* E2E: Settings → About you edits a fact in place: its wording, category and the spaces it
|
||||
* E2E: Settings → General → About you edits a fact in place: its wording, category and the spaces it
|
||||
* shows in.
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
@ -15,7 +15,7 @@ test("a fact's wording, category and spaces are edited in place", async ({ page
|
||||
addFacts: [{ category: "preferences", value: "Prefers tea" }],
|
||||
});
|
||||
|
||||
const settings = await openUserSettings(page, "profile");
|
||||
const settings = await openUserSettings(page);
|
||||
const profile = settings.getByTestId("user-profile-settings");
|
||||
const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" });
|
||||
await tea.getByRole("button", { name: "Edit" }).click();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* E2E: onboarding fills the user profile without asking (time zone, locale and language from
|
||||
* the browser) and records the focus answer as a fact; Settings → About you shows every fact
|
||||
* the browser) and records the focus answer as a fact; Settings → General → About you shows every fact
|
||||
* with its origin, confirms a pending one, and forgets one or all of them.
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
@ -58,8 +58,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
||||
addFacts: [{ category: "health", value: "Allergic to peanuts", source: "onboarding" }],
|
||||
});
|
||||
|
||||
const settings = await openUserSettings(page, "profile");
|
||||
await expect(settings).toHaveAttribute("data-settings-section", "profile");
|
||||
const settings = await openUserSettings(page);
|
||||
await expect(settings).toHaveAttribute("data-settings-section", "general");
|
||||
const profile = settings.getByTestId("user-profile-settings");
|
||||
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
|
||||
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
|
||||
@ -99,6 +99,6 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
||||
|
||||
await profile.getByRole("button", { name: "Forget everything" }).click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Forget", exact: true }).click();
|
||||
await expect(profile).toContainText("Nothing yet");
|
||||
await expect(profile).toHaveCount(0);
|
||||
expect(await rpc<{ facts: unknown[] }>(page, "profile/get", null)).toMatchObject({ facts: [] });
|
||||
});
|
||||
|
||||
@ -151,20 +151,22 @@ export function UserProfileSettings() {
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!error && (!state || (fields.length === 0 && groups.length === 0))) return null;
|
||||
|
||||
return (
|
||||
<div data-testid="user-profile-settings" className="space-y-5">
|
||||
<section
|
||||
data-testid="user-profile-settings"
|
||||
className="space-y-4 rounded-xl border border-border px-4 py-4"
|
||||
>
|
||||
<h3 className="text-ui-body font-medium text-foreground">
|
||||
<Trans>About you</Trans>
|
||||
</h3>
|
||||
{error ? (
|
||||
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{state && fields.length === 0 && groups.length === 0 ? (
|
||||
<p className="text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Nothing yet</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{fields.length > 0 ? (
|
||||
<section className="rounded-xl border border-border px-4 py-4">
|
||||
<ul className="space-y-3">
|
||||
{fields.map(({ field, value }) => (
|
||||
<Row
|
||||
@ -187,18 +189,13 @@ export function UserProfileSettings() {
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
key={group.category}
|
||||
data-testid={`user-facts-${group.category}`}
|
||||
className="rounded-xl border border-border px-4 py-4"
|
||||
>
|
||||
<h3 className="text-ui-body font-medium text-foreground">
|
||||
<div key={group.category} data-testid={`user-facts-${group.category}`}>
|
||||
<h4 className="text-ui-body-sm text-muted-foreground">
|
||||
{categoryLabels[group.category]}
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-3">
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-3">
|
||||
{group.facts.map((fact) =>
|
||||
fact.id === editingId ? (
|
||||
<UserFactEditor
|
||||
@ -269,7 +266,7 @@ export function UserProfileSettings() {
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
{fields.length > 0 || groups.length > 0 ? (
|
||||
<Button variant="destructive" disabled={busy} onClick={() => setForgetOpen(true)}>
|
||||
@ -300,6 +297,6 @@ export function UserProfileSettings() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,10 @@ vi.mock("@lingui/react/macro", () => {
|
||||
parts.reduce((text, part, index) => `${text}${part}${values[index] ?? ""}`, "");
|
||||
return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children };
|
||||
});
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (parts: TemplateStringsArray, ...values: unknown[]) =>
|
||||
parts.reduce((out, part, index) => `${out}${String(values[index - 1])}${part}`),
|
||||
}));
|
||||
vi.mock("../../pages/shell/message-cards", () => ({
|
||||
ArtifactImage: ({ artifactId, name }: { artifactId: string; name: string }) => (
|
||||
<img data-artifact={artifactId} alt={name} />
|
||||
@ -147,6 +151,65 @@ describe("media card", () => {
|
||||
expect(html).not.toContain("Transcribe");
|
||||
});
|
||||
|
||||
it("names the platform of an export, and its first problem only when the check did not pass", () => {
|
||||
const tiktok = {
|
||||
...video,
|
||||
artifactId: "tiktok-1",
|
||||
platform: "tiktok" as const,
|
||||
check: { status: "pass" as const, issues: [] },
|
||||
};
|
||||
const shorts = {
|
||||
...video,
|
||||
artifactId: "shorts-1",
|
||||
platform: "youtube-shorts" as const,
|
||||
check: {
|
||||
status: "warn" as const,
|
||||
issues: [
|
||||
{ code: "loudness_off" as const, targetLufs: -14, actualLufs: -18 },
|
||||
{ code: "true_peak_high" as const, maxDb: -1, actualDb: 0 },
|
||||
],
|
||||
},
|
||||
};
|
||||
const html = renderToString(
|
||||
<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", () => {
|
||||
const html = renderToString(
|
||||
<MediaCardView block={block({ status: "failed", error: "GPU server is unreachable" })} />,
|
||||
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
MediaJobStatus,
|
||||
MediaOperation,
|
||||
MediaOutput,
|
||||
MediaPlatformId,
|
||||
MediaPresetId,
|
||||
} from "@rakazo/contracts";
|
||||
import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts";
|
||||
@ -14,6 +15,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@rakazo/ui-web";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@ -26,12 +28,14 @@ import { ArtifactImage } from "../../pages/shell/message-cards";
|
||||
import { ArtifactFileCard } from "../ArtifactFileCard";
|
||||
import { Shimmer } from "../ai/primitives";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
import { exportPlatformsFor, platformIssueLine, platformName } from "./media-platforms";
|
||||
|
||||
const CARD_CLASS =
|
||||
"w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 text-start shadow-xs";
|
||||
|
||||
export type MediaCardAction =
|
||||
| { kind: "export"; artifactId: string; preset: MediaPresetId }
|
||||
| { kind: "export"; artifactId: string; platform: MediaPlatformId }
|
||||
| { kind: "transcribe"; artifactId: string; name: string }
|
||||
| { kind: "cancel" };
|
||||
|
||||
@ -114,6 +118,7 @@ function OutputActions({
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const name = output.name;
|
||||
const platforms = output.kind === "video" ? exportPlatformsFor(output) : [];
|
||||
const presets = output.kind === "video" ? exportPresetsFor(output) : [];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@ -129,7 +134,7 @@ function OutputActions({
|
||||
>
|
||||
<Download />
|
||||
</a>
|
||||
{onAction && available.export && presets.length > 0 ? (
|
||||
{onAction && available.export && platforms.length + presets.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={busy}
|
||||
@ -140,6 +145,17 @@ function OutputActions({
|
||||
<ChevronDown />
|
||||
</DropdownMenuTrigger>
|
||||
<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) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
@ -168,6 +184,26 @@ function OutputActions({
|
||||
);
|
||||
}
|
||||
|
||||
/** The platform an output was exported for, or its first problem there. */
|
||||
function PlatformLine({ output }: { output: MediaOutput }) {
|
||||
if (!output.platform) return null;
|
||||
const issue = output.check?.status === "pass" ? undefined : output.check?.issues[0];
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"text-ui-body-sm",
|
||||
!issue
|
||||
? "text-muted-foreground"
|
||||
: output.check?.status === "fail"
|
||||
? "text-destructive"
|
||||
: "text-warning",
|
||||
)}
|
||||
>
|
||||
{issue ? platformIssueLine(output.platform, issue) : platformName(output.platform)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live card of a media job: what it makes, how far it is, and the results with the
|
||||
* follow-ups that fit them (export other formats, transcribe). Nothing else is shown.
|
||||
@ -191,6 +227,10 @@ export function MediaCardView({
|
||||
const percent = Math.round((block.progress ?? 0) * 100);
|
||||
const images = block.outputs.filter((output) => output.kind === "image");
|
||||
const others = block.outputs.filter((output) => output.kind !== "image");
|
||||
const skipped = block.skipped ?? [];
|
||||
// When nothing was rendered because every platform was skipped, the skipped lines say why.
|
||||
const showError =
|
||||
block.status === "failed" && block.error && !(skipped.length && !block.outputs.length);
|
||||
|
||||
const act = onAction
|
||||
? (action: MediaCardAction) => {
|
||||
@ -251,7 +291,7 @@ export function MediaCardView({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{block.status === "failed" && block.error ? (
|
||||
{showError ? (
|
||||
<p className="mt-2 text-ui-body-sm text-destructive" dir="auto">
|
||||
{block.error}
|
||||
</p>
|
||||
@ -279,6 +319,7 @@ export function MediaCardView({
|
||||
width={output.width}
|
||||
height={output.height}
|
||||
/>
|
||||
<PlatformLine output={output} />
|
||||
{active ? null : (
|
||||
<OutputActions output={output} available={available} busy={busy} onAction={act} />
|
||||
)}
|
||||
@ -293,6 +334,11 @@ export function MediaCardView({
|
||||
)}
|
||||
</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 ? (
|
||||
<p role="alert" className="mt-2 text-ui-body-sm text-destructive" dir="auto">
|
||||
{actionError}
|
||||
@ -383,7 +429,10 @@ export function MediaCard({
|
||||
...(action.kind === "export"
|
||||
? {
|
||||
operation: "export_video",
|
||||
input: { artifact_id: action.artifactId, presets: [action.preset] },
|
||||
input:
|
||||
"platform" in action
|
||||
? { artifact_id: action.artifactId, platforms: [action.platform] }
|
||||
: { artifact_id: action.artifactId, presets: [action.preset] },
|
||||
}
|
||||
: // The new card is titled with the file it transcribes.
|
||||
{
|
||||
|
||||
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)`;
|
||||
}
|
||||
}
|
||||
@ -311,7 +311,7 @@ msgstr "Über Gruppen"
|
||||
msgid "About spaces"
|
||||
msgstr "Über Spaces"
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr "Über dich"
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Bearer-Token"
|
||||
msgid "Bell"
|
||||
msgstr "Glöckchen"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr "Bitrate zu hoch für {name} (maximal {max} kbps)"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr "In deinen Browser-Einstellungen blockiert"
|
||||
@ -2529,6 +2533,10 @@ msgstr "Passwort vergessen?"
|
||||
msgid "Forward"
|
||||
msgstr "Vorwärts"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr "Bildrate zu hoch für {name} (maximal {max} fps)"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr "Sommersprossen"
|
||||
@ -3607,10 +3615,6 @@ msgstr "Nichts läuft mehr"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Noch nichts gemerkt"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr "Noch nichts"
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr "Pfote"
|
||||
msgid "Peach"
|
||||
msgstr "Pfirsich"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr "Pegelspitzen zu hoch für {name} (maximal {max} dBTP)"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr "Pinguin"
|
||||
@ -5075,6 +5083,26 @@ msgstr "Heute"
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr "Heute, {heuteActionCount} brauchen dich"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr "Zu groß für {name} (maximal {max} MB)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr "Zu lang für {name} (maximal {max})"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr "Zu laut für {name} (Zielwert {target} LUFS)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr "Zu leise für {name} (Zielwert {target} LUFS)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr "Zu kurz für {name} (mindestens {min})"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "Toolquellen"
|
||||
@ -5494,6 +5522,14 @@ msgstr "Arbeitet…"
|
||||
msgid "Working…"
|
||||
msgstr "Wird verarbeitet…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr "Falsches Format für {name} (braucht H.264)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr "Falsche Größe für {name} (braucht {size})"
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "Gestern"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr "About groups"
|
||||
msgid "About spaces"
|
||||
msgstr "About spaces"
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr "About you"
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Bearer token"
|
||||
msgid "Bell"
|
||||
msgstr "Bell"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr "Bitrate too high for {name} (max {max} kbps)"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr "Blocked in your browser settings"
|
||||
@ -2529,6 +2533,10 @@ msgstr "Forgot password?"
|
||||
msgid "Forward"
|
||||
msgstr "Forward"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr "Frame rate too high for {name} (max {max} fps)"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr "Freckles"
|
||||
@ -3607,10 +3615,6 @@ msgstr "Nothing is still running"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Nothing remembered yet"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr "Nothing yet"
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr "Paw"
|
||||
msgid "Peach"
|
||||
msgstr "Peach"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr "Peaks too high for {name} (max {max} dBTP)"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr "Penguin"
|
||||
@ -5075,6 +5083,26 @@ msgstr "Today"
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr "Today, {heuteActionCount} need you"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr "Too large for {name} (max {max} MB)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr "Too long for {name} (max {max})"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr "Too loud for {name} (target {target} LUFS)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr "Too quiet for {name} (target {target} LUFS)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr "Too short for {name} (min {min})"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "Tool sources"
|
||||
@ -5494,6 +5522,14 @@ msgstr "Working…"
|
||||
msgid "Working…"
|
||||
msgstr "Working…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr "Wrong format for {name} (needs H.264)"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr "Wrong size for {name} (needs {size})"
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "Yesterday"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr ""
|
||||
msgid "About spaces"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr ""
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Token Bearer"
|
||||
msgid "Bell"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
@ -2529,6 +2533,10 @@ msgstr "¿Olvidaste la contraseña?"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr ""
|
||||
@ -3607,10 +3615,6 @@ msgstr "No queda nada en ejecución"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Aún no hay recuerdos"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr ""
|
||||
msgid "Peach"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr ""
|
||||
@ -5075,6 +5083,26 @@ msgstr ""
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "Fuentes de herramientas"
|
||||
@ -5494,6 +5522,14 @@ msgstr "Trabajando…"
|
||||
msgid "Working…"
|
||||
msgstr "Trabajando…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "Ayer"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr ""
|
||||
msgid "About spaces"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr ""
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "बियरर टोकन"
|
||||
msgid "Bell"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
@ -2529,6 +2533,10 @@ msgstr "पासवर्ड भूल गए?"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr ""
|
||||
@ -3607,10 +3615,6 @@ msgstr "कुछ भी नहीं चल रहा"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "अभी तक कुछ याद नहीं किया गया"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr ""
|
||||
msgid "Peach"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr ""
|
||||
@ -5075,6 +5083,26 @@ msgstr ""
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "टूल स्रोत"
|
||||
@ -5494,6 +5522,14 @@ msgstr "काम चल रहा है…"
|
||||
msgid "Working…"
|
||||
msgstr "काम चल रहा है…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "कल"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr ""
|
||||
msgid "About spaces"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr ""
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Bearer 토큰"
|
||||
msgid "Bell"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
@ -2529,6 +2533,10 @@ msgstr "비밀번호를 잊으셨나요?"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr ""
|
||||
@ -3607,10 +3615,6 @@ msgstr "실행 중인 작업 없음"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "아직 기억한 내용이 없습니다"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr ""
|
||||
msgid "Peach"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr ""
|
||||
@ -5075,6 +5083,26 @@ msgstr ""
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "도구 소스"
|
||||
@ -5494,6 +5522,14 @@ msgstr "처리 중…"
|
||||
msgid "Working…"
|
||||
msgstr "처리 중…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "어제"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr ""
|
||||
msgid "About spaces"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr ""
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Token portador"
|
||||
msgid "Bell"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
@ -2529,6 +2533,10 @@ msgstr "Esqueceu a senha?"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr ""
|
||||
@ -3607,10 +3615,6 @@ msgstr "Nada ainda está em execução"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Nada memorizado ainda"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr ""
|
||||
msgid "Peach"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr ""
|
||||
@ -5075,6 +5083,26 @@ msgstr ""
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "Fontes de ferramentas"
|
||||
@ -5494,6 +5522,14 @@ msgstr "Funcionando"
|
||||
msgid "Working…"
|
||||
msgstr "Funcionando"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "Ontem"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr "О группах"
|
||||
msgid "About spaces"
|
||||
msgstr "О пространствах"
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr ""
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Bearer-токен"
|
||||
msgid "Bell"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
@ -2529,6 +2533,10 @@ msgstr "Забыли пароль?"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr ""
|
||||
@ -3607,10 +3615,6 @@ msgstr ""
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Пока ничего не запомнено"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr ""
|
||||
msgid "Peach"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr ""
|
||||
@ -5075,6 +5083,26 @@ msgstr ""
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "Источники инструментов"
|
||||
@ -5494,6 +5522,14 @@ msgstr "Выполняется…"
|
||||
msgid "Working…"
|
||||
msgstr "Выполняется…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "Вчера"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr ""
|
||||
msgid "About spaces"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr ""
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Bearer token"
|
||||
msgid "Bell"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
@ -2529,6 +2533,10 @@ msgstr "Parolanızı mı unuttunuz?"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr ""
|
||||
@ -3607,10 +3615,6 @@ msgstr "Hâlâ çalışan bir şey yok"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Henüz hatırlanan bir şey yok"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr ""
|
||||
msgid "Peach"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr ""
|
||||
@ -5075,6 +5083,26 @@ msgstr ""
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "Araç kaynakları"
|
||||
@ -5494,6 +5522,14 @@ msgstr "Çalışıyor…"
|
||||
msgid "Working…"
|
||||
msgstr "Çalışıyor…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "Dün"
|
||||
|
||||
@ -311,7 +311,7 @@ msgstr ""
|
||||
msgid "About spaces"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "About you"
|
||||
msgstr ""
|
||||
|
||||
@ -835,6 +835,10 @@ msgstr "Bearer token"
|
||||
msgid "Bell"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Bitrate too high for {name} (max {max} kbps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
@ -2529,6 +2533,10 @@ msgstr "忘记密码?"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Frame rate too high for {name} (max {max} fps)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Freckles"
|
||||
msgstr ""
|
||||
@ -3607,10 +3615,6 @@ msgstr "没有仍在运行的任务"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "暂无记忆"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
@ -3815,6 +3819,10 @@ msgstr ""
|
||||
msgid "Peach"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Peaks too high for {name} (max {max} dBTP)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Penguin"
|
||||
msgstr ""
|
||||
@ -5075,6 +5083,26 @@ msgstr ""
|
||||
msgid "Today, {heuteActionCount} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too large for {name} (max {max} MB)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too long for {name} (max {max})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too loud for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too quiet for {name} (target {target} LUFS)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Too short for {name} (min {min})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Tool sources"
|
||||
msgstr "工具源"
|
||||
@ -5494,6 +5522,14 @@ msgstr "处理中…"
|
||||
msgid "Working…"
|
||||
msgstr "处理中…"
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong format for {name} (needs H.264)"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/media/media-platforms.ts
|
||||
msgid "Wrong size for {name} (needs {size})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/roster-format.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "昨天"
|
||||
|
||||
@ -15,6 +15,7 @@ import { SuccessPop } from "../components/ai/primitives";
|
||||
import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
|
||||
import { DesktopUpdateSection } from "../components/DesktopUpdates";
|
||||
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
|
||||
import { UserProfileSettings } from "../components/UserProfileSettings";
|
||||
import { authClient } from "../lib/auth";
|
||||
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
|
||||
import {
|
||||
@ -67,6 +68,8 @@ export function GeneralSettingsPanels({
|
||||
{email ? <p className="mt-1 text-ui-body-sm text-muted-foreground/70">{email}</p> : null}
|
||||
</section>
|
||||
|
||||
<UserProfileSettings />
|
||||
|
||||
<ChangePasswordSection email={email} />
|
||||
|
||||
{messagingEnabled && onOpenMessaging ? (
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
Monitor,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
Volume2,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
@ -20,7 +19,6 @@ import { type ComponentType, useEffect, useRef, useState } from "react";
|
||||
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
||||
import { MediaSettings } from "../components/MediaSettings";
|
||||
import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel";
|
||||
import { UserProfileSettings } from "../components/UserProfileSettings";
|
||||
import {
|
||||
ComputerSettingsPanel,
|
||||
GeneralSettingsPanels,
|
||||
@ -38,7 +36,6 @@ export type SettingsSection =
|
||||
| "integrations"
|
||||
| "models"
|
||||
| "memory"
|
||||
| "profile"
|
||||
| "voice"
|
||||
| "media"
|
||||
| "notifications"
|
||||
@ -108,7 +105,6 @@ export function SettingsOverlay({
|
||||
{ id: "integrations", label: t`Integrations`, icon: LayoutGrid },
|
||||
{ id: "models", label: t`Models`, icon: Cpu },
|
||||
{ id: "memory", label: t`Memory`, icon: Brain },
|
||||
{ id: "profile", label: t`About you`, icon: UserRound },
|
||||
{ id: "voice", label: t`Voice`, icon: Volume2 },
|
||||
...(isDeploymentOwner ? [{ id: "media" as const, label: t`Media`, icon: Clapperboard }] : []),
|
||||
{ id: "notifications", label: t`Notifications`, icon: Bell },
|
||||
@ -247,7 +243,6 @@ export function SettingsOverlay({
|
||||
onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{section === "profile" ? <UserProfileSettings /> : null}
|
||||
{section === "media" && isDeploymentOwner ? <MediaSettings /> : null}
|
||||
{section === "notifications" ? <NotificationSettingsPanel /> : null}
|
||||
{section === "usage" ? (
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import type {
|
||||
MediaCheck,
|
||||
MediaJobStatus,
|
||||
MediaOperation,
|
||||
MediaOutput,
|
||||
MediaOutputKind,
|
||||
MediaPlatformId,
|
||||
MediaPresetId,
|
||||
MediaSkippedTarget,
|
||||
} from "@rakazo/contracts";
|
||||
import type { AdapterDescriptor } from "./types.js";
|
||||
|
||||
@ -137,6 +140,8 @@ export interface MediaJobOutputFile {
|
||||
posterPath?: string;
|
||||
parentArtifactId?: string;
|
||||
preset?: MediaPresetId;
|
||||
platform?: MediaPlatformId;
|
||||
check?: MediaCheck;
|
||||
}
|
||||
|
||||
/** An artifact the job may read, copied into the job's temp directory. */
|
||||
@ -168,6 +173,8 @@ export interface MediaJobContext {
|
||||
fetchArtifact(artifactId: string): Promise<MediaJobInputFile>;
|
||||
/** Stores a produced file as an artifact and adds it to the media card. */
|
||||
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 {
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
ExportVideoInputSchema,
|
||||
GenerateImageInputSchema,
|
||||
GenerateVideoInputSchema,
|
||||
MEDIA_PLATFORM_PROFILES,
|
||||
MODEL_TIERS,
|
||||
SecretAskPurpose,
|
||||
SecretHttpRequest,
|
||||
@ -34,6 +35,11 @@ export const SUBAGENT_MODEL_TIER_DESCRIPTION =
|
||||
const VIDEO_PRESETS_AND_FITS =
|
||||
"Presets: vertical 9:16, landscape 16:9, square 1:1, portrait 4:5, classic 4:3, cinema 21:9. fit: crop (default) fills the frame and cuts the edges, pad adds bars, blur fills the bars with a blurred copy.";
|
||||
|
||||
/** "tiktok 600 s, …": each platform id with the longest video it takes. */
|
||||
const VIDEO_PLATFORMS = Object.values(MEDIA_PLATFORM_PROFILES)
|
||||
.map((profile) => `${profile.id} ${profile.duration.hardMaxMs / 1000} s`)
|
||||
.join(", ");
|
||||
|
||||
export const DELEGATION_TOOL_NAMES = new Set([
|
||||
"run_subagent",
|
||||
"spawn_bot",
|
||||
@ -564,7 +570,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
},
|
||||
{
|
||||
name: "export_video",
|
||||
description: `Export a video artifact as one H.264/AAC MP4 per preset. ${VIDEO_PRESETS_AND_FITS} For cuts, speed, audio or subtitles use edit_video.`,
|
||||
description: `Export a video artifact as one H.264/AAC MP4 per platform and per preset, 6 at most. When the user names where it will be posted, use platforms: each file is fitted to that platform's frame, frame rate, bitrate and loudness, then checked. Platforms (longest video): ${VIDEO_PLATFORMS}. A platform the video is too long or too short for is skipped: trim with edit_video first. ${VIDEO_PRESETS_AND_FITS} For cuts, speed, audio or subtitles use edit_video.`,
|
||||
inputSchema: mediaParameters(ExportVideoInputSchema),
|
||||
},
|
||||
{
|
||||
|
||||
@ -335,6 +335,25 @@ describePostgres("media job runtime (PostgreSQL, fake operations)", () => {
|
||||
expect(existsSync(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps one skipped entry per platform when a job reports it again", async () => {
|
||||
const tooLong = { code: "too_long", maxMs: 600_000, actualMs: 700_000 } as const;
|
||||
const tooShort = { code: "too_short", minMs: 3_000, actualMs: 1_000 } as const;
|
||||
const ctx = await setup({
|
||||
run: async (context) => {
|
||||
await context.addSkipped({ platform: "tiktok", issues: [tooLong] });
|
||||
await context.addSkipped({ platform: "tiktok", issues: [tooShort] });
|
||||
await context.addSkipped({ platform: "youtube", issues: [tooShort] });
|
||||
return { summary: "Skipped." };
|
||||
},
|
||||
});
|
||||
const { jobId } = await ctx.start({ prompt: "fox" });
|
||||
await ctx.runtime.runJob(jobId);
|
||||
expect((await ctx.card(jobId)).skipped).toEqual([
|
||||
{ platform: "tiktok", issues: [tooShort] },
|
||||
{ platform: "youtube", issues: [tooShort] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses outputs outside the temp dir and sources of another user", async () => {
|
||||
const other = await setup();
|
||||
const foreign = await other.sourceArtifact();
|
||||
|
||||
@ -32,12 +32,14 @@ import type {
|
||||
MediaJobStatus,
|
||||
MediaOperation,
|
||||
MediaOutput,
|
||||
MediaSkippedTarget,
|
||||
MessageBlock,
|
||||
} from "@rakazo/contracts";
|
||||
import {
|
||||
attachmentKindOf,
|
||||
MEDIA_JOB_ACTIVE_STATUSES,
|
||||
MediaOperationSchema,
|
||||
MediaSkippedTargetSchema,
|
||||
} from "@rakazo/contracts";
|
||||
import { attachmentExtensionForMimeType, inferAttachmentMimeType } from "@rakazo/core";
|
||||
import type { MediaJob, PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||
@ -160,7 +162,17 @@ function issuesText(error: z.ZodError): string {
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function cardBlock(job: MediaJob, outputs: MediaOutput[]): MediaJobBlock {
|
||||
/** What a write adds to the card: stored outputs and platforms the job did not render. */
|
||||
interface CardAdditions {
|
||||
outputs?: MediaOutput[];
|
||||
skipped?: MediaSkippedTarget[];
|
||||
}
|
||||
|
||||
function cardBlock(
|
||||
job: MediaJob,
|
||||
outputs: MediaOutput[],
|
||||
skipped: MediaSkippedTarget[] = [],
|
||||
): MediaJobBlock {
|
||||
return {
|
||||
kind: "media",
|
||||
jobId: job.id,
|
||||
@ -170,6 +182,7 @@ function cardBlock(job: MediaJob, outputs: MediaOutput[]): MediaJobBlock {
|
||||
...(job.progress === null ? {} : { progress: job.progress }),
|
||||
...(job.error === null ? {} : { error: job.error }),
|
||||
outputs,
|
||||
...(skipped.length ? { skipped } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -346,8 +359,9 @@ export function createMediaRuntime(
|
||||
const writeCard = async (
|
||||
tx: Prisma.TransactionClient,
|
||||
job: MediaJob,
|
||||
appended: MediaOutput[] = [],
|
||||
added: CardAdditions = {},
|
||||
): Promise<{ exists: boolean; seq?: number; outputs: MediaOutput[] }> => {
|
||||
const appended = added.outputs ?? [];
|
||||
if (!job.messageId) return { exists: false, outputs: appended };
|
||||
const message = await tx.message.findFirst({
|
||||
where: { id: job.messageId, threadId: job.threadId },
|
||||
@ -356,7 +370,16 @@ export function createMediaRuntime(
|
||||
if (!message) return { exists: false, outputs: appended };
|
||||
const previous = mediaBlockOf(message.blocks, job.id);
|
||||
const outputs = [...(previous?.outputs ?? []), ...appended];
|
||||
const next = cardBlock(job, outputs);
|
||||
// A recovered job runs again, so the newest reason per platform replaces an earlier one.
|
||||
const skipped = [
|
||||
...new Map(
|
||||
[...(previous?.skipped ?? []), ...(added.skipped ?? [])].map((entry) => [
|
||||
entry.platform,
|
||||
entry,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const next = cardBlock(job, outputs, skipped);
|
||||
const current = Array.isArray(message.blocks) ? (message.blocks as MessageBlock[]) : [];
|
||||
const blocks = previous
|
||||
? current.map((block) => (block === previous ? next : block))
|
||||
@ -386,7 +409,7 @@ export function createMediaRuntime(
|
||||
jobId: string,
|
||||
token: string | null,
|
||||
data: Prisma.MediaJobUpdateManyMutationInput,
|
||||
appended: MediaOutput[] = [],
|
||||
added: CardAdditions = {},
|
||||
) => {
|
||||
const committed = await withTransactionRetry(() =>
|
||||
deps.prisma.$transaction(async (tx) => {
|
||||
@ -408,7 +431,7 @@ export function createMediaRuntime(
|
||||
const updated = await tx.mediaJob.updateMany({ where: fence, data });
|
||||
if (!updated.count) return null;
|
||||
const job = await tx.mediaJob.findUniqueOrThrow({ where: { id: jobId } });
|
||||
const card = await writeCard(tx, job, appended);
|
||||
const card = await writeCard(tx, job, added);
|
||||
return { job, card };
|
||||
}),
|
||||
);
|
||||
@ -801,10 +824,15 @@ export function createMediaRuntime(
|
||||
...(posterArtifactId ? { posterArtifactId } : {}),
|
||||
...(parent ? { parentArtifactId: parent.id } : {}),
|
||||
...(file.preset ? { preset: file.preset } : {}),
|
||||
...(file.platform ? { platform: file.platform } : {}),
|
||||
...(file.check ? { check: file.check } : {}),
|
||||
};
|
||||
const written = await updateJob(jobId, token, { outputArtifactIds: { push: stored } }, [
|
||||
output,
|
||||
]);
|
||||
const written = await updateJob(
|
||||
jobId,
|
||||
token,
|
||||
{ outputArtifactIds: { push: stored } },
|
||||
{ outputs: [output] },
|
||||
);
|
||||
if (!written) {
|
||||
abort("lease");
|
||||
throw new Error("Media job lost its lease");
|
||||
@ -816,6 +844,17 @@ export function createMediaRuntime(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
addSkipped: async (target) => {
|
||||
signal.throwIfAborted();
|
||||
const written = await updateJob(
|
||||
jobId,
|
||||
token,
|
||||
{ updatedAt: new Date() },
|
||||
{ skipped: [MediaSkippedTargetSchema.parse(target)] },
|
||||
);
|
||||
if (!written) abort("lease");
|
||||
else if (!written.card.exists) abort("abandoned");
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@ -10,7 +10,9 @@ import {
|
||||
FfmpegError,
|
||||
ffmpegInputArgs,
|
||||
ffmpegSeconds,
|
||||
measureLoudness,
|
||||
packetDurationMs,
|
||||
parseLoudnormOutput,
|
||||
parseProbeOutput,
|
||||
probeMedia,
|
||||
renderPoster,
|
||||
@ -18,6 +20,7 @@ import {
|
||||
} from "./ffmpeg.js";
|
||||
import type { TestClips } from "./media-test-support.js";
|
||||
import { createTestClips } from "./media-test-support.js";
|
||||
import { planLoudnessFix } from "./video-edit-plan.js";
|
||||
|
||||
const ffmpeg = await detectFfmpeg();
|
||||
|
||||
@ -73,6 +76,18 @@ describe("probe parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the stream's average bitrate when the container records it", () => {
|
||||
const probe = parseProbeOutput(
|
||||
JSON.stringify({
|
||||
format: { duration: "3" },
|
||||
streams: [
|
||||
stream({ width: 1080, height: 1920, avg_frame_rate: "30/1", bit_rate: "522994" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(probe.video?.bitRateKbps).toBe(523);
|
||||
});
|
||||
|
||||
it("reads legacy rotate tags, skips cover art and falls back to stream durations", () => {
|
||||
const probe = parseProbeOutput(
|
||||
JSON.stringify({
|
||||
@ -108,6 +123,40 @@ describe("probe parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("loudness parsing", () => {
|
||||
it("reads loudnorm's JSON block from the end of the log", () => {
|
||||
const log = [
|
||||
"Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'file:out.mp4':",
|
||||
"[Parsed_loudnorm_0 @ 0x5555] ",
|
||||
"{",
|
||||
'\t"input_i" : "-17.42",',
|
||||
'\t"input_tp" : "-3.10",',
|
||||
'\t"input_lra" : "5.20",',
|
||||
'\t"input_thresh" : "-27.71",',
|
||||
'\t"output_i" : "-14.02",',
|
||||
'\t"normalization_type" : "dynamic",',
|
||||
'\t"target_offset" : "0.02"',
|
||||
"}",
|
||||
].join("\n");
|
||||
expect(parseLoudnormOutput(log)).toEqual({
|
||||
integratedLufs: -17.42,
|
||||
truePeakDb: -3.1,
|
||||
rangeLu: 5.2,
|
||||
thresholdLufs: -27.71,
|
||||
offsetLu: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads digital silence as minus infinity and refuses a log without numbers", () => {
|
||||
const silent = parseLoudnormOutput(
|
||||
'{"input_i":"-inf","input_tp":"-inf","input_lra":"0.00","input_thresh":"-70.00","target_offset":"inf"}',
|
||||
);
|
||||
expect(silent?.integratedLufs).toBe(Number.NEGATIVE_INFINITY);
|
||||
expect(parseLoudnormOutput("Conversion failed!")).toBeNull();
|
||||
expect(parseLoudnormOutput('{"input_i":"-14.0"}')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ffmpeg arguments", () => {
|
||||
it("opens inputs through the file protocol with the demuxer of their MIME type", () => {
|
||||
expect(
|
||||
@ -151,6 +200,28 @@ describe.skipIf(!ffmpeg)("ffmpeg integration", () => {
|
||||
expect(silent.video).toMatchObject({ width: 180, height: 320 });
|
||||
});
|
||||
|
||||
it("measures loudness and brings it to the target with a linear second pass", async () => {
|
||||
const target = { integratedLufs: -14, truePeakDb: -1 };
|
||||
const before = await measureLoudness(clips.landscape, target, { mimeType: "video/mp4" });
|
||||
// The generated tone sits well below the streaming target.
|
||||
expect(before.integratedLufs).toBeLessThan(-18);
|
||||
const fixed = join(clips.dir, "loudness-fixed.mp4");
|
||||
await runFfmpeg(
|
||||
planLoudnessFix({
|
||||
inputPath: clips.landscape,
|
||||
outputPath: fixed,
|
||||
durationMs: 3000,
|
||||
target,
|
||||
measured: before,
|
||||
}),
|
||||
);
|
||||
const after = await measureLoudness(fixed, target, { mimeType: "video/mp4" });
|
||||
expect(Math.abs(after.integratedLufs - target.integratedLufs)).toBeLessThan(1);
|
||||
const probe = await probeMedia(fixed, { mimeType: "video/mp4" });
|
||||
expect(probe.video).toMatchObject({ codec: "h264", width: 320, height: 180 });
|
||||
expect(Math.abs(probe.durationMs - 3000)).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("refuses a playlist disguised as an MP4 instead of following its URLs", async () => {
|
||||
const playlist = join(clips.dir, "playlist.mp4");
|
||||
await writeFile(
|
||||
|
||||
@ -27,6 +27,8 @@ export interface MediaProbe {
|
||||
fps: number;
|
||||
/** Pixel aspect ratio of the displayed frame; 1 for square pixels. */
|
||||
sampleAspectRatio: number;
|
||||
/** Average bitrate of the stream when the container records it. */
|
||||
bitRateKbps?: number;
|
||||
};
|
||||
audio?: { codec: string; sampleRate: number; channels: number };
|
||||
hasAudio: boolean;
|
||||
@ -212,6 +214,7 @@ interface ProbeStream {
|
||||
sample_rate?: string;
|
||||
channels?: number;
|
||||
duration?: string;
|
||||
bit_rate?: string;
|
||||
disposition?: { attached_pic?: number };
|
||||
tags?: { rotate?: string };
|
||||
side_data_list?: { rotation?: number }[];
|
||||
@ -265,6 +268,7 @@ function probeVideo(stream: ProbeStream, width: number, height: number) {
|
||||
const sideways = rotation === 90 || rotation === 270;
|
||||
const sar = ratio(stream.sample_aspect_ratio, ":") || 1;
|
||||
const fps = ratio(stream.avg_frame_rate, "/") || ratio(stream.r_frame_rate, "/");
|
||||
const bitRate = positive(Number(stream.bit_rate));
|
||||
return {
|
||||
codec: stream.codec_name ?? "",
|
||||
width: sideways ? height : width,
|
||||
@ -272,6 +276,7 @@ function probeVideo(stream: ProbeStream, width: number, height: number) {
|
||||
rotation,
|
||||
fps: fps > 0 && fps <= MAX_PLAUSIBLE_FPS ? fps : 0,
|
||||
sampleAspectRatio: sideways ? 1 / sar : sar,
|
||||
...(bitRate ? { bitRateKbps: Math.round(bitRate / 1000) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -505,6 +510,94 @@ export async function extractAudio(
|
||||
);
|
||||
}
|
||||
|
||||
/** EBU R128 values of a file's first audio track, as ffmpeg's loudnorm filter measures them. */
|
||||
export interface LoudnessMeasurement {
|
||||
/** Integrated loudness in LUFS; -Infinity for digital silence. */
|
||||
integratedLufs: number;
|
||||
truePeakDb: number;
|
||||
rangeLu: number;
|
||||
thresholdLufs: number;
|
||||
/** Gain loudnorm's second pass adds after limiting, for the targets it measured against. */
|
||||
offsetLu: number;
|
||||
}
|
||||
|
||||
const LOUDNESS_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** loudnorm's filter string: the target, and the first pass's numbers for a linear second pass. */
|
||||
export function loudnormFilter(
|
||||
target: { integratedLufs: number; truePeakDb: number },
|
||||
measured?: LoudnessMeasurement,
|
||||
): string {
|
||||
const options = [
|
||||
`I=${target.integratedLufs}`,
|
||||
`TP=${target.truePeakDb}`,
|
||||
// A wide loudness range keeps loudnorm linear (no pumping) for most speech and music.
|
||||
"LRA=11",
|
||||
...(measured
|
||||
? [
|
||||
`measured_I=${measured.integratedLufs}`,
|
||||
`measured_TP=${measured.truePeakDb}`,
|
||||
`measured_LRA=${measured.rangeLu}`,
|
||||
`measured_thresh=${measured.thresholdLufs}`,
|
||||
`offset=${measured.offsetLu}`,
|
||||
"linear=true",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return `loudnorm=${options.join(":")}`;
|
||||
}
|
||||
|
||||
/** Measures the loudness of a file with an audio track against a target. */
|
||||
export async function measureLoudness(
|
||||
input: string,
|
||||
target: { integratedLufs: number; truePeakDb: number },
|
||||
options: { signal?: AbortSignal; mimeType?: string } = {},
|
||||
): Promise<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. */
|
||||
export async function renderPoster(
|
||||
input: string,
|
||||
|
||||
@ -103,6 +103,7 @@ async function run(
|
||||
};
|
||||
return output;
|
||||
},
|
||||
addSkipped: async () => undefined,
|
||||
};
|
||||
const result = await operation(name).handler.run(context);
|
||||
return { result, outputs, progress };
|
||||
|
||||
@ -2,7 +2,7 @@ import { copyFile, mkdtemp, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { MediaJobContext, MediaJobInputFile, MediaJobOutputFile } from "@rakazo/adapter-kit";
|
||||
import type { MediaOutput, MediaOutputKind } from "@rakazo/contracts";
|
||||
import type { MediaOutput, MediaOutputKind, MediaSkippedTarget } from "@rakazo/contracts";
|
||||
import { attachmentKindOf } from "@rakazo/contracts";
|
||||
import { runFfmpeg } from "./ffmpeg.js";
|
||||
|
||||
@ -50,12 +50,13 @@ export interface TestArtifact {
|
||||
kind?: MediaOutputKind;
|
||||
}
|
||||
|
||||
/** A MediaJobContext over local files that records progress and outputs. */
|
||||
/** A MediaJobContext over local files that records progress, outputs and skipped platforms. */
|
||||
export async function fakeMediaJobContext(input: unknown, artifacts: Record<string, TestArtifact>) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "evoo-media-job-"));
|
||||
const controller = new AbortController();
|
||||
const progress: number[] = [];
|
||||
const outputs: (MediaJobOutputFile & { size: number; posterSize?: number })[] = [];
|
||||
const skipped: MediaSkippedTarget[] = [];
|
||||
const context: MediaJobContext = {
|
||||
jobId: "job-1",
|
||||
spaceId: "space-1",
|
||||
@ -99,8 +100,13 @@ export async function fakeMediaJobContext(input: unknown, artifacts: Record<stri
|
||||
...(file.posterPath ? { posterArtifactId: `poster-${index}` } : {}),
|
||||
...(file.parentArtifactId ? { parentArtifactId: file.parentArtifactId } : {}),
|
||||
...(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));
|
||||
}
|
||||
@ -150,6 +150,7 @@ async function jobContext(source: Omit<MediaJobInputFile, "path">, input: unknow
|
||||
outputs.push({ ...file, body: await readFile(file.path, "utf8") });
|
||||
return { artifactId: `out-${outputs.length}` } as MediaOutput;
|
||||
},
|
||||
addSkipped: async () => undefined,
|
||||
};
|
||||
return { context, outputs, progress };
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { EditVideoStep } from "@rakazo/contracts";
|
||||
import { MEDIA_PLATFORM_PROFILES } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MediaProbe } from "./ffmpeg.js";
|
||||
import { platformTarget } from "./platform-export.js";
|
||||
import type { PlanInput } from "./video-edit-plan.js";
|
||||
import { atempoFilters, planVideoEdit, subtitleStyle } from "./video-edit-plan.js";
|
||||
import { atempoFilters, planLoudnessFix, planVideoEdit, subtitleStyle } from "./video-edit-plan.js";
|
||||
|
||||
function probe(
|
||||
overrides: Partial<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]");
|
||||
});
|
||||
});
|
||||
|
||||
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 { MEDIA_PRESETS } from "@rakazo/contracts";
|
||||
import type { MediaProbe } from "./ffmpeg.js";
|
||||
import { ffmpegInputArgs, ffmpegSeconds } from "./ffmpeg.js";
|
||||
import type { LoudnessMeasurement, MediaProbe } from "./ffmpeg.js";
|
||||
import { ffmpegInputArgs, ffmpegSeconds, loudnormFilter } from "./ffmpeg.js";
|
||||
|
||||
/**
|
||||
* Turns an ordered list of edit steps into one ffmpeg invocation with a single filter_complex
|
||||
@ -22,9 +22,22 @@ export interface VideoEditPlanRequest {
|
||||
clips?: ReadonlyMap<string, PlanInput>;
|
||||
/** Subtitle files named by subtitles steps, by artifact id: safe names relative to ffmpeg's cwd. */
|
||||
subtitleFiles?: ReadonlyMap<string, string>;
|
||||
/** A platform's limits, applied after the steps. */
|
||||
target?: ExportTarget;
|
||||
outputPath: string;
|
||||
}
|
||||
|
||||
/** The frame, frame rate cap, bitrate cap and loudness a platform export is rendered to. */
|
||||
export interface ExportTarget {
|
||||
width: number;
|
||||
height: number;
|
||||
fit: MediaFit;
|
||||
maxFps: number;
|
||||
maxBitrateKbps: number;
|
||||
integratedLufs: number;
|
||||
truePeakDb: number;
|
||||
}
|
||||
|
||||
export interface VideoEditPlan {
|
||||
args: string[];
|
||||
kind: "video" | "audio";
|
||||
@ -46,7 +59,8 @@ export const VIDEO_ENCODE_ARGS = [
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
];
|
||||
const AUDIO_ENCODE_ARGS = ["-c:a", "aac", "-b:a", "160k"];
|
||||
export const AUDIO_BITRATE_KBPS = 160;
|
||||
const AUDIO_ENCODE_ARGS = ["-c:a", "aac", "-b:a", `${AUDIO_BITRATE_KBPS}k`];
|
||||
const AUDIO_ONLY_ENCODE_ARGS = ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"];
|
||||
/** Location and other camera metadata stay private; chapters do not survive cuts anyway. */
|
||||
const STRIP_METADATA_ARGS = ["-map_metadata", "-1", "-map_chapters", "-1"];
|
||||
@ -229,6 +243,8 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
|
||||
`poster at ${seconds(posterAt)} s is past the end of the ${seconds(timeline.seconds)} s result.`,
|
||||
);
|
||||
}
|
||||
const { target } = request;
|
||||
if (target && !audioOnly) applyTarget(graph, timeline, target);
|
||||
const durationMs = Math.round(timeline.seconds * 1000);
|
||||
const output = `file:${request.outputPath}`;
|
||||
if (audioOnly || !timeline.video) {
|
||||
@ -268,6 +284,7 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
|
||||
`[${videoOut}]`,
|
||||
...(audioOut ? ["-map", `[${audioOut}]`, ...AUDIO_ENCODE_ARGS] : []),
|
||||
...VIDEO_ENCODE_ARGS,
|
||||
...(target && !audioOnly ? bitrateCapArgs(target.maxBitrateKbps) : []),
|
||||
...STRIP_METADATA_ARGS,
|
||||
"-f",
|
||||
"mp4",
|
||||
@ -281,6 +298,64 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the result to a platform: frame rate cap, frame size and loudness. Loudness is one
|
||||
* dynamic loudnorm pass, which lands within about 1 LU of the target on most material without
|
||||
* decoding the source twice; the export measures the result and corrects it when it misses.
|
||||
*/
|
||||
function applyTarget(graph: FilterGraph, timeline: Timeline, target: ExportTarget) {
|
||||
if (timeline.video) {
|
||||
if (!timeline.fps || timeline.fps > target.maxFps + 0.01) {
|
||||
timeline.video.filters.push(`fps=${number(target.maxFps)}`);
|
||||
timeline.fps = target.maxFps;
|
||||
}
|
||||
timeline.video = fitFrame(graph, timeline.video, timeline, target, target.fit);
|
||||
}
|
||||
timeline.width = target.width;
|
||||
timeline.height = target.height;
|
||||
// loudnorm pads its last frame; trim the audio back to the video's length.
|
||||
timeline.audio?.filters.push(
|
||||
loudnormFilter(target),
|
||||
`atrim=end=${ffmpegSeconds(timeline.seconds)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** CRF quality, capped so no stretch of the video runs above the platform's bitrate. */
|
||||
function bitrateCapArgs(maxBitrateKbps: number): string[] {
|
||||
const kbps = Math.max(1, Math.round(maxBitrateKbps));
|
||||
return ["-maxrate", `${kbps}k`, "-bufsize", `${kbps * 2}k`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Second loudness pass over a rendered MP4: linear loudnorm with the measured values on the
|
||||
* audio, the video copied as it is.
|
||||
*/
|
||||
export function planLoudnessFix(request: {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
durationMs: number;
|
||||
target: { integratedLufs: number; truePeakDb: number };
|
||||
measured: LoudnessMeasurement;
|
||||
}): string[] {
|
||||
const filters = [
|
||||
loudnormFilter(request.target, request.measured),
|
||||
`atrim=end=${ffmpegSeconds(request.durationMs / 1000)}`,
|
||||
AUDIO_FORMAT,
|
||||
];
|
||||
return [
|
||||
...ffmpegInputArgs(request.inputPath, { mimeType: "video/mp4" }),
|
||||
"-filter_complex",
|
||||
`[0:a:0]${filters.join(",")}[a1]`,
|
||||
...["-map", "0:v:0", "-map", "[a1]", "-c:v", "copy"],
|
||||
...AUDIO_ENCODE_ARGS,
|
||||
...["-movflags", "+faststart"],
|
||||
...STRIP_METADATA_ARGS,
|
||||
"-f",
|
||||
"mp4",
|
||||
`file:${request.outputPath}`,
|
||||
];
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
start: number;
|
||||
end: number;
|
||||
|
||||
@ -176,6 +176,63 @@ describePostgres("video operations in the media runtime (PostgreSQL + ffmpeg)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports for a platform with its check and shows a skipped platform on the card", async () => {
|
||||
const { runtime, scope, store } = await setup();
|
||||
const source = await store(clips.landscape, "holiday.mp4", "video/mp4");
|
||||
const started = await runtime.start({
|
||||
...scope,
|
||||
operation: "export_video",
|
||||
input: { artifact_id: source, platforms: ["tiktok", "snapchat-spotlight"] },
|
||||
});
|
||||
await runtime.runJob(started.jobId);
|
||||
const job = await runtime.get(started.jobId);
|
||||
expect(job).toMatchObject({ status: "done", title: "TikTok · Snapchat Spotlight" });
|
||||
expect(job?.outputs).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "holiday-tiktok.mp4",
|
||||
platform: "tiktok",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
check: { status: "pass", issues: [] },
|
||||
}),
|
||||
]);
|
||||
const stored = await prisma.artifact.findUniqueOrThrow({
|
||||
where: { id: job!.outputs[0]!.artifactId },
|
||||
});
|
||||
expect(stored).toMatchObject({ parentArtifactId: source, width: 1080, height: 1920 });
|
||||
expect(await card(started.messageId, started.jobId)).toMatchObject({
|
||||
status: "done",
|
||||
outputs: job?.outputs,
|
||||
skipped: [
|
||||
{
|
||||
platform: "snapchat-spotlight",
|
||||
issues: [{ code: "too_short", minMs: 5000, actualMs: 3000 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails an export that fits none of its platforms and keeps the reason on the card", async () => {
|
||||
const { runtime, scope, store } = await setup();
|
||||
const source = await store(clips.landscape, "short.mp4", "video/mp4");
|
||||
const started = await runtime.start({
|
||||
...scope,
|
||||
operation: "export_video",
|
||||
input: { artifact_id: source, platforms: ["snapchat-spotlight"] },
|
||||
});
|
||||
await runtime.runJob(started.jobId);
|
||||
expect(await runtime.get(started.jobId)).toMatchObject({
|
||||
status: "failed",
|
||||
error:
|
||||
"Not exported for Snapchat Spotlight: 3 s is shorter than its 5 s minimum; use a longer video.",
|
||||
outputs: [],
|
||||
});
|
||||
expect(await card(started.messageId, started.jobId)).toMatchObject({
|
||||
status: "failed",
|
||||
skipped: [{ platform: "snapchat-spotlight", issues: [{ code: "too_short" }] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses appended clips and subtitles the user cannot read", async () => {
|
||||
const { runtime, scope, store } = await setup();
|
||||
const other = await setup();
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { MEDIA_PLATFORM_PROFILES, MediaPlatformIdSchema } from "@rakazo/contracts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { builtinAgentTools } from "../builtin-tools.js";
|
||||
import { createMediaOperations } from "../media-operations.js";
|
||||
import { MEDIA_CONTINUE_AFTER_PROPERTY } from "../media-tools.js";
|
||||
import { parametersFor } from "../pi-runtime.js";
|
||||
import { detectFfmpeg, probeMedia, runFfmpeg } from "./ffmpeg.js";
|
||||
import { detectFfmpeg, measureLoudness, probeMedia, runFfmpeg } from "./ffmpeg.js";
|
||||
import type { TestArtifact, TestClips } from "./media-test-support.js";
|
||||
import { createTestClips, fakeMediaJobContext } from "./media-test-support.js";
|
||||
import { LOUDNESS_TOLERANCE_LU } from "./platform-export.js";
|
||||
import { cleanFileName, createVideoOperations, subtitleFormat } from "./video-operations.js";
|
||||
|
||||
const ffmpeg = await detectFfmpeg();
|
||||
@ -48,13 +50,22 @@ describe("video operation registrations", () => {
|
||||
expect(exported.artifactIds?.(exportInput)).toEqual(["src"]);
|
||||
});
|
||||
|
||||
it("titles exports by aspect ratio unless the bot gave a title", () => {
|
||||
it("titles exports by platform and aspect ratio unless the bot gave a title", () => {
|
||||
const exported = operation("export_video");
|
||||
expect(
|
||||
exported.title(
|
||||
exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical", "square"] }),
|
||||
),
|
||||
).toBe("9:16 · 1:1");
|
||||
expect(
|
||||
exported.title(
|
||||
exported.inputSchema.parse({
|
||||
artifact_id: "a",
|
||||
platforms: ["tiktok", "youtube-shorts"],
|
||||
presets: ["square"],
|
||||
}),
|
||||
),
|
||||
).toBe("TikTok · YouTube Shorts · 1:1");
|
||||
expect(
|
||||
exported.title(
|
||||
exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical"], title: "Reel" }),
|
||||
@ -70,6 +81,18 @@ describe("video operation registrations", () => {
|
||||
expect(() => parametersFor(tool!)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("tells the model every platform id with its longest video", () => {
|
||||
const tool = builtinAgentTools.find((entry) => entry.name === "export_video")!;
|
||||
const properties = tool.inputSchema.properties as Record<
|
||||
string,
|
||||
{ items?: { enum?: string[] } }
|
||||
>;
|
||||
expect(properties.platforms?.items?.enum).toEqual(MediaPlatformIdSchema.options);
|
||||
for (const profile of Object.values(MEDIA_PLATFORM_PROFILES)) {
|
||||
expect(tool.description).toContain(`${profile.id} ${profile.duration.hardMaxMs / 1000} s`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtitle and file name helpers", () => {
|
||||
@ -251,6 +274,74 @@ describe.skipIf(!ffmpeg)("video operations with ffmpeg", () => {
|
||||
expect(progress.at(-1)).toBe(1);
|
||||
});
|
||||
|
||||
it("exports for a platform at its frame, frame rate and loudness, and skips one it is too short for", async () => {
|
||||
const fast = join(clips.dir, "fast.mp4");
|
||||
await runFfmpeg([
|
||||
...["-f", "lavfi", "-i", "testsrc=size=320x180:rate=60:duration=4"],
|
||||
...["-f", "lavfi", "-i", "sine=frequency=330:sample_rate=48000:duration=4"],
|
||||
...["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"],
|
||||
...["-c:a", "aac", "-shortest", `file:${fast}`],
|
||||
]);
|
||||
const registration = operation("export_video");
|
||||
const job = await fakeMediaJobContext(
|
||||
registration.inputSchema.parse({
|
||||
artifact_id: "fast",
|
||||
platforms: ["tiktok", "snapchat-spotlight"],
|
||||
}),
|
||||
{ fast: { path: fast, name: "Fast clip.mp4", mimeType: "video/mp4" } },
|
||||
);
|
||||
try {
|
||||
const result = await registration.handler.run(job.context);
|
||||
expect(job.skipped).toEqual([
|
||||
{
|
||||
platform: "snapchat-spotlight",
|
||||
issues: [{ code: "too_short", minMs: 5000, actualMs: expect.any(Number) }],
|
||||
},
|
||||
]);
|
||||
expect(job.outputs).toHaveLength(1);
|
||||
const [output] = job.outputs;
|
||||
expect(output).toMatchObject({
|
||||
name: "Fast clip-tiktok.mp4",
|
||||
platform: "tiktok",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
parentArtifactId: "fast",
|
||||
check: { status: "pass", issues: [] },
|
||||
});
|
||||
expect(output!.posterSize).toBeGreaterThan(0);
|
||||
const probe = await probeMedia(output!.path, { mimeType: "video/mp4" });
|
||||
expect(probe.video).toMatchObject({ codec: "h264", width: 1080, height: 1920, fps: 30 });
|
||||
expect(Math.abs(probe.durationMs - 4000)).toBeLessThan(100);
|
||||
const loudness = await measureLoudness(
|
||||
output!.path,
|
||||
{ integratedLufs: -14, truePeakDb: -1 },
|
||||
{ mimeType: "video/mp4" },
|
||||
);
|
||||
expect(Math.abs(loudness.integratedLufs + 14)).toBeLessThanOrEqual(LOUDNESS_TOLERANCE_LU);
|
||||
expect(result.summary).toContain("Fast clip-tiktok.mp4 for TikTok (1080×1920, 4 s)");
|
||||
expect(result.summary).toContain(
|
||||
"Not exported for Snapchat Spotlight: 4 s is shorter than its 5 s minimum; use a longer video.",
|
||||
);
|
||||
expect(job.progress.at(-1)).toBe(1);
|
||||
} finally {
|
||||
await rm(job.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails without rendering when the video fits none of the platforms", async () => {
|
||||
const registration = operation("export_video");
|
||||
const job = await fakeMediaJobContext(
|
||||
registration.inputSchema.parse({ artifact_id: "land", platforms: ["snapchat-spotlight"] }),
|
||||
artifacts,
|
||||
);
|
||||
await expect(registration.handler.run(job.context)).rejects.toThrow(
|
||||
"Not exported for Snapchat Spotlight: 3 s is shorter than its 5 s minimum; use a longer video.",
|
||||
);
|
||||
expect(job.skipped.map((target) => target.platform)).toEqual(["snapchat-spotlight"]);
|
||||
expect(job.outputs).toEqual([]);
|
||||
await rm(job.tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fails clearly on ranges past the end, non-subtitle files and non-videos", async () => {
|
||||
await expect(
|
||||
run("edit_video", {
|
||||
|
||||
@ -1,24 +1,49 @@
|
||||
import { copyFile, mkdtemp, open, stat } from "node:fs/promises";
|
||||
import { copyFile, mkdtemp, open, rename, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { MediaJobContext, MediaJobInputFile, MediaOperationResult } from "@rakazo/adapter-kit";
|
||||
import type { EditVideoInput, ExportVideoInput, MediaOutput } from "@rakazo/contracts";
|
||||
import type {
|
||||
MediaJobContext,
|
||||
MediaJobInputFile,
|
||||
MediaJobOutputFile,
|
||||
MediaOperationResult,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import type {
|
||||
EditVideoInput,
|
||||
ExportVideoInput,
|
||||
MediaFit,
|
||||
MediaOutput,
|
||||
MediaPlatformId,
|
||||
MediaSkippedTarget,
|
||||
PlatformProfile,
|
||||
} from "@rakazo/contracts";
|
||||
import {
|
||||
ARTIFACT_MAX_BYTES_BY_KIND,
|
||||
EditVideoInputSchema,
|
||||
ExportVideoInputSchema,
|
||||
MEDIA_PLATFORM_PROFILES,
|
||||
MEDIA_PRESETS,
|
||||
} from "@rakazo/contracts";
|
||||
import type { MediaOperationRegistration } from "../media-runtime.js";
|
||||
import type { FfmpegInfo } from "./ffmpeg.js";
|
||||
import type { FfmpegInfo, MediaProbe } from "./ffmpeg.js";
|
||||
import {
|
||||
detectFfmpeg,
|
||||
measureLoudness,
|
||||
outputTooLargeMessage,
|
||||
probeMedia,
|
||||
renderPoster,
|
||||
runFfmpeg,
|
||||
} from "./ffmpeg.js";
|
||||
import type { ExportMeasurement } from "./platform-export.js";
|
||||
import {
|
||||
checkExport,
|
||||
describeIssue,
|
||||
describeSkipped,
|
||||
durationIssues,
|
||||
needsLoudnessFix,
|
||||
platformTarget,
|
||||
sizeFixKbps,
|
||||
} from "./platform-export.js";
|
||||
import type { PlanInput, VideoEditPlan } from "./video-edit-plan.js";
|
||||
import { planVideoEdit } from "./video-edit-plan.js";
|
||||
import { planLoudnessFix, planVideoEdit } from "./video-edit-plan.js";
|
||||
|
||||
export interface VideoOperationsDeps {
|
||||
/** Finds ffmpeg; defaults to PATH lookup. */
|
||||
@ -66,7 +91,11 @@ export function createVideoOperations(
|
||||
operation: "export_video",
|
||||
inputSchema: ExportVideoInputSchema,
|
||||
title: (input) =>
|
||||
input.title ?? input.presets.map((preset) => MEDIA_PRESETS[preset].aspect).join(" · "),
|
||||
input.title ??
|
||||
[
|
||||
...(input.platforms ?? []).map((platform) => MEDIA_PLATFORM_PROFILES[platform].name),
|
||||
...(input.presets ?? []).map((preset) => MEDIA_PRESETS[preset].aspect),
|
||||
].join(" · "),
|
||||
available,
|
||||
artifactIds: (input) => [input.artifact_id],
|
||||
handler: {
|
||||
@ -142,39 +171,160 @@ async function runEditVideo(context: MediaJobContext): Promise<MediaOperationRes
|
||||
|
||||
async function runExportVideo(context: MediaJobContext): Promise<MediaOperationResult> {
|
||||
const input = ExportVideoInputSchema.parse(context.input);
|
||||
const presets = [...new Set(input.presets)];
|
||||
const platforms = [...new Set(input.platforms ?? [])];
|
||||
const presets = [...new Set(input.presets ?? [])];
|
||||
const workDir = await mkdtemp(join(context.tempDir, "export-"));
|
||||
const progress = createProgressReporter(context);
|
||||
const source = await loadVideo(context, input.artifact_id);
|
||||
const base = cleanFileName(withoutExtension(source.name), "video");
|
||||
// A platform the video is too long or too short for is not rendered; the bot trims first.
|
||||
const skipped: MediaSkippedTarget[] = [];
|
||||
const rendered: MediaPlatformId[] = [];
|
||||
for (const platform of platforms) {
|
||||
const issues = durationIssues(MEDIA_PLATFORM_PROFILES[platform], source.probe.durationMs);
|
||||
if (issues.length) skipped.push({ platform, issues });
|
||||
else rendered.push(platform);
|
||||
}
|
||||
for (const target of skipped) await context.addSkipped(target);
|
||||
const notExported = skipped.map(({ platform, issues }) =>
|
||||
describeSkipped(MEDIA_PLATFORM_PROFILES[platform], issues),
|
||||
);
|
||||
const count = rendered.length + presets.length;
|
||||
if (count === 0) throw new Error(notExported.join(" "));
|
||||
const share = 1 / count;
|
||||
const report = (index: number) => progress.range(index * share, (index + 1) * share);
|
||||
const outputs: MediaOutput[] = [];
|
||||
for (const [index, preset] of presets.entries()) {
|
||||
for (const platform of rendered) {
|
||||
outputs.push(
|
||||
await exportForPlatform(context, source, MEDIA_PLATFORM_PROFILES[platform], {
|
||||
fit: input.fit,
|
||||
name: `${base}-${platform}.mp4`,
|
||||
workDir,
|
||||
report: report(outputs.length),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const preset of presets) {
|
||||
const outputPath = join(workDir, `${preset}.mp4`);
|
||||
const plan = planVideoEdit({
|
||||
source,
|
||||
steps: [{ op: "reframe", preset, fit: input.fit }],
|
||||
outputPath,
|
||||
});
|
||||
const share = 1 / presets.length;
|
||||
outputs.push(
|
||||
await encode(
|
||||
context,
|
||||
plan,
|
||||
outputPath,
|
||||
workDir,
|
||||
progress.range(index * share, (index + 1) * share),
|
||||
{
|
||||
await encode(context, plan, outputPath, workDir, report(outputs.length), {
|
||||
name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`,
|
||||
parentArtifactId: source.artifactId,
|
||||
preset,
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await progress.done();
|
||||
return { summary: `Exported ${source.name} as ${outputs.map(describe).join(", ")}.` };
|
||||
const checks = outputs.flatMap((output) =>
|
||||
output.platform && output.check && output.check.status !== "pass"
|
||||
? [
|
||||
`${MEDIA_PLATFORM_PROFILES[output.platform].name} check ${output.check.status}: ${output.check.issues.map(describeIssue).join("; ")}.`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return {
|
||||
summary: [
|
||||
`Exported ${source.name} as ${outputs.map(describe).join(", ")}.`,
|
||||
...checks,
|
||||
...notExported,
|
||||
].join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a video to a platform's frame, frame rate, bitrate and loudness, measures the file
|
||||
* and stores it with the check. A file over the size limit is encoded once more at a lower
|
||||
* bitrate, and audio off the loudness target gets one more, linear loudness pass.
|
||||
*/
|
||||
async function exportForPlatform(
|
||||
context: MediaJobContext,
|
||||
source: PlanInput & MediaJobInputFile,
|
||||
profile: PlatformProfile,
|
||||
options: { fit: MediaFit; name: string; workDir: string; report: (fraction: number) => void },
|
||||
): Promise<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. */
|
||||
async function encode(
|
||||
context: MediaJobContext,
|
||||
@ -182,8 +332,24 @@ async function encode(
|
||||
outputPath: string,
|
||||
workDir: string,
|
||||
report: (fraction: number) => void,
|
||||
file: Pick<Parameters<MediaJobContext["addOutput"]>[0], "name" | "parentArtifactId" | "preset">,
|
||||
file: OutputDetails,
|
||||
): 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];
|
||||
await runFfmpeg(plan.args, {
|
||||
signal: context.signal,
|
||||
@ -194,10 +360,17 @@ async function encode(
|
||||
outputs: [outputPath],
|
||||
});
|
||||
if ((await stat(outputPath)).size > maxBytes) throw new Error(outputTooLargeMessage(maxBytes));
|
||||
const probe = await probeMedia(outputPath, {
|
||||
signal: context.signal,
|
||||
mimeType: plan.kind === "audio" ? "audio/mp4" : "video/mp4",
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders a video's poster and stores the file as an output. */
|
||||
async function store(
|
||||
context: MediaJobContext,
|
||||
plan: VideoEditPlan,
|
||||
outputPath: string,
|
||||
probe: MediaProbe,
|
||||
report: (fraction: number) => void,
|
||||
file: OutputDetails,
|
||||
): Promise<MediaOutput> {
|
||||
if (plan.kind === "audio") {
|
||||
const output = await context.addOutput({
|
||||
...file,
|
||||
@ -294,13 +467,16 @@ function withoutExtension(name: string): string {
|
||||
return name.replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
||||
}
|
||||
|
||||
/** "name (1080×1920, 12.5 s)" for the bot's summary; the runtime adds the artifact ids. */
|
||||
/** "name for TikTok (1080×1920, 12.5 s)" for the bot's summary; the runtime adds the ids. */
|
||||
function describe(output: MediaOutput): string {
|
||||
const name = output.platform
|
||||
? `${output.name} for ${MEDIA_PLATFORM_PROFILES[output.platform].name}`
|
||||
: output.name;
|
||||
const details = [
|
||||
output.width && output.height ? `${output.width}×${output.height}` : "",
|
||||
output.durationMs !== undefined ? `${Number((output.durationMs / 1000).toFixed(1))} s` : "",
|
||||
].filter(Boolean);
|
||||
return details.length ? `${output.name} (${details.join(", ")})` : output.name;
|
||||
return details.length ? `${name} (${details.join(", ")})` : name;
|
||||
}
|
||||
|
||||
/** Maps ffmpeg progress onto the card: monotonic, written in order, never failing the job. */
|
||||
|
||||
@ -18,6 +18,8 @@ export * from "./media.js";
|
||||
export * from "./media-generation.js";
|
||||
export * from "./media-jobs.js";
|
||||
export * from "./media-library.js";
|
||||
export * from "./media-platforms.js";
|
||||
export * from "./media-presets.js";
|
||||
export * from "./media-settings.js";
|
||||
export * from "./media-transcription.js";
|
||||
export * from "./media-video.js";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as z from "zod";
|
||||
import { Id } from "./ids.js";
|
||||
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
|
||||
import { MediaFitSchema, MediaPresetIdSchema } from "./media-presets.js";
|
||||
import { MediaTitleSchema } from "./media-video.js";
|
||||
|
||||
export const MEDIA_IMAGE_MAX_COUNT = 4;
|
||||
|
||||
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", () => {
|
||||
it("needs at least one preset and defaults to cropping", () => {
|
||||
it("needs at least one preset or platform and defaults to cropping", () => {
|
||||
expect(ExportVideoInputSchema.parse({ artifact_id: "a", presets: ["square"] })).toEqual({
|
||||
artifact_id: "a",
|
||||
presets: ["square"],
|
||||
fit: "crop",
|
||||
});
|
||||
expect(ExportVideoInputSchema.parse({ artifact_id: "a", platforms: ["tiktok"] })).toEqual({
|
||||
artifact_id: "a",
|
||||
platforms: ["tiktok"],
|
||||
fit: "crop",
|
||||
});
|
||||
expect(ExportVideoInputSchema.safeParse({ artifact_id: "a", presets: [] }).success).toBe(false);
|
||||
expect(ExportVideoInputSchema.safeParse({ artifact_id: "a" }).success).toBe(false);
|
||||
expect(ExportVideoInputSchema.safeParse({ artifact_id: "a", presets: ["imax"] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
ExportVideoInputSchema.safeParse({ artifact_id: "a", platforms: ["myspace"] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("takes at most six targets across platforms and presets", () => {
|
||||
const platforms = ["tiktok", "youtube-shorts", "instagram-reels", "linkedin"];
|
||||
expect(
|
||||
ExportVideoInputSchema.safeParse({
|
||||
artifact_id: "a",
|
||||
platforms,
|
||||
presets: ["square", "portrait"],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
const tooMany = ExportVideoInputSchema.safeParse({
|
||||
artifact_id: "a",
|
||||
platforms,
|
||||
presets: ["square", "portrait", "classic"],
|
||||
});
|
||||
expect(tooMany.success).toBe(false);
|
||||
expect(tooMany.error?.issues[0]?.path).toEqual(["platforms"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import { Id } from "./ids.js";
|
||||
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
|
||||
import { MediaPlatformIdSchema } from "./media-platforms.js";
|
||||
import { MediaFitSchema, MediaPresetIdSchema } from "./media-presets.js";
|
||||
|
||||
/**
|
||||
* Inputs of the `edit_video` and `export_video` media operations, which are also the arguments
|
||||
@ -71,10 +72,25 @@ export const EditVideoInputSchema = z
|
||||
});
|
||||
export type EditVideoInput = z.infer<typeof EditVideoInputSchema>;
|
||||
|
||||
export const ExportVideoInputSchema = z.object({
|
||||
export const MAX_EXPORT_TARGETS = 6;
|
||||
|
||||
/** One output per platform and per preset: at least one, at most MAX_EXPORT_TARGETS in all. */
|
||||
export const ExportVideoInputSchema = z
|
||||
.object({
|
||||
artifact_id: Id,
|
||||
presets: z.array(MediaPresetIdSchema).min(1).max(6),
|
||||
platforms: z.array(MediaPlatformIdSchema).max(MAX_EXPORT_TARGETS).optional(),
|
||||
presets: z.array(MediaPresetIdSchema).max(MAX_EXPORT_TARGETS).optional(),
|
||||
fit: MediaFitSchema.default("crop"),
|
||||
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>;
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import * as z from "zod";
|
||||
import { Id } from "./ids.js";
|
||||
import {
|
||||
MediaCheckSchema,
|
||||
MediaPlatformIdSchema,
|
||||
MediaSkippedTargetSchema,
|
||||
} from "./media-platforms.js";
|
||||
import { MediaPresetIdSchema } from "./media-presets.js";
|
||||
|
||||
/** What a media job does. Generation runs on the configured image/video backend, edits run locally. */
|
||||
export const MediaOperationSchema = z.enum([
|
||||
@ -32,33 +38,6 @@ export const MEDIA_JOB_ACTIVE_STATUSES = [
|
||||
export const MediaOutputKindSchema = z.enum(["image", "video", "audio", "file"]);
|
||||
export type MediaOutputKind = z.infer<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({
|
||||
artifactId: Id,
|
||||
kind: MediaOutputKindSchema,
|
||||
@ -72,6 +51,9 @@ export const MediaOutputSchema = z.object({
|
||||
/** The artifact this output was made from, for version history. */
|
||||
parentArtifactId: Id.optional(),
|
||||
preset: MediaPresetIdSchema.optional(),
|
||||
/** The platform it was exported for, and how it measured against that platform's limits. */
|
||||
platform: MediaPlatformIdSchema.optional(),
|
||||
check: MediaCheckSchema.optional(),
|
||||
});
|
||||
export type MediaOutput = z.infer<typeof MediaOutputSchema>;
|
||||
|
||||
@ -86,6 +68,8 @@ export const MediaJobBlockSchema = z.object({
|
||||
progress: z.number().min(0).max(1).optional(),
|
||||
error: z.string().optional(),
|
||||
outputs: z.array(MediaOutputSchema),
|
||||
/** Platforms an export did not render, each with the reason. */
|
||||
skipped: z.array(MediaSkippedTargetSchema).optional(),
|
||||
});
|
||||
export type MediaJobBlock = z.infer<typeof MediaJobBlockSchema>;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user