* Export videos for named platforms and check each file against its limits Exports only knew aspect ratios, so a cut could still be rejected or degraded on upload: too long for the platform, over its size limit, or far off its loudness. export_video now takes platforms (TikTok, Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels, LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the presets. Each platform is rendered to its frame with the export's fit, its frame rate cap, a bitrate cap that also keeps the file under the size limit, and its loudness target in one loudnorm pass. Every render is probed and measured afterwards. A file over the size limit is encoded once more at a bitrate scaled from what it averaged, and audio more than 2 LU off the target gets one linear loudness pass. The result is stored on the output as a pass, warn or fail check with language-neutral issue codes. A platform the video is too long or too short for is not rendered: the card lists it with the reason, the other targets still render, and the bot is told to trim first. The limits come from the earlier profile research, with its sources, review date and unverified flag; content, disclosure and caption rules are left out because they need inference or a review flow. Preset definitions move to their own module so the platform schemas can use their aspect ratios without an import cycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * Offer platforms in the media card's export menu and show their checks The card's Export menu now lists the platforms first, by brand name and untranslated, then the aspect ratios after a separator; the platform an output was made for is left out, like its own shape. A platform export shows the platform's name under the player, or, when its check did not pass, one line for the first issue instead ("Too long for TikTok (max 10:00)"), in the warning or destructive color. Skipped platforms get the same kind of line, and they stand in for the error of a job that skipped every platform, so the card does not say the same thing twice in two languages. The E2E spec seeds a platform export with a failed check and a skipped platform, and captures the card and the open menu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(media): keep one skipped entry per platform on the card A job that recovers after a crash runs again and reports the same skipped platforms; the newest reason per platform now replaces the earlier one instead of listing it twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { rm, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { FfmpegProgress } from "./ffmpeg.js";
|
|
import {
|
|
createFfmpegProgressParser,
|
|
detectFfmpeg,
|
|
extractAudio,
|
|
FfmpegError,
|
|
ffmpegInputArgs,
|
|
ffmpegSeconds,
|
|
measureLoudness,
|
|
packetDurationMs,
|
|
parseLoudnormOutput,
|
|
parseProbeOutput,
|
|
probeMedia,
|
|
renderPoster,
|
|
runFfmpeg,
|
|
} 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();
|
|
|
|
describe("ffmpeg progress", () => {
|
|
it("reports each block, tolerates split chunks and N/A times, and marks the end", () => {
|
|
const updates: FfmpegProgress[] = [];
|
|
const feed = createFfmpegProgressParser((update) => updates.push(update));
|
|
feed("frame=0\nout_time_us=N/A\ntotal_size=48\nprogress=cont");
|
|
feed("inue\nframe=75\nout_time_us=2995");
|
|
feed("374\nout_time_ms=2995374\ntotal_size=42300\nprogress=end\n");
|
|
expect(updates).toEqual([
|
|
{ totalSize: 48, done: false },
|
|
{ outTimeUs: 2_995_374, totalSize: 42_300, done: true },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("probe parsing", () => {
|
|
const stream = (fields: Record<string, unknown>) => ({
|
|
codec_type: "video",
|
|
codec_name: "h264",
|
|
...fields,
|
|
});
|
|
|
|
it("swaps the size of rotated video and reads frame rate and audio", () => {
|
|
const probe = parseProbeOutput(
|
|
JSON.stringify({
|
|
format: { duration: "12.345", format_name: "mov,mp4,m4a,3gp,3g2,mj2" },
|
|
streams: [
|
|
stream({
|
|
width: 1920,
|
|
height: 1080,
|
|
avg_frame_rate: "30000/1001",
|
|
sample_aspect_ratio: "1:1",
|
|
side_data_list: [{ side_data_type: "Display Matrix", rotation: -90 }],
|
|
}),
|
|
{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 },
|
|
],
|
|
}),
|
|
);
|
|
expect(probe).toEqual({
|
|
durationMs: 12_345,
|
|
video: {
|
|
codec: "h264",
|
|
width: 1080,
|
|
height: 1920,
|
|
rotation: 270,
|
|
fps: 30000 / 1001,
|
|
sampleAspectRatio: 1,
|
|
},
|
|
audio: { codec: "aac", sampleRate: 48_000, channels: 2 },
|
|
hasAudio: true,
|
|
});
|
|
});
|
|
|
|
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({
|
|
format: { format_name: "mov" },
|
|
streams: [
|
|
stream({ width: 600, height: 600, disposition: { attached_pic: 1 } }),
|
|
stream({
|
|
width: 640,
|
|
height: 480,
|
|
avg_frame_rate: "0/0",
|
|
r_frame_rate: "90000/1",
|
|
sample_aspect_ratio: "4:3",
|
|
tags: { rotate: "90" },
|
|
duration: "4.5",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(probe.durationMs).toBe(4500);
|
|
expect(probe.hasAudio).toBe(false);
|
|
expect(probe.video).toMatchObject({
|
|
width: 480,
|
|
height: 640,
|
|
rotation: 90,
|
|
fps: 0,
|
|
sampleAspectRatio: 3 / 4,
|
|
});
|
|
});
|
|
|
|
it("reads the end of the last packet when the container has no duration", () => {
|
|
expect(packetDurationMs("0.000000,0.040000\n2.960000,0.040000\nN/A,N/A\n")).toBe(3000);
|
|
expect(packetDurationMs("")).toBe(0);
|
|
});
|
|
});
|
|
|
|
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(
|
|
ffmpegInputArgs("/a/b.webm", {
|
|
mimeType: "video/webm",
|
|
startSeconds: 2,
|
|
durationSeconds: 1.5,
|
|
}),
|
|
).toEqual([
|
|
...["-ss", "2", "-t", "1.5", "-protocol_whitelist", "file", "-f", "matroska"],
|
|
...["-i", "file:/a/b.webm"],
|
|
]);
|
|
expect(ffmpegInputArgs("/a/b", { mimeType: "application/octet-stream" })).toEqual([
|
|
...["-protocol_whitelist", "file", "-i", "file:/a/b"],
|
|
]);
|
|
expect(ffmpegSeconds(1e-7)).toBe("0");
|
|
expect(ffmpegSeconds(12.34567)).toBe("12.346");
|
|
});
|
|
});
|
|
|
|
// CI runners may not have ffmpeg; these run wherever it is installed.
|
|
describe.skipIf(!ffmpeg)("ffmpeg integration", () => {
|
|
let clips: TestClips;
|
|
|
|
beforeAll(async () => {
|
|
clips = await createTestClips();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (clips) await rm(clips.dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("probes duration, size, frame rate and audio", async () => {
|
|
const probe = await probeMedia(clips.landscape, { mimeType: "video/mp4" });
|
|
expect(probe.durationMs).toBeGreaterThanOrEqual(2900);
|
|
expect(probe.durationMs).toBeLessThanOrEqual(3100);
|
|
expect(probe.video).toMatchObject({ codec: "h264", width: 320, height: 180, fps: 25 });
|
|
expect(probe.hasAudio).toBe(true);
|
|
const silent = await probeMedia(clips.silentPortrait, { mimeType: "video/mp4" });
|
|
expect(silent.hasAudio).toBe(false);
|
|
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(
|
|
playlist,
|
|
`#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:1.0,\nfile:${clips.srt}\n#EXT-X-ENDLIST\n`,
|
|
);
|
|
await expect(probeMedia(playlist, { mimeType: "video/mp4" })).rejects.toThrow(FfmpegError);
|
|
});
|
|
|
|
it("reports progress up to 1 for a run", async () => {
|
|
const output = join(clips.dir, "progress.mp4");
|
|
const fractions: number[] = [];
|
|
await runFfmpeg(
|
|
[
|
|
...ffmpegInputArgs(clips.landscape, { mimeType: "video/mp4" }),
|
|
...[
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"ultrafast",
|
|
"-c:a",
|
|
"aac",
|
|
"-f",
|
|
"mp4",
|
|
`file:${output}`,
|
|
],
|
|
],
|
|
{ durationMs: 3000, onProgress: (fraction) => fractions.push(fraction) },
|
|
);
|
|
expect(fractions.at(-1)).toBe(1);
|
|
expect(fractions).toEqual([...fractions].sort((a, b) => a - b));
|
|
expect(existsSync(output)).toBe(true);
|
|
});
|
|
|
|
it("fails with the last stderr line, without directory paths, and removes outputs", async () => {
|
|
const output = join(clips.dir, "broken.mp4");
|
|
await writeFile(output, "partial");
|
|
const failure = runFfmpeg(
|
|
[
|
|
...ffmpegInputArgs(join(clips.dir, "missing.mp4"), { mimeType: "video/mp4" }),
|
|
`file:${output}`,
|
|
],
|
|
{ outputs: [output] },
|
|
);
|
|
const error = await failure.catch((caught: unknown) => caught);
|
|
expect(error).toBeInstanceOf(FfmpegError);
|
|
expect((error as Error).message).toMatch(/^ffmpeg failed: .*missing\.mp4.*No such file/);
|
|
expect((error as Error).message).not.toContain(clips.dir);
|
|
expect(existsSync(output)).toBe(false);
|
|
});
|
|
|
|
it("kills ffmpeg on abort and removes the partial output", async () => {
|
|
const output = join(clips.dir, "aborted.mp4");
|
|
const controller = new AbortController();
|
|
const run = runFfmpeg(
|
|
[
|
|
...["-re", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=25"],
|
|
...["-t", "60", "-c:v", "libx264", "-preset", "ultrafast", "-f", "mp4", `file:${output}`],
|
|
],
|
|
{
|
|
signal: controller.signal,
|
|
durationMs: 60_000,
|
|
onProgress: () => controller.abort(new Error("cancelled")),
|
|
outputs: [output],
|
|
},
|
|
);
|
|
await expect(run).rejects.toThrow("cancelled");
|
|
expect(existsSync(output)).toBe(false);
|
|
});
|
|
|
|
it("stops when the output outgrows its cap", async () => {
|
|
const output = join(clips.dir, "too-large.mp4");
|
|
const run = runFfmpeg(
|
|
[
|
|
...["-f", "lavfi", "-i", "testsrc=size=640x360:rate=25:duration=20"],
|
|
...["-c:v", "libx264", "-preset", "ultrafast", "-crf", "0", "-f", "mp4", `file:${output}`],
|
|
],
|
|
{ maxOutputBytes: 64 * 1024, outputs: [output] },
|
|
);
|
|
await expect(run).rejects.toThrow("The result would exceed 0 MB.");
|
|
expect(existsSync(output)).toBe(false);
|
|
});
|
|
|
|
it("extracts 16 kHz mono WAV for transcription, optionally a range", async () => {
|
|
const output = join(clips.dir, "speech.wav");
|
|
await extractAudio(clips.landscape, output, { mimeType: "video/mp4", startMs: 1000 });
|
|
const probe = await probeMedia(output, { mimeType: "audio/wav" });
|
|
expect(probe.audio).toEqual({ codec: "pcm_s16le", sampleRate: 16_000, channels: 1 });
|
|
expect(probe.video).toBeUndefined();
|
|
expect(Math.abs(probe.durationMs - 2000)).toBeLessThan(100);
|
|
await expect(
|
|
extractAudio(clips.silentPortrait, join(clips.dir, "none.wav"), { mimeType: "video/mp4" }),
|
|
).rejects.toThrow("The file has no audio track.");
|
|
});
|
|
|
|
it("renders a poster frame, falling back to the first frame past the end", async () => {
|
|
const poster = join(clips.dir, "poster.jpg");
|
|
await renderPoster(clips.landscape, poster, { mimeType: "video/mp4", atMs: 60_000 });
|
|
const probe = await probeMedia(poster);
|
|
expect(probe.video).toMatchObject({ codec: "mjpeg", width: 320, height: 180 });
|
|
});
|
|
});
|