diff --git a/src/lib/exportGraph.ts b/src/lib/exportGraph.ts new file mode 100644 index 000000000..aad68743f --- /dev/null +++ b/src/lib/exportGraph.ts @@ -0,0 +1,289 @@ +import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; +import { buildTextFilter } from "./text-overlay"; + +/** + * The FFmpeg filter graph and argv for one export. + * + * This lives outside `ffmpeg.worker.ts` so it is a single implementation that + * both the worker and the unit tests execute — the export arguments used to be + * duplicated (one copy here, one copy in `ffmpeg.ts` that nothing called), and + * the copy that actually ran silently drifted behind it. Keep every export + * graph decision in this file. + * + * Everything here is pure: no worker globals, no DOM, no FFmpeg instance. + */ + +export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { + const filters: string[] = []; + + if (recipe.trimStart > 0 || recipe.trimEnd !== null) { + // Only use trim filter with precise bounds to avoid scanning entire file. + // If trimEnd is null, let FFmpeg infer the end instead of a large placeholder. + if (recipe.trimEnd !== null) { + filters.push(`trim=start=${recipe.trimStart}:end=${recipe.trimEnd}`); + } else if (recipe.trimStart > 0) { + filters.push(`trim=start=${recipe.trimStart}`); + } + } + + if (recipe.stabilization) { + filters.push("deshake"); + } + + if (recipe.rotate === 90) { + filters.push("transpose=1"); + } else if (recipe.rotate === 180) { + filters.push("transpose=1,transpose=1"); + } else if (recipe.rotate === 270) { + filters.push("transpose=2"); + } + + if (recipe.framing === "fit") { + filters.push( + `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, + `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black` + ); + } else { + filters.push( + `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`, + `crop=${targetW}:${targetH}` + ); + } + + if (recipe.sharpness !== 0) { + filters.push(`unsharp=5:5:${recipe.sharpness}:5:5:0.0`); + } + + // Normalize timestamps only when needed — trim or speed change both + // require a clean 0-based timeline to produce correct output duration. + if (recipe.trimStart > 0 || recipe.trimEnd !== null || recipe.speed !== 1) { + filters.push("setpts=PTS-STARTPTS"); + } + + if (recipe.speed !== 1) { + const pts = (1 / recipe.speed).toFixed(4); + filters.push(`setpts=${pts}*PTS`); + } + + if (recipe.denoise) { + filters.push("hqdn3d=1.5:1.5:6:6"); + } + + const needsEq = + recipe.brightness !== 0 || + recipe.contrast !== 1 || + recipe.saturation !== 1; + + if (needsEq) { + filters.push( + `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}` + ); + } + + const textOverlays = recipe.textOverlays || []; + textOverlays.forEach((overlay) => { + filters.push(buildTextFilter(overlay, targetW, targetH)); + }); + + return filters.join(","); +} + +export function buildAudioFilter(recipe: EditRecipe): string { + if (recipe.speed <= 0) return ""; + const filters: string[] = []; + + let remaining = recipe.speed; + while (remaining < 0.5) { + filters.push("atempo=0.5"); + remaining /= 0.5; + } + + while (remaining > 2.0) { + filters.push("atempo=2.0"); + remaining /= 2.0; + } + + if (Math.abs(remaining - 1.0) > 0.001) { + filters.push(`atempo=${Number(remaining.toFixed(4))}`); + } + + if (recipe.volume !== undefined && recipe.volume !== 100) { + filters.push(`volume=${(recipe.volume / 100).toFixed(2)}`); + } + + if (recipe.normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11"); + + return filters.join(","); +} + +export function buildAudioTrimFilter(recipe: EditRecipe): string { + if (recipe.trimStart === 0 && recipe.trimEnd === null) return ""; + + // Avoid scanning entire audio with large placeholder values; + // use precise trim bounds when available. + let trimFilter = `atrim=start=${recipe.trimStart}`; + if (recipe.trimEnd !== null) { + trimFilter += `:end=${recipe.trimEnd}`; + } + // asetpts normalizes timestamps after trim for correct stream positioning + return `${trimFilter},asetpts=PTS-STARTPTS`; +} + +/** + * Output duration cap, as FFmpeg arguments. + * + * Without `-t`, an audio input that outlasts the video decides when encoding + * stops: a music track longer than the video extends the output, and looped + * music (`-stream_loop -1`) on a video with no audio track never terminates. + * + * Returns no arguments when the source duration cannot be trusted — + * `videoDuration` falls back to 0 when metadata fails to load, and containers + * such as MediaRecorder WebM report no duration at all (`Infinity`). Either + * value would otherwise reach FFmpeg as `-t 0` (a zero-length export) or + * `-t Infinity` ("Invalid duration specification for t: Infinity"). + */ +export function buildDurationArgs(recipe: EditRecipe, videoDuration: number): string[] { + const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart; + if (!Number.isFinite(sourceDuration) || sourceDuration <= 0) return []; + return ["-t", (sourceDuration / recipe.speed).toFixed(6)]; +} + +export function buildArguments( + recipe: EditRecipe, + format: "mp4" | "webm" | "mkv" | "gif", + outputName: string, + inputName: string, + targetW: number, + targetH: number, + hasMusicTrack: boolean, + musicInputName: string, + musicOptions: BackgroundMusicOptions | undefined, + hasOverlay: boolean, + overlayInputName: string, + overlayOptions: ImageOverlayOptions | undefined, + hasOriginalAudio: boolean, + videoDuration: number +): string[] { + const vf = buildVideoFilter(recipe, targetW, targetH); + const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : ""; + const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe) : ""; + const afParts = [audioTrim, audioSpeed].filter(Boolean); + const af = afParts.join(","); + + const musicIdx = 1; + const overlayIdx = hasMusicTrack ? 2 : 1; + + const args: string[] = []; + args.push("-i", inputName); + if (hasMusicTrack) { + if (musicOptions!.loopMusic) args.push("-stream_loop", "-1"); + args.push("-i", musicInputName); + } + if (hasOverlay) { + args.push("-i", overlayInputName); + } + + const needsFilterComplex = hasOverlay || hasMusicTrack; + const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack); + + if (needsFilterComplex) { + const filterParts: string[] = []; + let videoOut = "[0:v]"; + + if (vf) { + filterParts.push(`[0:v]${vf}[vbase]`); + videoOut = "[vbase]"; + } + + if (hasOverlay) { + const scaledW = overlayOptions!.size; + const alpha = (overlayOptions!.opacity / 100).toFixed(2); + const posMap: Record = { + "top-left": "20:20", + "top-right": "W-w-20:20", + "bottom-left": "20:H-h-20", + "bottom-right": "W-w-20:H-h-20", + }; + + interface PositionCoords { + x: number; + y: number; + } + + const pos = typeof overlayOptions?.position === "string" + ? (posMap[overlayOptions.position] ?? "W-w-20:H-h-20") + : overlayOptions?.position + ? `(W-w)*${(overlayOptions.position as PositionCoords).x}/100:(H-h)*${(overlayOptions.position as PositionCoords).y}/100` + : "W-w-20:H-h-20"; + + filterParts.push(`[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`); + filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`); + videoOut = "[vout]"; + } + + let audioOut = ""; + if (shouldKeepAudio) { + if (hasMusicTrack) { + const musicVol = (musicOptions!.musicVolume / 100).toFixed(2); + if (hasOriginalAudio) { + const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2); + const origChain = afParts.length > 0 + ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]` + : `[0:a]volume=${origVol}[orig]`; + filterParts.push(origChain); + filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`); + filterParts.push(`[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`); + audioOut = "[aout]"; + } else { + filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`); + audioOut = "[aout]"; + } + } else if (hasOriginalAudio && af) { + filterParts.push(`[0:a]${af}[aout]`); + audioOut = "[aout]"; + } + } + + if (filterParts.length > 0) { + args.push("-filter_complex", filterParts.join(";")); + } + args.push("-map", videoOut === "[0:v]" ? "0:v" : videoOut); + + if (!shouldKeepAudio) { + args.push("-an"); + } else if (audioOut) { + args.push("-map", audioOut); + } else if (hasOriginalAudio) { + args.push("-map", "0:a"); + } + } else { + if (vf) args.push("-vf", vf); + if (!shouldKeepAudio) { + args.push("-an"); + } else if (af && hasOriginalAudio) { + args.push("-af", af); + } + } + + if (format === "webm") { + args.push( + "-c:v", "libvpx-vp9", + "-b:v", "0", + "-crf", String(recipe.quality), + "-cpu-used", "4", + "-deadline", "realtime" + ); + if (shouldKeepAudio) args.push("-c:a", "libopus"); + } else if (format === "mkv") { + args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast"); + if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); + } else { + args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast", "-movflags", "+faststart"); + if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); + } + + args.push(...buildDurationArgs(recipe, videoDuration)); + + args.push(outputName); + return args; +} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 1251ac076..9d8540a5e 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,7 +1,7 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions, MAX_FILE_SIZE } from "./types"; import { getPresetById } from "./presets"; -import { buildTextFilter } from "./text-overlay"; import { trackAction, trackTiming } from "./telemetry"; +export { buildVideoFilter, buildAudioFilter } from "./exportGraph"; export class FFmpegLoadError extends Error {} @@ -376,268 +376,6 @@ function buildSessionId(): string { return `${timestamp}-${randomPart}${counterPart}`; } -export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { - const filters: string[] = []; - - if (recipe.trimStart > 0 || recipe.trimEnd !== null) { - // Only use trim filter with precise bounds to avoid scanning entire file - // If trimEnd is null, use duration parameter instead of large placeholder value - if (recipe.trimEnd !== null) { - filters.push(`trim=start=${recipe.trimStart}:end=${recipe.trimEnd}`); - } else if (recipe.trimStart > 0) { - // When only trimStart is set, let FFmpeg infer end (don't use 999999 placeholder) - filters.push(`trim=start=${recipe.trimStart}`); - } - } - - if (recipe.stabilization) { - filters.push("deshake"); - } - - if (recipe.rotate === 90) { - filters.push("transpose=1"); - } else if (recipe.rotate === 180) { - filters.push("transpose=1,transpose=1"); - } else if (recipe.rotate === 270) { - filters.push("transpose=2"); - } - - if (recipe.framing === "fit") { - filters.push( - `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, - `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black` - ); - } else { - filters.push( - `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`, - `crop=${targetW}:${targetH}` - ); - } - if (recipe.sharpness !== 0) { - filters.push(`unsharp=5:5:${recipe.sharpness}:5:5:0.0`); - } - - // Normalize timestamps only when needed — trim or speed change both - // require a clean 0-based timeline to produce correct output duration. - if (recipe.trimStart > 0 || recipe.trimEnd !== null || recipe.speed !== 1) { - filters.push("setpts=PTS-STARTPTS"); - } - - if (recipe.speed !== 1) { - const pts = (1 / recipe.speed).toFixed(4); - filters.push(`setpts=${pts}*PTS`); - } - - if (recipe.denoise) { - filters.push("hqdn3d=1.5:1.5:6:6"); - } - - const needsEq = - recipe.brightness !== 0 || - recipe.contrast !== 1 || - recipe.saturation !== 1; - - if (needsEq) { - filters.push( - `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}` - ); - } - - // Add text overlays - const textOverlays = recipe.textOverlays || []; - textOverlays.forEach((overlay) => { - filters.push(buildTextFilter(overlay, targetW, targetH)); - }); - - return filters.join(","); -} - -export function buildAudioFilter(recipe: EditRecipe): string { - if (recipe.speed <= 0) return ""; - const filters: string[] = []; - - let remaining = recipe.speed; - while (remaining < 0.5) { - filters.push("atempo=0.5"); - remaining /= 0.5; - } - - while (remaining > 2.0) { - filters.push("atempo=2.0"); - remaining /= 2.0; - } - - if (Math.abs(remaining - 1.0) > 0.001) { - filters.push(`atempo=${Number(remaining.toFixed(4))}`); - } - - if (recipe.volume !== undefined && recipe.volume !== 100) { - filters.push(`volume=${(recipe.volume / 100).toFixed(2)}`); - } - - if (recipe.normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11"); - - return filters.join(","); -} - -function buildAudioTrimFilter(recipe: EditRecipe): string { - if (recipe.trimStart === 0 && recipe.trimEnd === null) return ""; - - // Avoid scanning entire audio with large placeholder values - // Use precise trim bounds when available - let trimFilter = `atrim=start=${recipe.trimStart}`; - if (recipe.trimEnd !== null) { - trimFilter += `:end=${recipe.trimEnd}`; - } - // asetpts normalizes timestamps after trim for correct stream positioning - return `${trimFilter},asetpts=PTS-STARTPTS`; -} - -function buildArguments( - recipe: EditRecipe, - format: "mp4" | "webm" | "mkv" | "gif", - outputName: string, - inputName: string, - targetW: number, - targetH: number, - hasMusicTrack: boolean, - musicInputName: string, - musicOptions: BackgroundMusicOptions | undefined, - hasOverlay: boolean, - overlayInputName: string, - overlayOptions: ImageOverlayOptions | undefined, - hasOriginalAudio: boolean, - videoDuration: number -): string[] { - const vf = buildVideoFilter(recipe, targetW, targetH); - const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : ""; - const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe) : ""; - const afParts = [audioTrim, audioSpeed].filter(Boolean); - const af = afParts.join(","); - - const musicIdx = 1; - const overlayIdx = hasMusicTrack ? 2 : 1; - - const args: string[] = []; - args.push("-i", inputName); - if (hasMusicTrack) { - if (musicOptions!.loopMusic) args.push("-stream_loop", "-1"); - args.push("-i", musicInputName); - } - if (hasOverlay) { - args.push("-i", overlayInputName); - } - - const needsFilterComplex = hasOverlay || hasMusicTrack; - const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack); - - if (needsFilterComplex) { - const filterParts: string[] = []; - let videoOut = "[0:v]"; - - if (vf) { - filterParts.push(`[0:v]${vf}[vbase]`); - videoOut = "[vbase]"; - } - -if (hasOverlay) { - const scaledW = overlayOptions!.size; - const alpha = (overlayOptions!.opacity / 100).toFixed(2); - const posMap: Record = { - "top-left": "20:20", - "top-right": "main_w-w-20:20", - "bottom-left": "20:main_h-h-20", - "bottom-right": "main_w-w-20:main_h-h-20", - }; - -interface PositionCoords { - x: number; - y: number; - } - - const pos = typeof overlayOptions?.position === "string" - ? (posMap[overlayOptions.position] ?? "main_w-w-20:main_h-h-20") - : overlayOptions?.position - ? `(main_w)*${(overlayOptions.position as PositionCoords).x}/100:(main_h)*${(overlayOptions.position as PositionCoords).y}/100` - : "main_w-w-20:main_h-h-20"; - - filterParts.push(`[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`); - filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`); - videoOut = "[vout]"; -} - - let audioOut = ""; - if (shouldKeepAudio) { - if (hasMusicTrack) { - const musicVol = (musicOptions!.musicVolume / 100).toFixed(2); - if (hasOriginalAudio) { - const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2); - const origChain = afParts.length > 0 - ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]` - : `[0:a]volume=${origVol}[orig]`; - filterParts.push(origChain); - filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`); - filterParts.push(`[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`); - audioOut = "[aout]"; - } else { - filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`); - audioOut = "[aout]"; - } - } else if (hasOriginalAudio && af) { - filterParts.push(`[0:a]${af}[aout]`); - audioOut = "[aout]"; - } - } - - if (filterParts.length > 0) { - args.push("-filter_complex", filterParts.join(";")); - } - args.push("-map", videoOut === "[0:v]" ? "0:v" : videoOut); - - if (!shouldKeepAudio) { - args.push("-an"); - } else if (audioOut) { - args.push("-map", audioOut); - } else if (hasOriginalAudio) { - args.push("-map", "0:a"); - } - } else { - if (vf) args.push("-vf", vf); - if (!shouldKeepAudio) { - args.push("-an"); - } else if (af && hasOriginalAudio) { - args.push("-af", af); - } - } - - if (format === "webm") { - args.push( - "-c:v", "libvpx-vp9", - "-b:v", "0", - "-crf", String(recipe.quality), - "-cpu-used", "4", - "-deadline", "realtime" - ); - if (shouldKeepAudio) args.push("-c:a", "libopus"); - } else if (format === "mkv") { - args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast"); - if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); - } else { - args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast", "-movflags", "+faststart"); - if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); - } - - // Add explicit output duration to prevent slight duration overshoot - // caused by encoder/filter pipeline frame flush at stream end, - // and to prevent infinite loops when adding looped audio to silent videos. - const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart; - const outputDuration = sourceDuration / recipe.speed; - args.push("-t", outputDuration.toFixed(6)); - - args.push(outputName); - return args; -} - export function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts index b0e33a683..4c17a2c44 100644 --- a/src/lib/ffmpeg.worker.ts +++ b/src/lib/ffmpeg.worker.ts @@ -2,7 +2,7 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { toBlobURL } from "@ffmpeg/util"; import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; import { getPresetById } from "./presets"; -import { buildTextFilter } from "./text-overlay"; +import { buildArguments, buildDurationArgs, buildVideoFilter } from "./exportGraph"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; // MT core aligned to the same version and distribution format as CORE_BASE_URL. @@ -92,249 +92,6 @@ async function fetchWithIntegrity(url: string, mimeType: string): Promise 0 || recipe.trimEnd !== null) { - const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999; - filters.push(`trim=start=${recipe.trimStart}:end=${end}`); - } - - if (recipe.stabilization) { - filters.push("deshake"); - } - - if (recipe.rotate === 90) { - filters.push("transpose=1"); - } else if (recipe.rotate === 180) { - filters.push("transpose=1,transpose=1"); - } else if (recipe.rotate === 270) { - filters.push("transpose=2"); - } - - if (recipe.framing === "fit") { - filters.push( - `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, - `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black` - ); - } else { - filters.push( - `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`, - `crop=${targetW}:${targetH}` - ); - } - - if (recipe.trimStart > 0 || recipe.trimEnd !== null || recipe.speed !== 1) { - filters.push("setpts=PTS-STARTPTS"); - } - - if (recipe.speed !== 1) { - const pts = (1 / recipe.speed).toFixed(4); - filters.push(`setpts=${pts}*PTS`); - } - - if (recipe.denoise) { - filters.push("hqdn3d=1.5:1.5:6:6"); - } - - const needsEq = - recipe.brightness !== 0 || - recipe.contrast !== 1 || - recipe.saturation !== 1; - - if (needsEq) { - filters.push( - `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}` - ); - } - - const textOverlays = recipe.textOverlays || []; - textOverlays.forEach((overlay) => { - filters.push(buildTextFilter(overlay, targetW, targetH)); - }); - - return filters.join(","); -} - -function buildAudioFilter(speed: number, normalizeAudio: boolean): string { - if (speed <= 0) return ""; - const filters: string[] = []; - - let remaining = speed; - while (remaining < 0.5) { - filters.push("atempo=0.5"); - remaining /= 0.5; - } - - while (remaining > 2.0) { - filters.push("atempo=2.0"); - remaining /= 2.0; - } - - if (Math.abs(remaining - 1.0) > 0.001) { - filters.push(`atempo=${Number(remaining.toFixed(4))}`); - } - - if (normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11"); - - return filters.join(","); -} - -function buildAudioTrimFilter(recipe: EditRecipe): string { - if (recipe.trimStart === 0 && recipe.trimEnd === null) return ""; - const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999; - return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`; -} - -function buildArguments( - recipe: EditRecipe, - format: "mp4" | "webm" | "mkv" | "gif", - outputName: string, - inputName: string, - targetW: number, - targetH: number, - hasMusicTrack: boolean, - musicInputName: string, - musicOptions: BackgroundMusicOptions | undefined, - hasOverlay: boolean, - overlayInputName: string, - overlayOptions: ImageOverlayOptions | undefined, - hasOriginalAudio: boolean, - videoDuration: number -): string[] { - const vf = buildVideoFilter(recipe, targetW, targetH); - const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : ""; - const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : ""; - const afParts = [audioTrim, audioSpeed].filter(Boolean); - const af = afParts.join(","); - - const musicIdx = 1; - const overlayIdx = hasMusicTrack ? 2 : 1; - - const args: string[] = []; - args.push("-i", inputName); - if (hasMusicTrack) { - if (musicOptions!.loopMusic) args.push("-stream_loop", "-1"); - args.push("-i", musicInputName); - } - if (hasOverlay) { - args.push("-i", overlayInputName); - } - - const needsFilterComplex = hasOverlay || hasMusicTrack; - const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack); - - if (needsFilterComplex) { - const filterParts: string[] = []; - let videoOut = "[0:v]"; - - if (vf) { - filterParts.push(`[0:v]${vf}[vbase]`); - videoOut = "[vbase]"; - } - -if (hasOverlay) { - const scaledW = overlayOptions!.size; - const alpha = (overlayOptions!.opacity / 100).toFixed(2); - const posMap: Record = { - "top-left": "20:20", - "top-right": "W-w-20:20", - "bottom-left": "20:H-h-20", - "bottom-right": "W-w-20:H-h-20", - }; - -interface PositionCoords { - x: number; - y: number; - } - - const pos = typeof overlayOptions?.position === "string" - ? (posMap[overlayOptions.position] ?? "W-w-20:H-h-20") - : overlayOptions?.position - ? `(W-w)*${(overlayOptions.position as PositionCoords).x}/100:(H-h)*${(overlayOptions.position as PositionCoords).y}/100` - : "W-w-20:H-h-20"; - - filterParts.push(`[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`); - filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`); - videoOut = "[vout]"; -} - - let audioOut = ""; - if (shouldKeepAudio) { - if (hasMusicTrack) { - const musicVol = (musicOptions!.musicVolume / 100).toFixed(2); - if (hasOriginalAudio) { - const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2); - const origChain = afParts.length > 0 - ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]` - : `[0:a]volume=${origVol}[orig]`; - filterParts.push(origChain); - filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`); - filterParts.push(`[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`); - audioOut = "[aout]"; - } else { - filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`); - audioOut = "[aout]"; - } - } else if (hasOriginalAudio && af) { - filterParts.push(`[0:a]${af}[aout]`); - audioOut = "[aout]"; - } - } - - if (filterParts.length > 0) { - args.push("-filter_complex", filterParts.join(";")); - } - args.push("-map", videoOut === "[0:v]" ? "0:v" : videoOut); - - if (!shouldKeepAudio) { - args.push("-an"); - } else if (audioOut) { - args.push("-map", audioOut); - } else if (hasOriginalAudio) { - args.push("-map", "0:a"); - } - } else { - if (vf) args.push("-vf", vf); - if (!shouldKeepAudio) { - args.push("-an"); - } else if (af && hasOriginalAudio) { - args.push("-af", af); - } - } - - if (format === "webm") { - args.push( - "-c:v", "libvpx-vp9", - "-b:v", "0", - "-crf", String(recipe.quality), - "-cpu-used", "4", - "-deadline", "realtime" - ); - if (shouldKeepAudio) args.push("-c:a", "libopus"); - } else if (format === "mkv") { - args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast"); - if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); - } else { - args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast", "-movflags", "+faststart"); - if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); - } - - if (recipe.speed !== 1) { - const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart; - // Skip -t when sourceDuration <= 0 (happens when video metadata fails to - // load and videoDuration falls back to 0). Passing -t 0 to FFmpeg produces - // a zero-length output file; omitting it lets FFmpeg encode the full stream. - if (sourceDuration > 0) { - const outputDuration = sourceDuration / recipe.speed; - args.push("-t", outputDuration.toFixed(6)); - } - } - - args.push(outputName); - return args; -} - async function loadCore(onProgress?: (percent: number) => void): Promise { if (ffmpegLoaded) { onProgress?.(100); @@ -465,16 +222,10 @@ async function runExport(request: ExportRequest): Promise { ? `[0:v]${vf}[x];[x][1:v]paletteuse` : "[0:v][1:v]paletteuse"; - const gifDurationArgs = recipe.speed !== 1 - ? (() => { - const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart; - // Skip -t when sourceDuration <= 0 (metadata load failure fallback) - // to avoid producing a zero-length GIF. - if (sourceDuration <= 0) return []; - const outputDuration = sourceDuration / recipe.speed; - return ["-t", outputDuration.toFixed(6)]; - })() - : []; + // GIF only needs -t for speed changes; the shared helper also rejects the + // unknown-duration cases (0 from failed metadata, Infinity from containers + // that report no duration) that would otherwise abort the export. + const gifDurationArgs = recipe.speed !== 1 ? buildDurationArgs(recipe, videoDuration) : []; const pass1Code = await ffmpeg.exec( ["-i", inputName, "-vf", vfWithPalette, ...gifDurationArgs, "-y", paletteName], diff --git a/src/lib/tests/exportGraph.test.ts b/src/lib/tests/exportGraph.test.ts new file mode 100644 index 000000000..223585368 --- /dev/null +++ b/src/lib/tests/exportGraph.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from "vitest"; +import { + buildArguments, + buildAudioFilter, + buildAudioTrimFilter, + buildDurationArgs, + buildVideoFilter, +} from "../exportGraph"; +import { DEFAULT_RECIPE } from "../constants"; +import { BackgroundMusicOptions, EditRecipe, ImageOverlayOptions } from "../types"; + +const MUSIC: BackgroundMusicOptions = { + file: null, + musicVolume: 70, + originalAudioVolume: 100, + loopMusic: false, +}; + +const OVERLAY: ImageOverlayOptions = { + file: null, + position: "bottom-right", + size: 200, + opacity: 100, +}; + +interface ArgsOverrides { + recipe?: Partial; + format?: "mp4" | "webm" | "mkv" | "gif"; + hasMusicTrack?: boolean; + musicOptions?: BackgroundMusicOptions; + hasOverlay?: boolean; + overlayOptions?: ImageOverlayOptions; + hasOriginalAudio?: boolean; + videoDuration?: number; +} + +/** The worker's call shape: one recipe, every branch flag overridable. */ +function build(overrides: ArgsOverrides = {}): string[] { + const recipe = { ...DEFAULT_RECIPE, ...(overrides.recipe ?? {}) } as EditRecipe; + return buildArguments( + recipe, + overrides.format ?? "mp4", + "output.mp4", + "input.mp4", + 1080, + 1920, + overrides.hasMusicTrack ?? false, + "music_input.mp3", + overrides.musicOptions, + overrides.hasOverlay ?? false, + "overlay.png", + overrides.overlayOptions, + overrides.hasOriginalAudio ?? true, + overrides.videoDuration ?? 10 + ); +} + +function valueAfter(args: string[], flag: string): string | null { + const index = args.indexOf(flag); + return index === -1 ? null : args[index + 1] ?? null; +} + +function filterComplex(args: string[]): string { + return valueAfter(args, "-filter_complex") ?? ""; +} + +const recipe = (patch: Partial) => ({ ...DEFAULT_RECIPE, ...patch } as EditRecipe); + +describe("export graph: video filters", () => { + it("applies the Sharpness setting with unsharp", () => { + expect(buildVideoFilter(recipe({ sharpness: 2 }), 1080, 1920)).toContain("unsharp=5:5:2:5:5:0.0"); + }); + + it("omits unsharp when sharpness is at its default", () => { + expect(buildVideoFilter(recipe({ sharpness: 0 }), 1080, 1920)).not.toContain("unsharp"); + }); + + it("trims to the end of the stream without a placeholder bound", () => { + const filter = buildVideoFilter(recipe({ trimStart: 5 }), 1080, 1920); + expect(filter).toContain("trim=start=5"); + expect(filter).not.toContain("999999"); + }); +}); + +describe("export graph: audio filters", () => { + it("applies the Volume setting to the original audio", () => { + expect(buildAudioFilter(recipe({ volume: 50 }))).toBe("volume=0.50"); + expect(buildAudioFilter(recipe({ volume: 200 }))).toBe("volume=2.00"); + expect(buildAudioFilter(recipe({ volume: 0 }))).toBe("volume=0.00"); + }); + + it("leaves the chain untouched at the default volume", () => { + expect(buildAudioFilter(recipe({ volume: 100 }))).toBe(""); + }); + + it("combines speed and volume", () => { + expect(buildAudioFilter(recipe({ speed: 2, volume: 200 }))).toBe("atempo=2,volume=2.00"); + }); + + it("keeps normalization after the manual volume", () => { + expect(buildAudioFilter(recipe({ volume: 50, normalizeAudio: true }))).toBe( + "volume=0.50,loudnorm=I=-14:TP=-1.5:LRA=11" + ); + }); + + it("trims audio without a placeholder bound", () => { + expect(buildAudioTrimFilter(recipe({}))).toBe(""); + expect(buildAudioTrimFilter(recipe({ trimStart: 5 }))).toBe("atrim=start=5,asetpts=PTS-STARTPTS"); + expect(buildAudioTrimFilter(recipe({ trimStart: 2, trimEnd: 8 }))).toBe( + "atrim=start=2:end=8,asetpts=PTS-STARTPTS" + ); + }); +}); + +describe("export graph: duration arguments", () => { + it("rejects durations that cannot be trusted", () => { + expect(buildDurationArgs(recipe({}), 0)).toEqual([]); + expect(buildDurationArgs(recipe({}), Infinity)).toEqual([]); + expect(buildDurationArgs(recipe({}), NaN)).toEqual([]); + }); + + it("divides the trimmed range by the playback speed", () => { + expect(buildDurationArgs(recipe({}), 12)).toEqual(["-t", "12.000000"]); + expect(buildDurationArgs(recipe({ trimStart: 2, trimEnd: 8, speed: 2 }), 30)).toEqual(["-t", "3.000000"]); + }); +}); + +describe("export graph: output argv", () => { + it("passes the volume setting through to the encoded audio", () => { + expect(valueAfter(build({ recipe: { volume: 50 } }), "-af")).toBe("volume=0.50"); + }); + + it("adds no audio filter at the default volume", () => { + expect(build()).not.toContain("-af"); + }); + + it("caps the output at the video duration", () => { + expect(valueAfter(build({ videoDuration: 10 }), "-t")).toBe("10.000000"); + }); + + it("caps trimmed and sped-up output at the expected duration", () => { + const args = build({ recipe: { trimStart: 2, trimEnd: 8, speed: 2 }, videoDuration: 30 }); + expect(valueAfter(args, "-t")).toBe("3.000000"); + expect(valueAfter(args, "-vf")).toContain("trim=start=2:end=8"); + expect(args[args.length - 1]).toBe("output.mp4"); + }); + + it("omits -t when the duration cannot be trusted", () => { + // 0 = metadata failed to load, Infinity = container reports no duration + // (MediaRecorder WebM). `-t Infinity` aborts the export outright. + expect(build({ videoDuration: 0 })).not.toContain("-t"); + expect(build({ videoDuration: Infinity })).not.toContain("-t"); + expect(build({ videoDuration: NaN })).not.toContain("-t"); + }); + + it("caps the duration from an explicit trim even when the total is unknown", () => { + const args = build({ recipe: { trimStart: 1, trimEnd: 6 }, videoDuration: Infinity }); + expect(valueAfter(args, "-t")).toBe("5.000000"); + }); + + it("caps a looped music export so it cannot outrun the video (#1493)", () => { + const args = build({ + hasMusicTrack: true, + musicOptions: { ...MUSIC, loopMusic: true }, + hasOriginalAudio: false, + videoDuration: 10, + }); + expect(valueAfter(args, "-stream_loop")).toBe("-1"); + expect(filterComplex(args)).toContain("[1:a]volume=0.70[aout]"); + expect(valueAfter(args, "-t")).toBe("10.000000"); + }); + + it("mixes original audio and music at their configured volumes", () => { + const args = build({ + hasMusicTrack: true, + musicOptions: { ...MUSIC, originalAudioVolume: 40 }, + }); + const graph = filterComplex(args); + expect(graph).toContain("[0:a]volume=0.40[orig]"); + expect(graph).toContain("[1:a]volume=0.70[music]"); + expect(graph).toContain("amix=inputs=2:duration=first:dropout_transition=0[aout]"); + expect(valueAfter(args, "-map")).toBe("[vbase]"); + }); + + it("drops audio entirely when the recipe is muted", () => { + const args = build({ recipe: { keepAudio: false } }); + expect(args).toContain("-an"); + expect(args).not.toContain("-af"); + expect(args).not.toContain("-c:a"); + }); + + it("drops audio when the source is silent and no music is attached", () => { + const args = build({ hasOriginalAudio: false }); + expect(args).toContain("-an"); + expect(args).not.toContain("-af"); + }); + + it("keeps valid overlay positioning variables", () => { + const graph = filterComplex(build({ hasOverlay: true, overlayOptions: OVERLAY })); + expect(graph).toContain("overlay=W-w-20:H-h-20"); + expect(graph).not.toContain("main_w"); + }); + + it("uses the audio codec that matches the container", () => { + expect(valueAfter(build({ format: "webm" }), "-c:a")).toBe("libopus"); + expect(valueAfter(build({ format: "mkv" }), "-c:a")).toBe("aac"); + expect(valueAfter(build({ format: "mp4" }), "-c:a")).toBe("aac"); + }); +});