evOo-local/packages/adapters/src/media/video-operations.ts
Claude 3eb1fdd68f
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
2026-10-05 10:53:57 +00:00

532 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { copyFile, mkdtemp, open, rename, stat } from "node:fs/promises";
import { join } from "node:path";
import type {
MediaJobContext,
MediaJobInputFile,
MediaJobOutputFile,
MediaOperationResult,
} from "@rakazo/adapter-kit";
import type {
EditVideoInput,
EditVideoStep,
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, 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 { detectSpeech } from "./silence.js";
import type { PlanInput, VideoEditPlan } from "./video-edit-plan.js";
import { planLoudnessFix, planVideoEdit } from "./video-edit-plan.js";
export interface VideoOperationsDeps {
/** Finds ffmpeg; defaults to PATH lookup. */
detect?: () => Promise<FfmpegInfo | null>;
}
/** Share of each output's progress spent encoding; the rest is probing and the poster. */
const ENCODE_SHARE = 0.95;
const SUBTITLE_MAX_BYTES = 5 * 1024 * 1024;
const OVERLAY_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
/** Larger images would take gigabytes to decode for something drawn a few hundred pixels wide. */
const OVERLAY_MAX_SIDE = 8192;
/** `edit_video` and `export_video`, available wherever ffmpeg and ffprobe are installed. */
export function createVideoOperations(
deps: VideoOperationsDeps = {},
): MediaOperationRegistration[] {
const detect = deps.detect ?? detectFfmpeg;
const available = async () => (await detect()) !== null;
const requireTools = async () => {
if (!(await available()))
throw new Error("ffmpeg and ffprobe are not installed on this server.");
};
const editVideo: MediaOperationRegistration<EditVideoInput> = {
operation: "edit_video",
inputSchema: EditVideoInputSchema,
title: (input) => input.title,
available,
artifactIds: (input) => [input.artifact_id, ...input.steps.flatMap(stepArtifactIds)],
handler: {
operation: "edit_video",
run: async (context) => {
await requireTools();
return runEditVideo(context);
},
},
};
const exportVideo: MediaOperationRegistration<ExportVideoInput> = {
operation: "export_video",
inputSchema: ExportVideoInputSchema,
title: (input) =>
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: {
operation: "export_video",
run: async (context) => {
await requireTools();
return runExportVideo(context);
},
},
};
return [editVideo, exportVideo];
}
async function runEditVideo(context: MediaJobContext): Promise<MediaOperationResult> {
const input = EditVideoInputSchema.parse(context.input);
const workDir = await mkdtemp(join(context.tempDir, "edit-"));
const progress = createProgressReporter(context);
const source = await loadVideo(context, input.artifact_id);
const name = cleanFileName(input.title, cleanFileName(withoutExtension(source.name), "video"));
const [first] = input.steps;
if (input.steps.length === 1 && first?.op === "poster") {
const at = first.at;
if (at * 1000 >= source.probe.durationMs) {
throw new Error(
`poster at ${at} s is past the end of the ${source.probe.durationMs / 1000} s video.`,
);
}
const posterPath = join(workDir, "poster.jpg");
await renderPoster(source.path, posterPath, {
signal: context.signal,
mimeType: source.mimeType,
atMs: at * 1000,
});
const output = await context.addOutput({
path: posterPath,
name: `${name}.jpg`,
mimeType: "image/jpeg",
width: source.probe.video?.width,
height: source.probe.video?.height,
parentArtifactId: source.artifactId,
});
await progress.done();
return { summary: `Saved the frame at ${at} s of ${source.name} as ${describe(output)}.` };
}
const clips = new Map<string, PlanInput>();
const images = new Map<string, PlanInput>();
const subtitleFiles = new Map<string, string>();
for (const step of input.steps) {
if (step.op === "concat") {
for (const id of step.artifact_ids) {
if (!clips.has(id)) clips.set(id, await loadVideo(context, id));
}
} else if (step.op === "overlay" && !images.has(step.artifact_id)) {
images.set(step.artifact_id, await loadImage(context, step.artifact_id));
} else if (step.op === "subtitles" && !subtitleFiles.has(step.artifact_id)) {
subtitleFiles.set(
step.artifact_id,
await prepareSubtitles(context, step.artifact_id, workDir, subtitleFiles.size),
);
}
}
const audioOnly = input.steps.at(-1)?.op === "extract_audio";
const outputPath = join(workDir, audioOnly ? "output.m4a" : "output.mp4");
const speech = await detectSpeech(
{ source, steps: input.steps, clips, images, subtitleFiles },
{ workDir, signal: context.signal },
);
const plan = planVideoEdit({
source,
steps: input.steps,
clips,
images,
subtitleFiles,
speech,
outputPath,
});
const output = await encode(context, plan, outputPath, workDir, progress.range(0, 1), {
name: `${name}${audioOnly ? ".m4a" : ".mp4"}`,
parentArtifactId: source.artifactId,
});
await progress.done();
return {
summary: `Edited ${source.name} into ${describe(output)}.`,
};
}
async function runExportVideo(context: MediaJobContext): Promise<MediaOperationResult> {
const input = ExportVideoInputSchema.parse(context.input);
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 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,
});
outputs.push(
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();
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,
plan: VideoEditPlan,
outputPath: string,
workDir: string,
report: (fraction: number) => void,
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,
cwd: workDir,
durationMs: plan.durationMs,
onProgress: (fraction) => report(fraction * ENCODE_SHARE),
maxOutputBytes: maxBytes,
outputs: [outputPath],
});
if ((await stat(outputPath)).size > maxBytes) throw new Error(outputTooLargeMessage(maxBytes));
}
/** 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,
path: outputPath,
mimeType: "audio/mp4",
durationMs: probe.durationMs,
});
report(1);
return output;
}
const posterPath = outputPath.replace(/\.mp4$/, ".jpg");
await renderPoster(outputPath, posterPath, {
signal: context.signal,
mimeType: "video/mp4",
atMs: plan.posterAtMs ?? Math.min(1_000, probe.durationMs / 4),
});
const output = await context.addOutput({
...file,
path: outputPath,
mimeType: "video/mp4",
width: probe.video?.width ?? plan.width,
height: probe.video?.height ?? plan.height,
durationMs: probe.durationMs,
posterPath,
});
report(1);
return output;
}
async function loadVideo(
context: MediaJobContext,
artifactId: string,
): Promise<PlanInput & MediaJobInputFile> {
const file = await context.fetchArtifact(artifactId);
if (file.kind !== "video") throw new Error(`${file.name} is not a video.`);
const probe = await probeMedia(file.path, {
signal: context.signal,
mimeType: file.mimeType,
});
if (!probe.video) throw new Error(`${file.name} has no video.`);
if (probe.durationMs <= 0) throw new Error(`Could not read the length of ${file.name}.`);
return { ...file, probe };
}
function stepArtifactIds(step: EditVideoStep): string[] {
if (step.op === "concat") return step.artifact_ids;
if (step.op === "subtitles" || step.op === "overlay") return [step.artifact_id];
return [];
}
/** A PNG, JPEG or WebP artifact for an overlay, read with its format's demuxer only. */
async function loadImage(context: MediaJobContext, artifactId: string): Promise<PlanInput> {
const file = await context.fetchArtifact(artifactId);
if (file.kind !== "image" || !OVERLAY_MIME_TYPES.has(file.mimeType)) {
throw new Error(`${file.name} is not a PNG, JPEG or WebP image.`);
}
const probe = await probeMedia(file.path, { signal: context.signal, mimeType: file.mimeType });
if (!probe.video) throw new Error(`${file.name} is not a PNG, JPEG or WebP image.`);
if (Math.max(probe.video.width, probe.video.height) > OVERLAY_MAX_SIDE) {
throw new Error(`${file.name} is larger than ${OVERLAY_MAX_SIDE} pixels on a side.`);
}
return { ...file, probe };
}
/** Copies an SRT/VTT artifact to a generated name, so no user text reaches the filter graph. */
async function prepareSubtitles(
context: MediaJobContext,
artifactId: string,
workDir: string,
index: number,
): Promise<string> {
const file = await context.fetchArtifact(artifactId);
const format = file.size <= SUBTITLE_MAX_BYTES ? subtitleFormat(await readHead(file.path)) : null;
if (!format) throw new Error(`${file.name} is not an SRT or VTT file.`);
const name = `subtitles-${index}.${format}`;
await copyFile(file.path, join(workDir, name));
return name;
}
async function readHead(path: string): Promise<string> {
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(1024);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close();
}
}
/**
* Recognizes WebVTT and SubRip by content. Anything else is refused, so ffmpeg never probes
* an arbitrary file (a playlist, say) handed in as subtitles.
*/
export function subtitleFormat(head: string): "srt" | "vtt" | null {
const text = head.replace(/^/, "").trimStart();
if (/^WEBVTT(?:[ \t\r\n]|$)/.test(text)) return "vtt";
if (/^(?:\d+[ \t]*\r?\n)?\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}[ \t]*-->/.test(text)) return "srt";
return null;
}
/** A name for produced files: no path separators or control characters, at most 80 chars. */
export function cleanFileName(text: string, fallback: string): string {
const name = text
.replaceAll(/[\p{Cc}\\/:*?"<>|]+/gu, " ")
.replaceAll(/\s+/g, " ")
.replace(/^[\s.]+/, "")
.slice(0, 80)
.trim();
return name || fallback;
}
function withoutExtension(name: string): string {
return name.replace(/\.[A-Za-z0-9]{1,5}$/, "");
}
/** "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 ? `${name} (${details.join(", ")})` : name;
}
/** Maps ffmpeg progress onto the card: monotonic, written in order, never failing the job. */
export function createProgressReporter(context: MediaJobContext) {
let reported = 0;
let writes = Promise.resolve();
const write = (fraction: number) => {
reported = fraction;
writes = writes.then(() => context.progress(fraction)).catch(() => undefined);
};
return {
range: (from: number, to: number) => (fraction: number) => {
const value = Math.min(1, from + (to - from) * Math.min(1, Math.max(0, fraction)));
if (value > reported) write(value);
},
done: async () => {
if (reported < 1) write(1);
await writes;
},
};
}