Skip to content
35 changes: 20 additions & 15 deletions src/lib/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, string> = {
"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]`;
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string>([inputName, outputName, fallbackOutputName, paletteName]);
Expand Down Expand Up @@ -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);
Expand All @@ -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`;
Expand Down
17 changes: 9 additions & 8 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down