From 9a4f6bc17e33d42ec563ef72031d8729d8ba886d Mon Sep 17 00:00:00 2001 From: jayesh durge <179298187+jayesh-durge@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:38:55 +0530 Subject: [PATCH] fix: export text overlays reliably --- src/lib/ffmpeg.ts | 86 ++++++++++++++++++++++++++++++- src/lib/ffmpeg.worker.ts | 71 ++++++++++++++++++++++--- src/lib/tests/textOverlay.test.ts | 38 ++++++++++++++ src/lib/text-overlay.ts | 33 +++++------- 4 files changed, 199 insertions(+), 29 deletions(-) create mode 100644 src/lib/tests/textOverlay.test.ts diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 625387d2..28075a9f 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,6 +1,7 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; import { getPresetById } from "./presets"; import { buildTextFilter } from "./text-overlay"; +import { ensureFontLoaded, getFontFamily } from "@/utils/fontLoader"; export class FFmpegLoadError extends Error {} @@ -21,6 +22,7 @@ type WorkerExportRequest = { musicOptions?: BackgroundMusicOptions; overlayFile?: SerializedFile; overlayOptions?: ImageOverlayOptions; + textOverlayFile?: SerializedFile; }; type WorkerLoadResponse = { type: "ready" }; @@ -222,6 +224,19 @@ export async function exportVideo( } const sessionId = buildSessionId(); + const { width: targetW, height: targetH } = getRecipeOutputSize(recipe); + const hasTextOverlays = (recipe.textOverlays || []).some((overlay) => overlay.text.trim()); + const textOverlayFilePayload = hasTextOverlays + ? await renderTextOverlayFile(recipe, targetW, targetH, sessionId) + : undefined; + + if (hasTextOverlays && !textOverlayFilePayload) { + throw new Error("Text overlays could not be rendered for export."); + } + + const exportRecipe = hasTextOverlays + ? { ...recipe, textOverlays: [] } + : recipe; const arrayBuffer = await file.arrayBuffer(); const filePayload: SerializedFile = { name: file.name, @@ -273,18 +288,20 @@ export async function exportVideo( const transfers: Transferable[] = [arrayBuffer]; if (musicFilePayload) transfers.push(musicFilePayload.data); if (overlayFilePayload) transfers.push(overlayFilePayload.data); + if (textOverlayFilePayload) transfers.push(textOverlayFilePayload.data); ffmpegWorker.postMessage( { type: "export", id: sessionId, file: filePayload, - recipe, + recipe: exportRecipe, videoDuration: await getVideoDuration(file), musicFile: musicFilePayload, musicOptions: sanitizedMusicOptions, overlayFile: overlayFilePayload, overlayOptions: sanitizedOverlayOptions, + textOverlayFile: textOverlayFilePayload, } as WorkerExportRequest, transfers ); @@ -325,6 +342,73 @@ function buildSessionId(): string { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } +function getRecipeOutputSize(recipe: EditRecipe): { width: number; height: number } { + let width: number; + let height: number; + + if (recipe.preset === "custom") { + width = recipe.customWidth; + height = recipe.customHeight; + } else { + const preset = getPresetById(recipe.preset); + width = preset?.width ?? 1920; + height = preset?.height ?? 1080; + } + + return { + width: Math.round(width / 2) * 2, + height: Math.round(height / 2) * 2, + }; +} + +async function renderTextOverlayFile( + recipe: EditRecipe, + targetW: number, + targetH: number, + sessionId: string +): Promise { + const textOverlays = (recipe.textOverlays || []).filter((overlay) => overlay.text.trim()); + if (textOverlays.length === 0) return undefined; + + await Promise.all( + textOverlays.map((overlay) => ensureFontLoaded(overlay.fontFamily, overlay.fontSize)) + ); + + const canvas = document.createElement("canvas"); + canvas.width = targetW; + canvas.height = targetH; + const ctx = canvas.getContext("2d"); + if (!ctx) return undefined; + + ctx.clearRect(0, 0, targetW, targetH); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + textOverlays.forEach((overlay) => { + const x = (overlay.x / 100) * targetW; + const y = (overlay.y / 100) * targetH; + const weight = + overlay.fontWeight === "900" ? 900 : overlay.fontWeight === "bold" ? 700 : 400; + + ctx.font = `${weight} ${overlay.fontSize}px ${getFontFamily(overlay.fontFamily)}`; + ctx.fillStyle = overlay.color; + ctx.shadowColor = "rgba(0, 0, 0, 0.8)"; + ctx.shadowBlur = 8; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 2; + ctx.fillText(overlay.text, x, y); + }); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!blob) return undefined; + + return { + name: `text_overlay_${sessionId}.png`, + type: "image/png", + data: await blob.arrayBuffer(), + }; +} + export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { const filters: string[] = []; diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts index f72ef583..4d0947a8 100644 --- a/src/lib/ffmpeg.worker.ts +++ b/src/lib/ffmpeg.worker.ts @@ -27,6 +27,7 @@ type ExportRequest = { musicOptions?: BackgroundMusicOptions; overlayFile?: SerializedFile; overlayOptions?: ImageOverlayOptions; + textOverlayFile?: SerializedFile; }; type LoadRequest = { type: "load" }; @@ -186,6 +187,8 @@ function buildArguments( hasOverlay: boolean, overlayInputName: string, overlayOptions: ImageOverlayOptions | undefined, + hasTextOverlay: boolean, + textOverlayInputName: string, hasOriginalAudio: boolean, videoDuration: number ): string[] { @@ -196,7 +199,8 @@ function buildArguments( const af = afParts.join(","); const musicIdx = 1; - const overlayIdx = hasMusicTrack ? 2 : 1; + const overlayIdx = 1 + (hasMusicTrack ? 1 : 0); + const textOverlayIdx = 1 + (hasMusicTrack ? 1 : 0) + (hasOverlay ? 1 : 0); const args: string[] = []; args.push("-i", inputName); @@ -207,8 +211,11 @@ function buildArguments( if (hasOverlay) { args.push("-i", overlayInputName); } + if (hasTextOverlay) { + args.push("-i", textOverlayInputName); + } - const needsFilterComplex = hasOverlay || hasMusicTrack; + const needsFilterComplex = hasOverlay || hasTextOverlay || hasMusicTrack; const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack); if (needsFilterComplex) { @@ -246,6 +253,12 @@ interface PositionCoords { videoOut = "[vout]"; } + if (hasTextOverlay) { + filterParts.push(`[${textOverlayIdx}:v]format=rgba[textlayer]`); + filterParts.push(`${videoOut}[textlayer]overlay=0:0[vtext]`); + videoOut = "[vtext]"; + } + let audioOut = ""; if (shouldKeepAudio) { if (hasMusicTrack) { @@ -429,6 +442,15 @@ async function runExport(request: ExportRequest): Promise { }); } + const hasTextOverlay = !!request.textOverlayFile; + const textOverlayInputName = `text_overlay_${sessionId}.png`; + if (hasTextOverlay) { + cleanupFiles.add(textOverlayInputName); + await ffmpeg.writeFile(textOverlayInputName, serializeFileBuffer(request.textOverlayFile!), { + signal: activeExportAbortController?.signal, + }); + } + const videoDuration = request.videoDuration; const handleProgress = ({ progress }: { progress: number }) => { @@ -442,10 +464,13 @@ async function runExport(request: ExportRequest): Promise { try { if (recipe.format === "gif") { const vf = buildVideoFilter(recipe, targetW, targetH); - const vfWithPalette = vf ? `${vf},palettegen` : "palettegen"; - const vfWithPaletteUse = vf - ? `[0:v]${vf}[x];[x][1:v]paletteuse` - : "[0:v][1:v]paletteuse"; + const baseVideo = vf ? `[0:v]${vf}[vbase]` : "[0:v]null[vbase]"; + const gifVideoForPalette = hasTextOverlay + ? `${baseVideo};[1:v]format=rgba[textlayer];[vbase][textlayer]overlay=0:0[gifbase];[gifbase]palettegen[paletteout]` + : `${baseVideo};[vbase]palettegen[paletteout]`; + const gifVideoWithPalette = hasTextOverlay + ? `${baseVideo};[1:v]format=rgba[textlayer];[vbase][textlayer]overlay=0:0[gifbase];[gifbase][2:v]paletteuse[gifout]` + : `${baseVideo};[vbase][1:v]paletteuse[gifout]`; const gifDurationArgs = recipe.speed !== 1 ? (() => { @@ -456,14 +481,38 @@ async function runExport(request: ExportRequest): Promise { : []; const pass1Code = await ffmpeg.exec( - ["-i", inputName, "-vf", vfWithPalette, ...gifDurationArgs, "-y", paletteName], + [ + "-i", + inputName, + ...(hasTextOverlay ? ["-i", textOverlayInputName] : []), + "-filter_complex", + gifVideoForPalette, + "-map", + "[paletteout]", + ...gifDurationArgs, + "-y", + paletteName, + ], undefined, { signal: activeExportAbortController?.signal } ); if (pass1Code !== 0) throw new Error("GIF palette generation failed"); const pass2Code = await ffmpeg.exec( - ["-i", inputName, "-i", paletteName, "-lavfi", vfWithPaletteUse, ...gifDurationArgs, "-y", outputName], + [ + "-i", + inputName, + ...(hasTextOverlay ? ["-i", textOverlayInputName] : []), + "-i", + paletteName, + "-filter_complex", + gifVideoWithPalette, + "-map", + "[gifout]", + ...gifDurationArgs, + "-y", + outputName, + ], undefined, { signal: activeExportAbortController?.signal } ); @@ -511,6 +560,8 @@ async function runExport(request: ExportRequest): Promise { hasOverlay, overlayInputName, request.overlayOptions, + hasTextOverlay, + textOverlayInputName, true, videoDuration ); @@ -534,6 +585,8 @@ async function runExport(request: ExportRequest): Promise { hasOverlay, overlayInputName, request.overlayOptions, + hasTextOverlay, + textOverlayInputName, false, videoDuration ); @@ -556,6 +609,8 @@ async function runExport(request: ExportRequest): Promise { hasOverlay, overlayInputName, request.overlayOptions, + hasTextOverlay, + textOverlayInputName, !missingAudioDetected, videoDuration ); diff --git a/src/lib/tests/textOverlay.test.ts b/src/lib/tests/textOverlay.test.ts new file mode 100644 index 00000000..565dee2e --- /dev/null +++ b/src/lib/tests/textOverlay.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { buildTextFilter } from "../text-overlay"; +import { TextOverlay } from "../types"; + +function overlay(overrides: Partial = {}): TextOverlay { + return { + id: "text-1", + text: "Title", + x: 50, + y: 25, + fontSize: 48, + color: "#ffffff", + fontWeight: "bold", + fontFamily: "Arial", + ...overrides, + }; +} + +describe("buildTextFilter", () => { + it("builds a drawtext filter without unsupported font options for built-in fonts", () => { + const filter = buildTextFilter(overlay(), 1920, 1080); + + expect(filter).toBe("drawtext=text='Title':x=960:y=270:fontsize=48:fontcolor=#ffffff"); + expect(filter).not.toContain("fontweight="); + expect(filter).not.toContain("fontfile='Arial'"); + }); + + it("escapes drawtext separators inside overlay text", () => { + const filter = buildTextFilter( + overlay({ text: "It's 10:30, go\\now; 50%", x: 10, y: 20 }), + 1000, + 500 + ); + + expect(filter).toContain("text='It\\'s 10\\:30\\, go\\\\now\\; 50\\%'"); + expect(filter).toContain("x=100:y=100"); + }); +}); diff --git a/src/lib/text-overlay.ts b/src/lib/text-overlay.ts index b7ca5c5c..b796ed92 100644 --- a/src/lib/text-overlay.ts +++ b/src/lib/text-overlay.ts @@ -58,6 +58,17 @@ export function getTextPercentPosition( }; } +function escapeDrawtextValue(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/:/g, "\\:") + .replace(/,/g, "\\,") + .replace(/;/g, "\\;") + .replace(/%/g, "\\%") + .replace(/\r?\n/g, "\\n"); +} + /** * Generates a drawText FFmpeg filter for a single text overlay. * Escapes special characters and positions text on the output video. @@ -69,34 +80,16 @@ export function buildTextFilter( targetHeight: number ): string { // Escape special characters for FFmpeg drawtext filter - const escapedText = overlay.text - .replace(/\\/g, "\\\\") - .replace(/'/g, "\\'") - .replace(/:/g, "\\:"); + const escapedText = escapeDrawtextValue(overlay.text); // Convert percentage position to pixel position const pixelX = Math.round((overlay.x / 100) * targetWidth); const pixelY = Math.round((overlay.y / 100) * targetHeight); - // Build font parameters - const fontWeightParam = overlay.fontWeight === "900" - ? "bold" - : overlay.fontWeight === "bold" - ? "bold" - : "normal"; - // Get font file parameter for custom fonts (if available) const fontFileParam = getFFmpegFontArg(overlay.fontFamily, overlay.fontPath); - // Build the drawtext filter with font support - let filter = `drawtext=text='${escapedText}':x=${pixelX}:y=${pixelY}:fontsize=${overlay.fontSize}:fontcolor=${overlay.color}:fontweight=${fontWeightParam}`; - - // Add font family if specified - if (overlay.fontFamily) { - // Sanitize font name for FFmpeg - const safeFontName = overlay.fontFamily.replace(/[^a-zA-Z0-9-]/g, ""); - filter += `:fontfile='${safeFontName}'`; - } + let filter = `drawtext=text='${escapedText}':x=${pixelX}:y=${pixelY}:fontsize=${overlay.fontSize}:fontcolor=${overlay.color}`; // Add custom font file path if available if (fontFileParam) {