diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index f28aaa0e..707d2955 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -91,6 +91,7 @@ export function terminateFFmpeg() { ffmpegInstance = null; } +/** Generates a unique session ID used to isolate FFmpeg file names across concurrent exports. */ function buildSessionId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); @@ -195,6 +196,7 @@ function buildAudioTrimFilter(recipe: EditRecipe): string { return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`; } +type OutputFormat = "mp4" | "webm" | "mkv"; function buildArguments( recipe: EditRecipe, format: "mp4" | "webm" | "mkv" | "gif", @@ -242,27 +244,27 @@ function buildArguments( videoOut = "[vbase]"; } - if (hasOverlay) { - const scaledW = overlayOptions!.size; - const alpha = (overlayOptions!.opacity / 100).toFixed(2); + if (hasOverlay && overlayOptions) { + const scaledW = overlayOptions.size ?? 100; + const alpha = ((overlayOptions.opacity ?? 100) / 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", }; - const pos = posMap[overlayOptions!.position] ?? "W-w-20:H-h-20"; + const pos = overlayOptions.position ? posMap[overlayOptions.position] : "W-w-20:H-h-20"; + const finalPos = pos ?? "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]`); + filterParts.push(`${videoOut}[logo]overlay=${finalPos}[vout]`); videoOut = "[vout]"; } let audioOut = ""; - if (shouldKeepAudio) { - if (hasMusicTrack) { - const musicVol = (musicOptions!.musicVolume / 100).toFixed(2); + if (hasMusicTrack && musicOptions) { + const musicVol = ((musicOptions.musicVolume ?? 100) / 100).toFixed(2); if (hasOriginalAudio) { - const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2); + const origVol = ((musicOptions.originalAudioVolume ?? 100) / 100).toFixed(2); const origChain = afParts.length > 0 ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]` : `[0:a]volume=${origVol}[orig]`; @@ -274,10 +276,7 @@ function buildArguments( 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) { @@ -369,7 +368,12 @@ export async function exportVideo( } }; - const { filename: outputName, mimeType } = getOutputConfig(recipe.format); + // Format handling - single source of truth + const outputFormat: OutputFormat = + recipe.format === "webm" ? "webm" : + recipe.format === "mkv" ? "mkv" : "mp4"; + + const { filename: outputName, mimeType } = getOutputConfig(outputFormat); const fallbackOutputName = `fallback_${sessionId}.webm`; const paletteName = `palette_${sessionId}.png`; const cleanupFiles = new Set([inputName, outputName, fallbackOutputName, paletteName]); @@ -539,7 +543,7 @@ export async function exportVideo( size: blob.size, width: targetW, height: targetH, - format: recipe.format as "mp4" | "webm" | "mkv", + format: outputFormat, }; } finally { ffmpeg.off("progress", handleProgress); @@ -551,6 +555,7 @@ export async function exportVideo( } } +/** Formats a byte count as a human-readable string (KB or MB). */ 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/types.ts b/src/lib/types.ts index 30cab3aa..3c61c58c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -42,18 +42,19 @@ export type OverlayPosition = | "bottom-left" | "bottom-right"; + export interface ImageOverlayOptions { - file: File | null; - position: OverlayPosition; - size: number; - opacity: number; + file?: File | null; + position?: OverlayPosition; + size?: number; + opacity?: number; } export interface BackgroundMusicOptions { - file: File | null; - musicVolume: number; - originalAudioVolume: number; - loopMusic: boolean; + file?: File | null; + musicVolume?: number; + originalAudioVolume?: number; + loopMusic?: boolean; } export interface ExportResult {