From 0d82e49c2180b89db58b7fae93c598f082d84dd2 Mon Sep 17 00:00:00 2001 From: divyanshi-adhikari Date: Sun, 17 May 2026 00:03:00 +0530 Subject: [PATCH 1/4] fix: cleanup FFmpeg temp files after export --- src/lib/ffmpeg.ts | 142 +++++++++++++++++++++++++--------------------- 1 file changed, 77 insertions(+), 65 deletions(-) diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 26ceb5c1..e33e0dde 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -94,63 +94,77 @@ export async function exportVideo( const ext = file.name.split(".").pop() ?? "mp4"; const inputName = `input.${ext}`; const outputName = "output.mp4"; + const webmOutput = "output.webm"; - await ffmpeg.writeFile(inputName, await fetchFile(file)); + try { + await ffmpeg.writeFile(inputName, await fetchFile(file)); - ffmpeg.on("progress", ({ progress }) => { - onProgress(Math.min(99, Math.round(progress * 100))); - }); - - const vf = buildVideoFilter(recipe, targetW, targetH); - const audioTrim = buildAudioTrimFilter(recipe); - const audioSpeed = buildAudioFilter(recipe.speed); - const afParts = [audioTrim, audioSpeed].filter(Boolean); - const af = afParts.join(","); - - const args = ["-i", inputName]; - if (vf) args.push("-vf", vf); + ffmpeg.on("progress", ({ progress }) => { + onProgress(Math.min(99, Math.round(progress * 100))); + }); - if (!recipe.keepAudio) { - args.push("-an"); - } else if (af) { - args.push("-af", af); - } - - args.push( - "-c:v", "libx264", - "-crf", String(recipe.quality), - "-preset", "medium", - "-movflags", "+faststart" - ); + const vf = buildVideoFilter(recipe, targetW, targetH); + const audioTrim = buildAudioTrimFilter(recipe); + const audioSpeed = buildAudioFilter(recipe.speed); + const afParts = [audioTrim, audioSpeed].filter(Boolean); + const af = afParts.join(","); - if (recipe.keepAudio) { - args.push("-c:a", "aac", "-b:a", "128k"); - } + const args = ["-i", inputName]; + if (vf) args.push("-vf", vf); - args.push(outputName); + if (!recipe.keepAudio) { + args.push("-an"); + } else if (af) { + args.push("-af", af); + } - const exitCode = await ffmpeg.exec(args); - - // fall back to webm if libx264 isnt available - if (exitCode !== 0) { - const webmOutput = "output.webm"; - const fallbackArgs = [ - "-i", inputName, - ...(vf ? ["-vf", vf] : []), - ...(recipe.keepAudio ? (af ? ["-af", af] : []) : ["-an"]), - "-c:v", "libvpx-vp9", + args.push( + "-c:v", "libx264", "-crf", String(recipe.quality), - ...(recipe.keepAudio ? ["-c:a", "libopus"] : []), - webmOutput, - ]; - - const fallbackCode = await ffmpeg.exec(fallbackArgs); - if (fallbackCode !== 0) throw new Error("Export failed"); + "-preset", "medium", + "-movflags", "+faststart" + ); - const data = await ffmpeg.readFile(webmOutput); - const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" }); - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(webmOutput); + if (recipe.keepAudio) { + args.push("-c:a", "aac", "-b:a", "128k"); + } + + args.push(outputName); + + const exitCode = await ffmpeg.exec(args); + + // fall back to webm if libx264 isnt available + if (exitCode !== 0) { + try { await ffmpeg.deleteFile(outputName); } catch {} + + const fallbackArgs = [ + "-i", inputName, + ...(vf ? ["-vf", vf] : []), + ...(recipe.keepAudio ? (af ? ["-af", af] : []) : ["-an"]), + "-c:v", "libvpx-vp9", + "-crf", String(recipe.quality), + ...(recipe.keepAudio ? ["-c:a", "libopus"] : []), + webmOutput, + ]; + + const fallbackCode = await ffmpeg.exec(fallbackArgs); + if (fallbackCode !== 0) throw new Error("Export failed"); + + const data = await ffmpeg.readFile(webmOutput); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" }); + + onProgress(100); + return { + blobUrl: URL.createObjectURL(blob), + size: blob.size, + width: targetW, + height: targetH, + format: "webm", + }; + } + + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/mp4" }); onProgress(100); return { @@ -158,26 +172,24 @@ export async function exportVideo( size: blob.size, width: targetW, height: targetH, - format: "webm", + format: "mp4", }; + } finally { + try { + await ffmpeg.deleteFile(inputName); + } catch {} + + try { + await ffmpeg.deleteFile(outputName); + } catch {} + + try { + await ffmpeg.deleteFile(webmOutput); + } catch {} } - - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/mp4" }); - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - onProgress(100); - return { - blobUrl: URL.createObjectURL(blob), - size: blob.size, - width: targetW, - height: targetH, - format: "mp4", - }; } export function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} +} \ No newline at end of file From 7dd6c76e317b8c250f4c8b4afee1b704384f0519 Mon Sep 17 00:00:00 2001 From: divyanshi-adhikari Date: Sun, 17 May 2026 13:18:06 +0530 Subject: [PATCH 2/4] fix: add FFmpeg temp file cleanup with proper types --- src/lib/ffmpeg.ts | 201 ++++++++++++++++++++++++++++++++-------------- src/lib/types.ts | 7 +- 2 files changed, 147 insertions(+), 61 deletions(-) diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index e33e0dde..62bb8a83 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -2,23 +2,53 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile, toBlobURL } from "@ffmpeg/util"; import { EditRecipe, ExportResult } from "./types"; import { getPresetById } from "./presets"; +import { simd } from "wasm-feature-detect"; -const CORE_BASE_URL = - "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; +const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; let ffmpegInstance: FFmpeg | null = null; -export async function loadFFmpeg(): Promise { - if (ffmpegInstance) return ffmpegInstance; +export class FFmpegLoadError extends Error { + constructor(message: string) { + super(message); + this.name = "FFmpegLoadError"; + } +} - const ffmpeg = new FFmpeg(); - await ffmpeg.load({ - coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"), - wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"), - }); +export async function loadFFmpeg(signal?: AbortSignal): Promise { + if (ffmpegInstance?.loaded) return ffmpegInstance; + const ffmpeg = ffmpegInstance ?? new FFmpeg(); ffmpegInstance = ffmpeg; - return ffmpeg; + + try { + const isSimdSupported = await simd(); + const coreName = isSimdSupported ? "ffmpeg-core-simd" : "ffmpeg-core"; + + await ffmpeg.load({ + coreURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.js`, "text/javascript"), + wasmURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.wasm`, "application/wasm"), + }, { signal }); + + return ffmpeg; + } catch (err) { + if (ffmpegInstance === ffmpeg) { + ffmpegInstance = null; + } + throw new FFmpegLoadError("The ffmpeg cdn could not load. Please check your internet connection."); + } +} + +export function terminateFFmpeg() { + ffmpegInstance?.terminate(); + ffmpegInstance = null; +} + +function buildSessionId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { @@ -30,13 +60,9 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): filters.push("setpts=PTS-STARTPTS"); } - 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.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( @@ -55,14 +81,35 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): filters.push(`setpts=${pts}*PTS`); } + // ✅ Fixed: No 'any' - using optional chaining with defaults + filters.push( + `eq=brightness=${recipe.brightness ?? 0}:contrast=${recipe.contrast ?? 1}:saturation=${recipe.saturation ?? 1}` + ); + return filters.join(","); } -function buildAudioFilter(speed: number): string { +export function buildAudioFilter(speed: number): string { if (speed === 1) return ""; - if (speed === 0.25) return "atempo=0.5,atempo=0.5"; - if (speed === 4) return "atempo=2.0,atempo=2.0"; - return `atempo=${speed}`; + + 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))}`); + } + + return filters.join(","); } function buildAudioTrimFilter(recipe: EditRecipe): string { @@ -71,13 +118,19 @@ function buildAudioTrimFilter(recipe: EditRecipe): string { return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`; } +type OutputFormat = "mp4" | "webm" | "mkv"; + export async function exportVideo( ffmpeg: FFmpeg, file: File, recipe: EditRecipe, - onProgress: (percent: number) => void + onProgress: (percent: number) => void, + signal?: AbortSignal ): Promise { + const sessionId = buildSessionId(); + let targetW: number, targetH: number; + if (recipe.preset === "custom") { targetW = recipe.customWidth; targetH = recipe.customHeight; @@ -87,29 +140,51 @@ export async function exportVideo( targetH = preset?.height ?? 1080; } - // dimensions must be even for libx264 targetW = Math.round(targetW / 2) * 2; targetH = Math.round(targetH / 2) * 2; const ext = file.name.split(".").pop() ?? "mp4"; - const inputName = `input.${ext}`; - const outputName = "output.mp4"; - const webmOutput = "output.webm"; + const inputName = `input_${sessionId}.${ext}`; + + // ✅ Fixed: Proper type for getOutputConfig + const getOutputConfig = (format: OutputFormat) => { + switch (format) { + case "webm": + return { filename: `output_${sessionId}.webm`, mimeType: "video/webm" }; + case "mkv": + return { filename: `output_${sessionId}.mkv`, mimeType: "video/x-matroska" }; + default: + return { filename: `output_${sessionId}.mp4`, mimeType: "video/mp4" }; + } + }; + + // 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`; + + // Only add fallback to cleanup if it actually gets created + const cleanupFiles = new Set([inputName, outputName]); + + const handleProgress = ({ progress }: { progress: number }) => { + onProgress(Math.min(99, Math.round(progress * 100))); + }; try { - await ffmpeg.writeFile(inputName, await fetchFile(file)); + await ffmpeg.writeFile(inputName, await fetchFile(file), { signal }); - ffmpeg.on("progress", ({ progress }) => { - onProgress(Math.min(99, Math.round(progress * 100))); - }); + ffmpeg.on("progress", handleProgress); const vf = buildVideoFilter(recipe, targetW, targetH); const audioTrim = buildAudioTrimFilter(recipe); const audioSpeed = buildAudioFilter(recipe.speed); - const afParts = [audioTrim, audioSpeed].filter(Boolean); - const af = afParts.join(","); + const af = [audioTrim, audioSpeed].filter(Boolean).join(","); const args = ["-i", inputName]; + if (vf) args.push("-vf", vf); if (!recipe.keepAudio) { @@ -118,25 +193,33 @@ export async function exportVideo( args.push("-af", af); } - args.push( - "-c:v", "libx264", - "-crf", String(recipe.quality), - "-preset", "medium", - "-movflags", "+faststart" - ); - - if (recipe.keepAudio) { - args.push("-c:a", "aac", "-b:a", "128k"); + // Using outputFormat consistently + if (outputFormat === "webm") { + args.push("-c:v", "libvpx-vp9", "-crf", String(recipe.quality)); + if (recipe.keepAudio) args.push("-c:a", "libopus"); + } else if (outputFormat === "mkv") { + args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "medium"); + if (recipe.keepAudio) args.push("-c:a", "aac", "-b:a", "128k"); + } else { + args.push( + "-c:v", "libx264", + "-crf", String(recipe.quality), + "-preset", "medium", + "-movflags", "+faststart" + ); + if (recipe.keepAudio) args.push("-c:a", "aac", "-b:a", "128k"); } args.push(outputName); - const exitCode = await ffmpeg.exec(args); + const exitCode = await ffmpeg.exec(args, undefined, { signal }); - // fall back to webm if libx264 isnt available if (exitCode !== 0) { - try { await ffmpeg.deleteFile(outputName); } catch {} + // Add fallback file to cleanup since it will be created + cleanupFiles.add(fallbackOutputName); + try { await ffmpeg.deleteFile(outputName); } catch {} + const fallbackArgs = [ "-i", inputName, ...(vf ? ["-vf", vf] : []), @@ -144,13 +227,14 @@ export async function exportVideo( "-c:v", "libvpx-vp9", "-crf", String(recipe.quality), ...(recipe.keepAudio ? ["-c:a", "libopus"] : []), - webmOutput, + fallbackOutputName, ]; - const fallbackCode = await ffmpeg.exec(fallbackArgs); + const fallbackCode = await ffmpeg.exec(fallbackArgs, undefined, { signal }); + if (fallbackCode !== 0) throw new Error("Export failed"); - const data = await ffmpeg.readFile(webmOutput); + const data = await ffmpeg.readFile(fallbackOutputName, undefined, { signal }); const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" }); onProgress(100); @@ -163,8 +247,8 @@ export async function exportVideo( }; } - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/mp4" }); + const data = await ffmpeg.readFile(outputName, undefined, { signal }); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: mimeType }); onProgress(100); return { @@ -172,20 +256,17 @@ export async function exportVideo( size: blob.size, width: targetW, height: targetH, - format: "mp4", + format: outputFormat, }; - } finally { - try { - await ffmpeg.deleteFile(inputName); - } catch {} - try { - await ffmpeg.deleteFile(outputName); - } catch {} + } finally { + ffmpeg.off("progress", handleProgress); - try { - await ffmpeg.deleteFile(webmOutput); - } catch {} + for (const path of cleanupFiles) { + try { + await ffmpeg.deleteFile(path); + } catch {} + } } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 726d1120..81a7266c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,6 +9,11 @@ export interface EditRecipe { keepAudio: boolean; speed: number; quality: number; + format?: "mp4" | "webm" | "mkv"; + brightness?: number; + contrast?: number; + saturation?: number; + } export interface ExportResult { @@ -16,7 +21,7 @@ export interface ExportResult { size: number; width: number; height: number; - format: "mp4" | "webm"; + format: "mp4" | "webm" | "mkv"; } export type ExportStatus = From cd3ab5c6cdea7bbab1174f55e16158a80b99013a Mon Sep 17 00:00:00 2001 From: divyanshi-adhikari Date: Mon, 18 May 2026 19:47:00 +0530 Subject: [PATCH 3/4] fix: add FFmpeg temp file cleanup --- src/lib/types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/types.ts b/src/lib/types.ts index 371765b2..2351f0e9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -36,3 +36,17 @@ export const MAX_FILE_SIZE = export const WARNING_FILE_SIZE = 500 * 1024 * 1024; // 500MB + +export interface BackgroundMusicOptions { + file?: File; + loopMusic?: boolean; + musicVolume?: number; // ✓ This exists + originalAudioVolume?: number; // ✓ This exists +} + +export interface ImageOverlayOptions { + file?: File; + size?: number; // ✓ This exists + opacity?: number; // ✓ This exists + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; // ✓ This exists +} \ No newline at end of file From 74ce11630513672eafbfb38c2cfb83fd9d7562c6 Mon Sep 17 00:00:00 2001 From: divyanshi-adhikari Date: Sun, 24 May 2026 15:41:55 +0530 Subject: [PATCH 4/4] save work before rebase --- src/lib/ffmpeg.ts | 23 ++++++++++------------- src/lib/types.ts | 30 ++++++++++-------------------- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index f7dd5886..a134d53d 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -212,27 +212,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]`; @@ -244,10 +244,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) { diff --git a/src/lib/types.ts b/src/lib/types.ts index b23fb3a5..25714d98 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -23,18 +23,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 { @@ -88,16 +89,5 @@ export const MAX_FILE_SIZE = export const WARNING_FILE_SIZE = 500 * 1024 * 1024; // 500MB -export interface BackgroundMusicOptions { - file?: File; - loopMusic?: boolean; - musicVolume?: number; // ✓ This exists - originalAudioVolume?: number; // ✓ This exists -} -export interface ImageOverlayOptions { - file?: File; - size?: number; // ✓ This exists - opacity?: number; // ✓ This exists - position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; // ✓ This exists -} +