Export videos for named platforms and check each file against its limits
Exports only knew aspect ratios, so a cut could still be rejected or degraded on upload: too long for the platform, over its size limit, or far off its loudness. export_video now takes platforms (TikTok, Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels, LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the presets. Each platform is rendered to its frame with the export's fit, its frame rate cap, a bitrate cap that also keeps the file under the size limit, and its loudness target in one loudnorm pass. Every render is probed and measured afterwards. A file over the size limit is encoded once more at a bitrate scaled from what it averaged, and audio more than 2 LU off the target gets one linear loudness pass. The result is stored on the output as a pass, warn or fail check with language-neutral issue codes. A platform the video is too long or too short for is not rendered: the card lists it with the reason, the other targets still render, and the bot is told to trim first. The limits come from the earlier profile research, with its sources, review date and unverified flag; content, disclosure and caption rules are left out because they need inference or a review flow. Preset definitions move to their own module so the platform schemas can use their aspect ratios without an import cycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
404a1d1782
commit
fa3bc601b7
@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@ -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,8 @@ 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);
|
||||
const skipped = [...(previous?.skipped ?? []), ...(added.skipped ?? [])];
|
||||
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 +401,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 +423,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 +816,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 +836,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;
|
||||
@ -210,6 +212,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 }[];
|
||||
@ -256,6 +259,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,
|
||||
@ -263,6 +267,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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -496,6 +501,94 @@ export async function extractAudio(
|
||||
);
|
||||
}
|
||||
|
||||
/** EBU R128 values of a file's first audio track, as ffmpeg's loudnorm filter measures them. */
|
||||
export interface LoudnessMeasurement {
|
||||
/** Integrated loudness in LUFS; -Infinity for digital silence. */
|
||||
integratedLufs: number;
|
||||
truePeakDb: number;
|
||||
rangeLu: number;
|
||||
thresholdLufs: number;
|
||||
/** Gain loudnorm's second pass adds after limiting, for the targets it measured against. */
|
||||
offsetLu: number;
|
||||
}
|
||||
|
||||
const LOUDNESS_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** loudnorm's filter string: the target, and the first pass's numbers for a linear second pass. */
|
||||
export function loudnormFilter(
|
||||
target: { integratedLufs: number; truePeakDb: number },
|
||||
measured?: LoudnessMeasurement,
|
||||
): string {
|
||||
const options = [
|
||||
`I=${target.integratedLufs}`,
|
||||
`TP=${target.truePeakDb}`,
|
||||
// A wide loudness range keeps loudnorm linear (no pumping) for most speech and music.
|
||||
"LRA=11",
|
||||
...(measured
|
||||
? [
|
||||
`measured_I=${measured.integratedLufs}`,
|
||||
`measured_TP=${measured.truePeakDb}`,
|
||||
`measured_LRA=${measured.rangeLu}`,
|
||||
`measured_thresh=${measured.thresholdLufs}`,
|
||||
`offset=${measured.offsetLu}`,
|
||||
"linear=true",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return `loudnorm=${options.join(":")}`;
|
||||
}
|
||||
|
||||
/** Measures the loudness of a file with an audio track against a target. */
|
||||
export async function measureLoudness(
|
||||
input: string,
|
||||
target: { integratedLufs: number; truePeakDb: number },
|
||||
options: { signal?: AbortSignal; mimeType?: string } = {},
|
||||
): Promise<LoudnessMeasurement> {
|
||||
const { ffmpegPath } = await requireFfmpeg();
|
||||
const result = await capture(
|
||||
ffmpegPath,
|
||||
[
|
||||
...["-hide_banner", "-nostdin", "-nostats", "-loglevel", "info"],
|
||||
...ffmpegInputArgs(input, { mimeType: options.mimeType }),
|
||||
...["-map", "0:a:0", "-af", `${loudnormFilter(target)}:print_format=json`],
|
||||
...["-f", "null", "-"],
|
||||
],
|
||||
{ signal: options.signal, timeoutMs: LOUDNESS_TIMEOUT_MS },
|
||||
);
|
||||
if (result.code !== 0) throw ffmpegFailure("ffmpeg", result.code, result.stderr);
|
||||
const measured = parseLoudnormOutput(result.stderr);
|
||||
if (!measured) throw new Error("ffmpeg did not report the loudness.");
|
||||
return measured;
|
||||
}
|
||||
|
||||
/** Reads the JSON block loudnorm prints with print_format=json at the end of its log. */
|
||||
export function parseLoudnormOutput(log: string): LoudnessMeasurement | null {
|
||||
const start = log.lastIndexOf("{");
|
||||
const end = log.lastIndexOf("}");
|
||||
if (start < 0 || end < start) return null;
|
||||
let data: Record<string, string>;
|
||||
try {
|
||||
data = JSON.parse(log.slice(start, end + 1)) as Record<string, string>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const value = (key: string) => {
|
||||
const text = data[key]?.trim();
|
||||
// Digital silence measures as -inf, and its offset as inf.
|
||||
if (text === "-inf") return Number.NEGATIVE_INFINITY;
|
||||
if (text === "inf") return Number.POSITIVE_INFINITY;
|
||||
return text ? Number(text) : Number.NaN;
|
||||
};
|
||||
const measured = {
|
||||
integratedLufs: value("input_i"),
|
||||
truePeakDb: value("input_tp"),
|
||||
rangeLu: value("input_lra"),
|
||||
thresholdLufs: value("input_thresh"),
|
||||
offsetLu: value("target_offset"),
|
||||
};
|
||||
return Object.values(measured).some(Number.isNaN) ? null : measured;
|
||||
}
|
||||
|
||||
/** Writes one JPEG frame at atMs (default 0), falling back to the first frame past the end. */
|
||||
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),
|
||||
{
|
||||
name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`,
|
||||
parentArtifactId: source.artifactId,
|
||||
preset,
|
||||
},
|
||||
),
|
||||
await encode(context, plan, outputPath, workDir, report(outputs.length), {
|
||||
name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`,
|
||||
parentArtifactId: source.artifactId,
|
||||
preset,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await progress.done();
|
||||
return { summary: `Exported ${source.name} as ${outputs.map(describe).join(", ")}.` };
|
||||
const checks = outputs.flatMap((output) =>
|
||||
output.platform && output.check && output.check.status !== "pass"
|
||||
? [
|
||||
`${MEDIA_PLATFORM_PROFILES[output.platform].name} check ${output.check.status}: ${output.check.issues.map(describeIssue).join("; ")}.`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return {
|
||||
summary: [
|
||||
`Exported ${source.name} as ${outputs.map(describe).join(", ")}.`,
|
||||
...checks,
|
||||
...notExported,
|
||||
].join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a video to a platform's frame, frame rate, bitrate and loudness, measures the file
|
||||
* and stores it with the check. A file over the size limit is encoded once more at a lower
|
||||
* bitrate, and audio off the loudness target gets one more, linear loudness pass.
|
||||
*/
|
||||
async function exportForPlatform(
|
||||
context: MediaJobContext,
|
||||
source: PlanInput & MediaJobInputFile,
|
||||
profile: PlatformProfile,
|
||||
options: { fit: MediaFit; name: string; workDir: string; report: (fraction: number) => void },
|
||||
): Promise<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. */
|
||||
|
||||
@ -17,6 +17,8 @@ export * from "./mcp.js";
|
||||
export * from "./media.js";
|
||||
export * from "./media-generation.js";
|
||||
export * from "./media-jobs.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({
|
||||
artifact_id: Id,
|
||||
presets: z.array(MediaPresetIdSchema).min(1).max(6),
|
||||
fit: MediaFitSchema.default("crop"),
|
||||
title: MediaTitleSchema.optional(),
|
||||
});
|
||||
export const MAX_EXPORT_TARGETS = 6;
|
||||
|
||||
/** One output per platform and per preset: at least one, at most MAX_EXPORT_TARGETS in all. */
|
||||
export const ExportVideoInputSchema = z
|
||||
.object({
|
||||
artifact_id: Id,
|
||||
platforms: z.array(MediaPlatformIdSchema).max(MAX_EXPORT_TARGETS).optional(),
|
||||
presets: z.array(MediaPresetIdSchema).max(MAX_EXPORT_TARGETS).optional(),
|
||||
fit: MediaFitSchema.default("crop"),
|
||||
title: MediaTitleSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(input) => {
|
||||
const targets = (input.platforms?.length ?? 0) + (input.presets?.length ?? 0);
|
||||
return targets >= 1 && targets <= MAX_EXPORT_TARGETS;
|
||||
},
|
||||
{
|
||||
path: ["platforms"],
|
||||
message: `name 1 to ${MAX_EXPORT_TARGETS} platforms and presets in all`,
|
||||
},
|
||||
);
|
||||
export type ExportVideoInput = z.infer<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