* 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 * Add remove_silence step to edit_video Talking-head clips are mostly cut by hand to drop pauses, and the bot had no way to find them: it could only keep segments it guessed. The new remove_silence step finds every pause quieter than threshold_db (default -35 dB) and at least min_silence long (default 0.6 s), keeps padding (default 0.12 s) on each side so word onsets survive, and cuts the rest through the existing keep-segments graph, so picture and sound stay in sync. Steps run in order, so silence is detected on the timeline the steps before it leave: the planner gets an analysis mode that sends that audio through silencedetect into a null output, and ametadata prints the marks to a file in the job directory. The detected speech is handed back to the planner per step. Nothing silent makes the step a no-op; nothing left fails with a clear error. At most 200 pieces are kept, bridging the shortest pauses first, so the filter graph stays a sensible size. 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 * Add analyze_video to find cut points for the bot To cut a social video well the bot has to know where the cuts fit, and it could not look at a video at all. analyze_video is a read-only media job: one ffmpeg pass prints scene changes (scene score above 0.3, one per cut), pauses (silencedetect with the remove_silence defaults) and short-term loudness (ebur128) to files in the job directory, and a transcript made from the video by transcribe (an SRT whose parent is the video) adds when each line is spoken. The job stores nothing; its card just says done. The result reaches the bot through the existing media job path: the job always wakes the bot that started it (the tool has no continue_after) and the summary carries a short legend plus compact JSON. The runtime's summary limit grows to 12,000 characters and the summary is fitted into it: at most 200 entries per list, then every list shrinks evenly, keeping the strongest cuts, the longest pauses and the start of the transcript, with omitted counts. The wake prompt no longer claims the card shows the result when a job made no files. Jobs read derived artifacts through a new fetchDerivedArtifact, scoped to the job's user like fetchArtifact. A migration admits the operation in the media_jobs check and adds the tool to existing Studio specialists that still edit video. The web card gets an icon; no new copy. 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 * chore(db): add the widened media job operation check as NOT VALID and validate it separately Follows the repository's migration convention: the constraint commits without scanning media_jobs, and a second migration validates it with a lock that lets reads and writes continue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * Brand videos with a logo overlay and an intro before the cut edit_video gains an overlay step that lays a PNG, JPEG or WebP image over the video at a corner or the center, sized to a share of the frame width, with optional opacity and time range; tall frames keep the 9:16 safe areas clear. concat takes at: "start" so an intro can go before the video. The image is authorized like other inputs, read with its own demuxer and refused above 8192 pixels on a side. New Studio specialists are told to brand with the user's logo and intro. 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>
697 lines
23 KiB
TypeScript
697 lines
23 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { constants } from "node:fs";
|
|
import { access, rm, stat } from "node:fs/promises";
|
|
import { setPriority } from "node:os";
|
|
import { delimiter, join } from "node:path";
|
|
|
|
/**
|
|
* ffmpeg and ffprobe from PATH. Every call passes an argument array (never a shell), opens
|
|
* inputs through the `file` protocol only, and forces the demuxer from the artifact's MIME type
|
|
* so a crafted upload cannot make ffmpeg probe it as a playlist that reads other files or URLs.
|
|
*/
|
|
|
|
export interface FfmpegInfo {
|
|
ffmpegPath: string;
|
|
ffprobePath: string;
|
|
}
|
|
|
|
export interface MediaProbe {
|
|
durationMs: number;
|
|
/** Width and height as displayed: swapped when the stream is rotated by 90 or 270 degrees. */
|
|
video?: {
|
|
codec: string;
|
|
width: number;
|
|
height: number;
|
|
rotation: 0 | 90 | 180 | 270;
|
|
/** Frames per second; 0 when unknown or implausible (variable frame rate files). */
|
|
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;
|
|
/** When the recording was made, from the container's `creation_time` tag. */
|
|
createdAt?: Date;
|
|
}
|
|
|
|
export interface FfmpegProgress {
|
|
outTimeUs?: number;
|
|
totalSize?: number;
|
|
done: boolean;
|
|
}
|
|
|
|
export interface RunFfmpegOptions {
|
|
signal?: AbortSignal;
|
|
/** Expected output duration; progress is reported as out_time / durationMs. */
|
|
durationMs?: number;
|
|
onProgress?: (fraction: number) => void;
|
|
cwd?: string;
|
|
/** Kills ffmpeg once the output grows past this size. */
|
|
maxOutputBytes?: number;
|
|
/** Files removed when the run fails or is aborted. */
|
|
outputs?: readonly string[];
|
|
}
|
|
|
|
export class FfmpegError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly exitCode: number | null,
|
|
readonly stderrTail: string,
|
|
) {
|
|
super(message);
|
|
this.name = "FfmpegError";
|
|
}
|
|
}
|
|
|
|
/** ffmpeg reports progress twice a second while it works; silence this long means a hang. */
|
|
const STALL_TIMEOUT_MS = 2 * 60_000;
|
|
const PROBE_TIMEOUT_MS = 60_000;
|
|
const STDERR_TAIL_BYTES = 8 * 1024;
|
|
const KILL_GRACE_MS = 5_000;
|
|
const MAX_PLAUSIBLE_FPS = 240;
|
|
|
|
const DEMUXERS: Record<string, string> = {
|
|
"video/mp4": "mov",
|
|
"video/quicktime": "mov",
|
|
"audio/mp4": "mov",
|
|
"video/webm": "matroska",
|
|
"audio/webm": "matroska",
|
|
"audio/mpeg": "mp3",
|
|
"audio/wav": "wav",
|
|
"audio/ogg": "ogg",
|
|
"image/png": "png_pipe",
|
|
"image/jpeg": "jpeg_pipe",
|
|
"image/webp": "webp_pipe",
|
|
};
|
|
|
|
let detection: Promise<FfmpegInfo | null> | undefined;
|
|
|
|
/** Finds ffmpeg and ffprobe on PATH once per process; null when either is missing or broken. */
|
|
export function detectFfmpeg(): Promise<FfmpegInfo | null> {
|
|
detection ??= locateFfmpeg();
|
|
return detection;
|
|
}
|
|
|
|
async function locateFfmpeg(): Promise<FfmpegInfo | null> {
|
|
const [ffmpegPath, ffprobePath] = await Promise.all([
|
|
findExecutable("ffmpeg"),
|
|
findExecutable("ffprobe"),
|
|
]);
|
|
if (!ffmpegPath || !ffprobePath) return null;
|
|
try {
|
|
const [ffmpeg, ffprobe] = await Promise.all([
|
|
capture(ffmpegPath, ["-hide_banner", "-version"], { timeoutMs: 10_000 }),
|
|
capture(ffprobePath, ["-hide_banner", "-version"], { timeoutMs: 10_000 }),
|
|
]);
|
|
return ffmpeg.code === 0 && ffprobe.code === 0 ? { ffmpegPath, ffprobePath } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function findExecutable(name: string): Promise<string | null> {
|
|
const extensions =
|
|
process.platform === "win32"
|
|
? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean)
|
|
: [""];
|
|
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
if (!directory) continue;
|
|
for (const extension of extensions) {
|
|
const candidate = join(directory, `${name}${extension}`);
|
|
try {
|
|
await access(candidate, constants.X_OK);
|
|
if ((await stat(candidate)).isFile()) return candidate;
|
|
} catch {
|
|
// Not in this directory.
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function requireFfmpeg(): Promise<FfmpegInfo> {
|
|
const info = await detectFfmpeg();
|
|
if (!info) throw new Error("ffmpeg and ffprobe are not installed on this server.");
|
|
return info;
|
|
}
|
|
|
|
/** ffmpeg time argument with millisecond precision and no exponent notation. */
|
|
export function ffmpegSeconds(seconds: number): string {
|
|
return String(Number(Math.max(0, seconds).toFixed(3)));
|
|
}
|
|
|
|
/** Input arguments for a local file: optional seek, file protocol only, forced demuxer. */
|
|
export function ffmpegInputArgs(
|
|
path: string,
|
|
options: { mimeType?: string; startSeconds?: number; durationSeconds?: number } = {},
|
|
): string[] {
|
|
const demuxer = options.mimeType ? DEMUXERS[options.mimeType] : undefined;
|
|
return [
|
|
...(options.startSeconds ? ["-ss", ffmpegSeconds(options.startSeconds)] : []),
|
|
...(options.durationSeconds !== undefined
|
|
? ["-t", ffmpegSeconds(options.durationSeconds)]
|
|
: []),
|
|
"-protocol_whitelist",
|
|
"file",
|
|
...(demuxer ? ["-f", demuxer] : []),
|
|
"-i",
|
|
`file:${path}`,
|
|
];
|
|
}
|
|
|
|
export async function probeMedia(
|
|
path: string,
|
|
options: { signal?: AbortSignal; mimeType?: string } = {},
|
|
): Promise<MediaProbe> {
|
|
const { ffprobePath } = await requireFfmpeg();
|
|
const input = ffmpegInputArgs(path, { mimeType: options.mimeType });
|
|
const result = await capture(
|
|
ffprobePath,
|
|
["-v", "error", ...input, "-print_format", "json", "-show_format", "-show_streams"],
|
|
{ signal: options.signal, timeoutMs: PROBE_TIMEOUT_MS },
|
|
);
|
|
if (result.code !== 0) throw ffmpegFailure("ffprobe", result.code, result.stderr);
|
|
const probe = parseProbeOutput(result.stdout);
|
|
if (probe.durationMs > 0 || (!probe.video && !probe.audio)) return probe;
|
|
// Recordings streamed by browsers often carry no duration header; read it from the packets.
|
|
const packets = await capture(
|
|
ffprobePath,
|
|
[
|
|
"-v",
|
|
"error",
|
|
...input,
|
|
"-select_streams",
|
|
probe.video ? "v:0" : "a:0",
|
|
"-show_entries",
|
|
"packet=pts_time,duration_time",
|
|
"-of",
|
|
"csv=p=0",
|
|
],
|
|
{ signal: options.signal, timeoutMs: PROBE_TIMEOUT_MS * 5 },
|
|
);
|
|
if (packets.code !== 0) throw ffmpegFailure("ffprobe", packets.code, packets.stderr);
|
|
return { ...probe, durationMs: packetDurationMs(packets.stdout) };
|
|
}
|
|
|
|
/** End of the last packet, from ffprobe `packet=pts_time,duration_time` CSV lines. */
|
|
export function packetDurationMs(csv: string): number {
|
|
let end = 0;
|
|
for (const line of csv.split("\n")) {
|
|
const [pts, duration] = line.split(",").map(Number);
|
|
if (pts === undefined || !Number.isFinite(pts)) continue;
|
|
end = Math.max(end, pts + (duration !== undefined && Number.isFinite(duration) ? duration : 0));
|
|
}
|
|
return Math.round(end * 1000);
|
|
}
|
|
|
|
interface ProbeStream {
|
|
codec_type?: string;
|
|
codec_name?: string;
|
|
width?: number;
|
|
height?: number;
|
|
avg_frame_rate?: string;
|
|
r_frame_rate?: string;
|
|
sample_aspect_ratio?: string;
|
|
sample_rate?: string;
|
|
channels?: number;
|
|
duration?: string;
|
|
bit_rate?: string;
|
|
disposition?: { attached_pic?: number };
|
|
tags?: { rotate?: string };
|
|
side_data_list?: { rotation?: number }[];
|
|
}
|
|
|
|
/** Parses `ffprobe -print_format json -show_format -show_streams` output. */
|
|
export function parseProbeOutput(json: string): MediaProbe {
|
|
const data = JSON.parse(json) as {
|
|
format?: { duration?: string; tags?: { creation_time?: string } };
|
|
streams?: ProbeStream[];
|
|
};
|
|
const streams = data.streams ?? [];
|
|
// Cover art in audio files is a single attached picture, not a video track.
|
|
const video = streams.find(
|
|
(stream) => stream.codec_type === "video" && stream.disposition?.attached_pic !== 1,
|
|
);
|
|
const audio = streams.find((stream) => stream.codec_type === "audio");
|
|
const seconds =
|
|
positive(Number(data.format?.duration)) ??
|
|
Math.max(0, ...streams.map((stream) => positive(Number(stream.duration)) ?? 0));
|
|
return {
|
|
durationMs: Math.round(seconds * 1000),
|
|
...(video?.width && video.height
|
|
? { video: probeVideo(video, video.width, video.height) }
|
|
: {}),
|
|
...(audio
|
|
? {
|
|
audio: {
|
|
codec: audio.codec_name ?? "",
|
|
sampleRate: Number(audio.sample_rate) || 0,
|
|
channels: audio.channels ?? 0,
|
|
},
|
|
}
|
|
: {}),
|
|
hasAudio: Boolean(audio),
|
|
...creationTime(data.format?.tags?.creation_time),
|
|
};
|
|
}
|
|
|
|
/** Cameras without a clock write 1970 or 1904; those are no capture time. */
|
|
function creationTime(value: string | undefined): { createdAt?: Date } {
|
|
const time = value ? Date.parse(value) : Number.NaN;
|
|
return Number.isFinite(time) && time > Date.UTC(1990, 0, 1) ? { createdAt: new Date(time) } : {};
|
|
}
|
|
|
|
function probeVideo(stream: ProbeStream, width: number, height: number) {
|
|
const rotation = normalizeRotation(
|
|
stream.side_data_list?.find((entry) => typeof entry.rotation === "number")?.rotation ??
|
|
Number(stream.tags?.rotate ?? 0),
|
|
);
|
|
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,
|
|
height: sideways ? width : height,
|
|
rotation,
|
|
fps: fps > 0 && fps <= MAX_PLAUSIBLE_FPS ? fps : 0,
|
|
sampleAspectRatio: sideways ? 1 / sar : sar,
|
|
...(bitRate ? { bitRateKbps: Math.round(bitRate / 1000) } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizeRotation(degrees: number): 0 | 90 | 180 | 270 {
|
|
if (!Number.isFinite(degrees)) return 0;
|
|
const quarter = (((Math.round(degrees / 90) % 4) + 4) % 4) as 0 | 1 | 2 | 3;
|
|
return ([0, 90, 180, 270] as const)[quarter];
|
|
}
|
|
|
|
function ratio(value: string | undefined, separator: string): number {
|
|
if (!value) return 0;
|
|
const [numerator, denominator] = value.split(separator).map(Number);
|
|
if (!numerator || !denominator || !Number.isFinite(numerator / denominator)) return 0;
|
|
return numerator / denominator;
|
|
}
|
|
|
|
function positive(value: number): number | undefined {
|
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
}
|
|
|
|
/** Feeds ffmpeg `-progress` output (key=value lines) and reports each completed block. */
|
|
export function createFfmpegProgressParser(
|
|
onUpdate: (progress: FfmpegProgress) => void,
|
|
): (chunk: string) => void {
|
|
let buffer = "";
|
|
let current: Omit<FfmpegProgress, "done"> = {};
|
|
return (chunk) => {
|
|
buffer += chunk;
|
|
for (let newline = buffer.indexOf("\n"); newline >= 0; newline = buffer.indexOf("\n")) {
|
|
const line = buffer.slice(0, newline).trim();
|
|
buffer = buffer.slice(newline + 1);
|
|
const separator = line.indexOf("=");
|
|
if (separator < 0) continue;
|
|
const key = line.slice(0, separator);
|
|
const value = line.slice(separator + 1).trim();
|
|
const number = Number(value);
|
|
// out_time_ms is microseconds too (a long-standing ffmpeg naming bug).
|
|
if ((key === "out_time_us" || key === "out_time_ms") && value && number >= 0) {
|
|
current.outTimeUs = number;
|
|
} else if (key === "total_size" && value && number >= 0) {
|
|
current.totalSize = number;
|
|
} else if (key === "progress") {
|
|
onUpdate({ ...current, done: value === "end" });
|
|
current = {};
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Runs ffmpeg with progress on stdout; rejects with the stderr tail when it fails. */
|
|
export async function runFfmpeg(
|
|
args: readonly string[],
|
|
options: RunFfmpegOptions = {},
|
|
): Promise<void> {
|
|
options.signal?.throwIfAborted();
|
|
const { ffmpegPath } = await requireFfmpeg();
|
|
try {
|
|
await spawnFfmpeg(ffmpegPath, args, options);
|
|
} catch (error) {
|
|
await Promise.all((options.outputs ?? []).map((path) => rm(path, { force: true })));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function spawnFfmpeg(
|
|
ffmpegPath: string,
|
|
args: readonly string[],
|
|
options: RunFfmpegOptions,
|
|
): Promise<void> {
|
|
const { signal, durationMs, onProgress, maxOutputBytes } = options;
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(
|
|
ffmpegPath,
|
|
[
|
|
"-hide_banner",
|
|
"-nostdin",
|
|
"-nostats",
|
|
"-loglevel",
|
|
"error",
|
|
"-progress",
|
|
"pipe:1",
|
|
"-y",
|
|
...args,
|
|
],
|
|
{ cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
|
);
|
|
if (child.pid) {
|
|
try {
|
|
// Long encodes must not starve the API process that shares this machine.
|
|
setPriority(child.pid, 10);
|
|
} catch {
|
|
// Lowering priority is best effort.
|
|
}
|
|
}
|
|
let stderr = "";
|
|
let settled = false;
|
|
let reported = 0;
|
|
let failure: unknown;
|
|
let stallTimer: NodeJS.Timeout | undefined;
|
|
const finish = (error?: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(stallTimer);
|
|
signal?.removeEventListener("abort", abort);
|
|
if (error === undefined) resolve();
|
|
else reject(error);
|
|
};
|
|
// Settles once the process is gone, so callers can delete its files right away.
|
|
const kill = (error: unknown) => {
|
|
if (failure !== undefined || settled) return;
|
|
failure = error;
|
|
clearTimeout(stallTimer);
|
|
child.kill("SIGKILL");
|
|
setTimeout(() => finish(error), KILL_GRACE_MS).unref?.();
|
|
};
|
|
const abort = () => kill(signal?.reason ?? new Error("aborted"));
|
|
const armStallTimer = () => {
|
|
clearTimeout(stallTimer);
|
|
stallTimer = setTimeout(
|
|
() => kill(new FfmpegError("ffmpeg stopped making progress.", null, tail(stderr))),
|
|
STALL_TIMEOUT_MS,
|
|
);
|
|
stallTimer.unref?.();
|
|
};
|
|
const parse = createFfmpegProgressParser((progress) => {
|
|
if (failure !== undefined) return;
|
|
if (maxOutputBytes !== undefined && (progress.totalSize ?? 0) > maxOutputBytes) {
|
|
kill(new Error(outputTooLargeMessage(maxOutputBytes)));
|
|
return;
|
|
}
|
|
if (!onProgress) return;
|
|
const fraction = progress.done
|
|
? 1
|
|
: durationMs && progress.outTimeUs !== undefined
|
|
? Math.min(1, progress.outTimeUs / (durationMs * 1000))
|
|
: 0;
|
|
if (fraction > reported) {
|
|
reported = fraction;
|
|
onProgress(fraction);
|
|
}
|
|
});
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
armStallTimer();
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
if (failure === undefined) armStallTimer();
|
|
parse(chunk);
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr = tail(stderr + chunk);
|
|
});
|
|
child.on("error", (error) => finish(failure ?? error));
|
|
child.on("close", (code) => {
|
|
if (failure !== undefined) finish(failure);
|
|
else if (code === 0) finish();
|
|
else finish(ffmpegFailure("ffmpeg", code, stderr));
|
|
});
|
|
if (signal?.aborted) abort();
|
|
});
|
|
}
|
|
|
|
export function outputTooLargeMessage(maxBytes: number): string {
|
|
return `The result would exceed ${Math.round(maxBytes / (1024 * 1024))} MB.`;
|
|
}
|
|
|
|
function tail(text: string): string {
|
|
return text.length > STDERR_TAIL_BYTES ? text.slice(-STDERR_TAIL_BYTES) : text;
|
|
}
|
|
|
|
function ffmpegFailure(tool: string, code: number | null, stderr: string): FfmpegError {
|
|
// The cause is usually the line before ffmpeg's generic summary ("Conversion failed!").
|
|
const lines = stderr
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.slice(-2);
|
|
// Name files by their base name only; the job's temp paths mean nothing to the reader.
|
|
const detail = lines.join(" ").replaceAll(/(?:file:)?(?:[A-Za-z]:)?[\\/][^\s'"]*[\\/]/g, "");
|
|
return new FfmpegError(
|
|
detail ? `${tool} failed: ${detail}` : `${tool} failed with exit code ${code}.`,
|
|
code,
|
|
tail(stderr),
|
|
);
|
|
}
|
|
|
|
/** Extracts the first audio track as 16 kHz mono 16-bit WAV, the format speech models expect. */
|
|
export async function extractAudio(
|
|
input: string,
|
|
output: string,
|
|
options: {
|
|
signal?: AbortSignal;
|
|
mimeType?: string;
|
|
startMs?: number;
|
|
endMs?: number;
|
|
onProgress?: (fraction: number) => void;
|
|
} = {},
|
|
): Promise<void> {
|
|
const probe = await probeMedia(input, { signal: options.signal, mimeType: options.mimeType });
|
|
if (!probe.hasAudio) throw new Error("The file has no audio track.");
|
|
const startMs = Math.max(0, options.startMs ?? 0);
|
|
const endMs = Math.min(probe.durationMs, options.endMs ?? probe.durationMs);
|
|
if (endMs <= startMs) throw new Error("The audio range is empty.");
|
|
await runFfmpeg(
|
|
[
|
|
...ffmpegInputArgs(input, {
|
|
mimeType: options.mimeType,
|
|
startSeconds: startMs / 1000,
|
|
durationSeconds: (endMs - startMs) / 1000,
|
|
}),
|
|
"-map",
|
|
"0:a:0",
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
"16000",
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
"-map_metadata",
|
|
"-1",
|
|
"-f",
|
|
"wav",
|
|
`file:${output}`,
|
|
],
|
|
{
|
|
signal: options.signal,
|
|
durationMs: endMs - startMs,
|
|
onProgress: options.onProgress,
|
|
outputs: [output],
|
|
},
|
|
);
|
|
}
|
|
|
|
/** 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,
|
|
output: string,
|
|
options: { signal?: AbortSignal; mimeType?: string; atMs?: number } = {},
|
|
): Promise<void> {
|
|
const render = (atMs: number) =>
|
|
runFfmpeg(
|
|
[
|
|
...ffmpegInputArgs(input, { mimeType: options.mimeType, startSeconds: atMs / 1000 }),
|
|
"-map",
|
|
"0:v:0",
|
|
"-frames:v",
|
|
"1",
|
|
"-c:v",
|
|
"mjpeg",
|
|
// JPEG uses full-range YUV; newer encoders refuse video's limited range.
|
|
"-pix_fmt",
|
|
"yuvj420p",
|
|
"-q:v",
|
|
"3",
|
|
"-map_metadata",
|
|
"-1",
|
|
"-f",
|
|
"image2",
|
|
"-update",
|
|
"1",
|
|
`file:${output}`,
|
|
],
|
|
{ signal: options.signal, outputs: [output] },
|
|
);
|
|
const atMs = Math.max(0, options.atMs ?? 0);
|
|
await render(atMs);
|
|
// Seeking into the last frame's gap writes nothing; the first frame is better than no poster.
|
|
if (atMs > 0 && !(await nonEmptyFile(output))) await render(0);
|
|
if (!(await nonEmptyFile(output))) throw new Error("Could not render a poster frame.");
|
|
}
|
|
|
|
async function nonEmptyFile(path: string): Promise<boolean> {
|
|
try {
|
|
return (await stat(path)).size > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
interface CaptureResult {
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
function capture(
|
|
command: string,
|
|
args: readonly string[],
|
|
options: { signal?: AbortSignal; timeoutMs: number },
|
|
): Promise<CaptureResult> {
|
|
options.signal?.throwIfAborted();
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
const stdout: Buffer[] = [];
|
|
let stderr = "";
|
|
let settled = false;
|
|
const finish = (error: unknown, result?: CaptureResult) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
options.signal?.removeEventListener("abort", abort);
|
|
if (result) resolve(result);
|
|
else reject(error);
|
|
};
|
|
const kill = (error: unknown) => {
|
|
child.kill("SIGKILL");
|
|
finish(error);
|
|
};
|
|
const abort = () => kill(options.signal?.reason ?? new Error("aborted"));
|
|
const timer = setTimeout(
|
|
() => kill(new Error(`${command} timed out after ${options.timeoutMs} ms`)),
|
|
options.timeoutMs,
|
|
);
|
|
timer.unref?.();
|
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr = tail(stderr + chunk);
|
|
});
|
|
child.on("error", (error) => finish(error));
|
|
child.on("close", (code) =>
|
|
finish(undefined, { code, stdout: Buffer.concat(stdout).toString("utf8"), stderr }),
|
|
);
|
|
});
|
|
}
|