diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx
index d684c1e5..75bf5cf8 100644
--- a/src/components/VideoPreview.tsx
+++ b/src/components/VideoPreview.tsx
@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-noninteractive-element-interactions */
"use client";
-import { useEffect, useRef, useState, useCallback, RefObject } from "react";
+import { useEffect, useRef, useState, useCallback, useMemo, RefObject } from "react";
import { EditRecipe, TextOverlay } from "@/lib/types";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
@@ -124,23 +124,40 @@ export default function VideoPreview({
videoRef.current.playbackRate = recipe.speed;
}, [recipe, videoRef]);
+ const aspectStyle = useMemo(() => {
+ if (!recipe) return undefined;
+ const preset = recipe.preset === "custom"
+ ? { width: recipe.customWidth, height: recipe.customHeight }
+ : getPresetById(recipe.preset);
+ if (!preset) return undefined;
+ return { aspectRatio: `${preset.width} / ${preset.height}` };
+ }, [recipe]);
+
/**
- * Track preview container dimensions for text overlay positioning.
+ * Track preview container dimensions for text overlay positioning using ResizeObserver.
*/
useEffect(() => {
+ const container = previewContainerRef.current;
+ if (!container) return;
+
const updateDimensions = () => {
- if (previewContainerRef.current) {
- const rect = previewContainerRef.current.getBoundingClientRect();
- setContainerDimensions({
- width: rect.width,
- height: rect.height,
- });
- }
+ const rect = container.getBoundingClientRect();
+ setContainerDimensions({
+ width: rect.width,
+ height: rect.height,
+ });
};
updateDimensions();
- window.addEventListener("resize", updateDimensions);
- return () => window.removeEventListener("resize", updateDimensions);
+
+ const observer = new ResizeObserver(() => {
+ updateDimensions();
+ });
+
+ observer.observe(container);
+ return () => {
+ observer.disconnect();
+ };
}, []);
const overlay = (() => {
@@ -217,7 +234,8 @@ export default function VideoPreview({
Date: Mon, 25 May 2026 23:54:04 +0530
Subject: [PATCH 5/7] feat: Add subtitles/captions support with custom styling
options #991
---
src/components/SubtitleControls.tsx | 366 ++++++++++++++++++++++++++++
src/components/VideoEditor.tsx | 29 ++-
src/components/VideoPreview.tsx | 69 ++++++
src/hooks/useVideoEditor.ts | 44 +++-
src/lib/constants.ts | 5 +
src/lib/ffmpeg.ts | 64 ++++-
src/lib/subtitles.ts | 77 ++++++
src/lib/tests/subtitles.test.ts | 84 +++++++
src/lib/types.ts | 10 +
9 files changed, 738 insertions(+), 10 deletions(-)
create mode 100644 src/components/SubtitleControls.tsx
create mode 100644 src/lib/subtitles.ts
create mode 100644 src/lib/tests/subtitles.test.ts
diff --git a/src/components/SubtitleControls.tsx b/src/components/SubtitleControls.tsx
new file mode 100644
index 00000000..cec145c2
--- /dev/null
+++ b/src/components/SubtitleControls.tsx
@@ -0,0 +1,366 @@
+"use client";
+
+import { EditRecipe } from "@/lib/types";
+import { SubtitleItem } from "@/lib/subtitles";
+import { Upload, Trash2, Sliders, Type, Palette, Layout, Search, Play } from "lucide-react";
+import { useMemo, useState, useRef } from "react";
+import { getPresetById } from "@/lib/presets";
+
+interface SubtitleControlsProps {
+ recipe: EditRecipe;
+ onChange: (patch: Partial
) => void;
+ subtitleFile: File | null;
+ parsedSubtitles: SubtitleItem[] | null;
+ onSubtitleSelect: (file: File) => void;
+ onClearSubtitles: () => void;
+ onSeek: (time: number) => void;
+}
+
+const FONTS = [
+ { value: "Inter", label: "Inter" },
+ { value: "Outfit", label: "Outfit" },
+ { value: "Roboto", label: "Roboto" },
+ { value: "Arial", label: "Arial (Standard)" },
+ { value: "Courier New", label: "Courier New" },
+ { value: "Times New Roman", label: "Times New Roman" },
+ { value: "system-ui", label: "System UI" },
+];
+
+const PRESET_COLORS = [
+ { hex: "#ffffff", label: "White" },
+ { hex: "#ffff00", label: "Yellow" },
+ { hex: "#00ffff", label: "Cyan" },
+ { hex: "#00ff00", label: "Green" },
+ { hex: "#ff00ff", label: "Magenta" },
+ { hex: "#000000", label: "Black" },
+];
+
+const STYLES = [
+ { value: "none", label: "Plain Text" },
+ { value: "outline", label: "Black Outline" },
+ { value: "box", label: "Semi-Transparent Box" },
+ { value: "shadow", label: "Soft Shadow" },
+];
+
+export default function SubtitleControls({
+ recipe,
+ onChange,
+ subtitleFile,
+ parsedSubtitles,
+ onSubtitleSelect,
+ onClearSubtitles,
+ onSeek,
+}: SubtitleControlsProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const fileInputRef = useRef(null);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ onSubtitleSelect(file);
+ }
+ };
+
+ const filteredSubtitles = useMemo(() => {
+ if (!parsedSubtitles) return [];
+ if (!searchQuery.trim()) return parsedSubtitles;
+ const query = searchQuery.toLowerCase();
+ return parsedSubtitles.filter((sub) => sub.text.toLowerCase().includes(query));
+ }, [parsedSubtitles, searchQuery]);
+
+ const formatTimestamp = (sec: number) => {
+ const min = Math.floor(sec / 60);
+ const s = Math.floor(sec % 60);
+ const ms = Math.floor((sec % 1) * 1000);
+ return `${String(min).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
+ };
+
+ return (
+
+ {/* File Upload / Clear */}
+
+ {!subtitleFile ? (
+
+
+
+
+ ) : (
+
+
+
+ {subtitleFile.name}
+
+
+ {parsedSubtitles?.length ?? 0} subtitle segments loaded
+
+
+
+
+ )}
+
+
+ {subtitleFile && (
+
+ {/* Typography Settings */}
+
+
+
+ Typography
+
+
+ {/* Font Family */}
+
+
+
+
+
+ {/* Font Size */}
+
+
+
+
+ {recipe.subtitleSize}px
+
+
+
+
onChange({ subtitleSize: Number(e.target.value) })}
+ className="flex-1 accent-film-600 cursor-pointer"
+ />
+
+ {(["Small", "Medium", "Large"] as const).map((label, idx) => {
+ const size = idx === 0 ? 24 : idx === 1 ? 36 : 48;
+ const isActive = recipe.subtitleSize === size;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {/* Color Settings */}
+
+
+
+ Colors
+
+
+ {/* Text Color */}
+
+
+
+ onChange({ subtitleColor: e.target.value })}
+ className="w-9 h-7 rounded border border-[var(--border)] cursor-pointer shrink-0 bg-transparent"
+ />
+ onChange({ subtitleColor: e.target.value })}
+ placeholder="#ffffff"
+ className="flex-1 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] font-mono focus:outline-none focus:ring-2 focus:ring-film-500"
+ aria-label="Subtitle Hex color input"
+ />
+
+
+ {/* Curated color buttons */}
+
+ {PRESET_COLORS.map((color) => (
+
+ ))}
+
+
+
+
+ {/* Style & Box Settings */}
+
+
+
+ Background & Readability
+
+
+ {/* Background type select */}
+
+
+
+ {STYLES.map((style) => {
+ const isActive = recipe.subtitleBgType === style.value;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Background / outline color */}
+ {recipe.subtitleBgType !== "none" && (
+
+ )}
+
+
+ {/* Subtitles Navigation list */}
+
+
+
+
+ Segments
+
+
+ {filteredSubtitles.length} of {parsedSubtitles?.length ?? 0}
+
+
+
+ {/* Search Input */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-8 pr-2.5 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] placeholder-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-film-500"
+ />
+
+
+ {/* Subtitle segments list */}
+ {filteredSubtitles.length > 0 ? (
+
+ {filteredSubtitles.map((sub) => (
+
+ ))}
+
+ ) : (
+
+ No matching captions found
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index 928f1925..487a0d18 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -17,12 +17,14 @@ import ExportSettings from "./ExportSettings";
import ExportOverlay from "./ExportOverlay";
import DownloadResult from "./DownloadResult";
import ImageOverlay from "./ImageOverlay"
+import SubtitleControls from "./SubtitleControls";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
import {
Layers, Crop, Scissors, RotateCw, Volume2, Type,
- SlidersHorizontal, Zap, AlertTriangle, Github, Copy
+ SlidersHorizontal, Zap, AlertTriangle, Github, Copy,
+ Subtitles
} from "lucide-react";
import OnboardingTour from "./OnboardingTour";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
@@ -210,6 +212,10 @@ export default function VideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
+ subtitleFile,
+ parsedSubtitles,
+ handleSubtitleSelect,
+ clearSubtitles,
} = useVideoEditor();
useKeyboardShortcuts({
@@ -231,6 +237,7 @@ export default function VideoEditor() {
trim: false,
rotation: false,
text: false,
+ subtitles: false,
audio: false,
export: false,
});
@@ -394,6 +401,7 @@ export default function VideoEditor() {
selectedTextId={selectedTextId}
onSelectText={setSelectedTextId}
onUpdateText={handleUpdateTextOverlay}
+ parsedSubtitles={parsedSubtitles}
/>
@@ -463,6 +471,25 @@ export default function VideoEditor() {
onSelectText={setSelectedTextId}
/>
+
+
}
+ title="Subtitles / Captions"
+ isOpen={openSections.subtitles}
+ onToggle={() => toggleSection("subtitles")}
+ delay={115}
+ >
+
+
void;
onUpdateText?: (id: string, updates: Partial) => void;
+ parsedSubtitles?: SubtitleItem[] | null;
}
export default function VideoPreview({
@@ -25,7 +27,40 @@ export default function VideoPreview({
selectedTextId = null,
onSelectText,
onUpdateText,
+ parsedSubtitles = null,
}: Props) {
+ const [localCurrentTime, setLocalCurrentTime] = useState(0);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handleTimeUpdate = () => {
+ setLocalCurrentTime(video.currentTime);
+ };
+
+ video.addEventListener("timeupdate", handleTimeUpdate);
+ return () => {
+ video.removeEventListener("timeupdate", handleTimeUpdate);
+ };
+ }, [videoRef, file]);
+
+ const activeSub = useMemo(() => {
+ if (!parsedSubtitles || parsedSubtitles.length === 0) return null;
+ return parsedSubtitles.find(
+ (sub) => localCurrentTime >= sub.startTime && localCurrentTime <= sub.endTime
+ );
+ }, [parsedSubtitles, localCurrentTime]);
+
+ const scaledFontSize = useMemo(() => {
+ if (!recipe) return 24;
+ const preset = recipe.preset === "custom"
+ ? { width: recipe.customWidth, height: recipe.customHeight }
+ : getPresetById(recipe.preset);
+ const targetH = preset?.height ?? 1080;
+ const scale = containerDimensions.height / targetH;
+ return Math.max(12, Math.round(recipe.subtitleSize * scale));
+ }, [recipe, containerDimensions.height]);
const lastId = useRef(0);
const urlRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
@@ -290,6 +325,40 @@ export default function VideoPreview({
)}
+ {/* Subtitles Overlay */}
+ {activeSub && recipe && (
+
+ {activeSub.text}
+
+ )}
+
{/* Draggable Text Overlays */}
{recipe && !isLoading && containerDimensions.width > 0 && (
(null);
+ const [parsedSubtitles, setParsedSubtitles] = useState(null);
+
+ const handleSubtitleSelect = useCallback(async (selectedFile: File) => {
+ if (!selectedFile) return;
+ setSubtitleFile(selectedFile);
+ try {
+ const text = await selectedFile.text();
+ const subs = parseSRT(text);
+ setParsedSubtitles(subs);
+ } catch (err) {
+ console.error("Failed to parse subtitle file:", err);
+ setParsedSubtitles([]);
+ }
+ }, []);
+
+ const clearSubtitles = useCallback(() => {
+ setSubtitleFile(null);
+ setParsedSubtitles(null);
+ }, []);
+
const updateRecipe = useCallback((patch: Partial) => {
setRecipe((prev) => {
const next = { ...prev, ...patch };
@@ -212,6 +235,16 @@ export function useVideoEditor() {
return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 2;
case "saturation":
return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 3;
+ case "subtitleFont":
+ return typeof val === "string";
+ case "subtitleColor":
+ return typeof val === "string";
+ case "subtitleSize":
+ return typeof val === "number" && !isNaN(val) && val >= 10 && val <= 120;
+ case "subtitleBgType":
+ return ["none", "box", "shadow", "outline"].includes(val);
+ case "subtitleBgColor":
+ return typeof val === "string";
default:
return true;
}
@@ -483,7 +516,8 @@ export function useVideoEditor() {
position: overlayPosition,
size: overlaySize,
opacity: overlayOpacity,
- }
+ },
+ parsedSubtitles ?? undefined
);
if (exportCancelledRef.current) return;
@@ -527,6 +561,7 @@ export function useVideoEditor() {
recipe,
result,
status,
+ parsedSubtitles,
]);
@@ -633,6 +668,7 @@ export function useVideoEditor() {
overlayPosition,
overlaySize,
overlayOpacity,
+ subtitleFile,
]);
useEffect(() => {
@@ -673,6 +709,8 @@ export function useVideoEditor() {
setResult(null);
setError(null);
setExportStartedAt(null);
+ setSubtitleFile(null);
+ setParsedSubtitles(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
@@ -738,5 +776,9 @@ export function useVideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
+ subtitleFile,
+ parsedSubtitles,
+ handleSubtitleSelect,
+ clearSubtitles,
};
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index e713f779..3bd6e330 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -23,5 +23,10 @@ export const DEFAULT_RECIPE: EditRecipe = {
soundOnCompletion: false,
normalizeAudio: false,
textOverlays: [],
+ subtitleFont: "Inter",
+ subtitleColor: "#ffffff",
+ subtitleSize: 36,
+ subtitleBgType: "outline",
+ subtitleBgColor: "#000000",
version: RECIPE_VERSION,
};
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index f28aaa0e..e640f713 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -4,6 +4,7 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions }
import { getPresetById } from "./presets";
import { buildTextFilter } from "./text-overlay";
import { simd } from "wasm-feature-detect";
+import { SubtitleItem } from "./subtitles";
const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
@@ -98,7 +99,42 @@ function buildSessionId(): string {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
-export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string {
+function buildSubtitleFilter(
+ sub: SubtitleItem,
+ recipe: EditRecipe,
+ targetW: number,
+ targetH: number
+): string {
+ const escapedText = sub.text
+ .replace(/\\/g, "\\\\")
+ .replace(/'/g, "'\\\\''")
+ .replace(/:/g, "\\:")
+ .replace(/\n/g, "\n");
+
+ const xExpr = "(w-text_w)/2";
+ const yExpr = "h-th-h*0.12";
+
+ let styleParams = "";
+ if (recipe.subtitleBgType === "box") {
+ const boxCol = recipe.subtitleBgColor.replace("#", "0x") + "@0.6";
+ styleParams = `:box=1:boxcolor=${boxCol}:boxborderw=8`;
+ } else if (recipe.subtitleBgType === "outline") {
+ const borderCol = recipe.subtitleBgColor;
+ styleParams = `:borderw=2:bordercolor=${borderCol}`;
+ } else if (recipe.subtitleBgType === "shadow") {
+ const shadowCol = recipe.subtitleBgColor.replace("#", "0x") + "@0.6";
+ styleParams = `:shadowcolor=${shadowCol}:shadowx=2:shadowy=2`;
+ }
+
+ return `drawtext=text='${escapedText}':x=${xExpr}:y=${yExpr}:fontsize=${recipe.subtitleSize}:fontcolor=${recipe.subtitleColor}${styleParams}:enable='between(t,${sub.startTime},${sub.endTime})'`;
+}
+
+export function buildVideoFilter(
+ recipe: EditRecipe,
+ targetW: number,
+ targetH: number,
+ subtitles?: SubtitleItem[]
+): string {
const filters: string[] = [];
if (recipe.trimStart > 0 || recipe.trimEnd !== null) {
@@ -162,6 +198,13 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n
filters.push(buildTextFilter(overlay, targetW, targetH));
});
+ // Add subtitles if present
+ if (subtitles && subtitles.length > 0) {
+ subtitles.forEach((sub) => {
+ filters.push(buildSubtitleFilter(sub, recipe, targetW, targetH));
+ });
+ }
+
return filters.join(",");
}
@@ -209,9 +252,10 @@ function buildArguments(
overlayInputName: string,
overlayOptions: ImageOverlayOptions | undefined,
hasOriginalAudio: boolean,
- videoDuration: number
+ videoDuration: number,
+ subtitles?: SubtitleItem[]
): string[] {
- const vf = buildVideoFilter(recipe, targetW, targetH);
+ const vf = buildVideoFilter(recipe, targetW, targetH, subtitles);
const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : "";
const afParts = [audioTrim, audioSpeed].filter(Boolean);
@@ -337,7 +381,8 @@ export async function exportVideo(
onProgress: (percent: number) => void,
signal?: AbortSignal,
musicOptions?: BackgroundMusicOptions,
- overlayOptions?: ImageOverlayOptions
+ overlayOptions?: ImageOverlayOptions,
+ subtitles?: SubtitleItem[]
): Promise {
const sessionId = buildSessionId();
let targetW: number, targetH: number;
@@ -418,7 +463,7 @@ export async function exportVideo(
// ── Two-pass GIF export ──────────────────────────────────────────────────
if (recipe.format === "gif") {
- const vf = buildVideoFilter(recipe, targetW, targetH);
+ const vf = buildVideoFilter(recipe, targetW, targetH, subtitles);
const vfWithPalette = vf ? `${vf},palettegen` : "palettegen";
const vfWithPaletteUse = vf
? `[0:v]${vf}[x];[x][1:v]paletteuse`
@@ -486,7 +531,8 @@ export async function exportVideo(
let args = buildArguments(
recipe, recipe.format, outputName, inputName, targetW, targetH,
hasMusicTrack, musicInputName, musicOptions,
- hasOverlay, overlayInputName, overlayOptions, true, videoDuration
+ hasOverlay, overlayInputName, overlayOptions, true, videoDuration,
+ subtitles
);
let exitCode = await ffmpeg.exec(args, undefined, { signal });
@@ -497,7 +543,8 @@ export async function exportVideo(
args = buildArguments(
recipe, recipe.format, outputName, inputName, targetW, targetH,
hasMusicTrack, musicInputName, musicOptions,
- hasOverlay, overlayInputName, overlayOptions, false, videoDuration
+ hasOverlay, overlayInputName, overlayOptions, false, videoDuration,
+ subtitles
);
exitCode = await ffmpeg.exec(args, undefined, { signal });
}
@@ -507,7 +554,8 @@ export async function exportVideo(
args = buildArguments(
recipe, "webm", fallbackOutputName, inputName, targetW, targetH,
hasMusicTrack, musicInputName, musicOptions,
- hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected, videoDuration
+ hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected, videoDuration,
+ subtitles
);
const fallbackCode = await ffmpeg.exec(args, undefined, { signal });
diff --git a/src/lib/subtitles.ts b/src/lib/subtitles.ts
new file mode 100644
index 00000000..dde11902
--- /dev/null
+++ b/src/lib/subtitles.ts
@@ -0,0 +1,77 @@
+export interface SubtitleItem {
+ id: string;
+ startTime: number; // in seconds
+ endTime: number; // in seconds
+ text: string;
+}
+
+/**
+ * Parses an SRT subtitle file content string into an array of SubtitleItem objects.
+ */
+export function parseSRT(content: string): SubtitleItem[] {
+ if (!content) return [];
+
+ // Normalize line endings to LF
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+
+ // Split by blank lines (double newlines or more)
+ const blocks = normalized.trim().split(/\n\s*\n+/);
+
+ const items: SubtitleItem[] = [];
+
+ for (const block of blocks) {
+ const lines = block.trim().split("\n");
+ if (lines.length < 2) continue;
+
+ // A block usually looks like:
+ // 1
+ // 00:00:01,000 --> 00:00:04,000
+ // Subtitle text here...
+ let timestampLineIndex = 0;
+ if (/^\d+$/.test(lines[0].trim())) {
+ timestampLineIndex = 1;
+ }
+
+ const timestampLine = lines[timestampLineIndex];
+ if (!timestampLine || !timestampLine.includes("-->")) continue;
+
+ const parts = timestampLine.split("-->");
+ if (parts.length !== 2) continue;
+
+ const startTime = parseSRTTimestamp(parts[0].trim());
+ const endTime = parseSRTTimestamp(parts[1].trim());
+
+ if (startTime === null || endTime === null) continue;
+
+ // Subtitle text is the remaining lines
+ const textLines = lines.slice(timestampLineIndex + 1);
+ const text = textLines.join("\n").trim();
+ if (!text) continue;
+
+ items.push({
+ id: `sub-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ startTime,
+ endTime,
+ text,
+ });
+ }
+
+ // Sort by startTime to ensure chronological order
+ return items.sort((a, b) => a.startTime - b.startTime);
+}
+
+/**
+ * Parses a single SRT timestamp (HH:MM:SS,mmm or HH:MM:SS.mmm) into seconds.
+ */
+function parseSRTTimestamp(timestamp: string): number | null {
+ const regex = /(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/;
+ const match = timestamp.match(regex);
+ if (!match) return null;
+
+ const hours = parseInt(match[1], 10);
+ const minutes = parseInt(match[2], 10);
+ const seconds = parseInt(match[3], 10);
+ const milliseconds = parseInt(match[4], 10);
+
+ return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
+}
diff --git a/src/lib/tests/subtitles.test.ts b/src/lib/tests/subtitles.test.ts
new file mode 100644
index 00000000..af5cf59d
--- /dev/null
+++ b/src/lib/tests/subtitles.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from "vitest";
+import { parseSRT } from "../subtitles";
+
+describe("parseSRT", () => {
+ it("returns empty array for empty content", () => {
+ expect(parseSRT("")).toEqual([]);
+ expect(parseSRT(" ")).toEqual([]);
+ });
+
+ it("correctly parses a single basic subtitle block", () => {
+ const srt = `1
+00:00:01,000 --> 00:00:04,500
+Hello World!`;
+ const result = parseSRT(srt);
+ expect(result.length).toBe(1);
+ expect(result[0].startTime).toBe(1.0);
+ expect(result[0].endTime).toBe(4.5);
+ expect(result[0].text).toBe("Hello World!");
+ });
+
+ it("correctly parses multiple subtitle blocks", () => {
+ const srt = `1
+00:00:01,000 --> 00:00:03,000
+Hello World!
+
+2
+00:00:04,250 --> 00:00:07,100
+This is a second caption.`;
+ const result = parseSRT(srt);
+ expect(result.length).toBe(2);
+ expect(result[0].startTime).toBe(1.0);
+ expect(result[0].endTime).toBe(3.0);
+ expect(result[0].text).toBe("Hello World!");
+
+ expect(result[1].startTime).toBe(4.25);
+ expect(result[1].endTime).toBe(7.1);
+ expect(result[1].text).toBe("This is a second caption.");
+ });
+
+ it("handles multi-line subtitles", () => {
+ const srt = `1
+00:01:15,123 --> 00:01:20,500
+This is a multi-line
+subtitle text.
+Enjoy it!`;
+ const result = parseSRT(srt);
+ expect(result.length).toBe(1);
+ expect(result[0].startTime).toBe(75.123);
+ expect(result[0].endTime).toBe(80.5);
+ expect(result[0].text).toBe("This is a multi-line\nsubtitle text.\nEnjoy it!");
+ });
+
+ it("handles alternative timestamp separators (dot instead of comma)", () => {
+ const srt = `1
+00:00:05.500 --> 00:00:08.200
+Test dot timestamps.`;
+ const result = parseSRT(srt);
+ expect(result.length).toBe(1);
+ expect(result[0].startTime).toBe(5.5);
+ expect(result[0].endTime).toBe(8.2);
+ expect(result[0].text).toBe("Test dot timestamps.");
+ });
+
+ it("handles Windows CRLF and trailing spaces", () => {
+ const srt = "1\r\n00:00:02,000 --> 00:00:05,000\r\nWindows CRLF Test\r\n\r\n2\r\n00:00:06,000 --> 00:00:08,000\r\nSecond Line\r\n";
+ const result = parseSRT(srt);
+ expect(result.length).toBe(2);
+ expect(result[0].text).toBe("Windows CRLF Test");
+ expect(result[1].text).toBe("Second Line");
+ });
+
+ it("ignores blocks with invalid timestamps", () => {
+ const srt = `1
+invalid timestamp line
+Some text
+
+2
+00:00:01,000 --> 00:00:03,000
+Valid Subtitle`;
+ const result = parseSRT(srt);
+ expect(result.length).toBe(1);
+ expect(result[0].text).toBe("Valid Subtitle");
+ });
+});
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 8207f60c..208445a9 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -33,6 +33,11 @@ export interface EditRecipe {
saturation: number;
soundOnCompletion: boolean;
textOverlays: TextOverlay[];
+ subtitleFont: string;
+ subtitleColor: string;
+ subtitleSize: number;
+ subtitleBgType: "none" | "box" | "shadow" | "outline";
+ subtitleBgColor: string;
version: number;
}
@@ -103,6 +108,11 @@ export function isValidRecipe(value: unknown): value is EditRecipe {
if (typeof v.saturation !== "number" || !isFinite(v.saturation)) return false;
if (typeof v.soundOnCompletion !== "boolean") return false;
if (!Array.isArray(v.textOverlays)) return false;
+ if (typeof v.subtitleFont !== "string") return false;
+ if (typeof v.subtitleColor !== "string") return false;
+ if (typeof v.subtitleSize !== "number" || !isFinite(v.subtitleSize)) return false;
+ if (!["none", "box", "shadow", "outline"].includes(v.subtitleBgType)) return false;
+ if (typeof v.subtitleBgColor !== "string") return false;
return true;
}
From e71856d45196d53b9bad83de13123daaec3d8cef Mon Sep 17 00:00:00 2001
From: Rucha
Date: Tue, 26 May 2026 00:29:25 +0530
Subject: [PATCH 6/7] feat(ffmpeg): support burning subtitles inside background
Web Worker
---
src/lib/ffmpeg.ts | 6 +++-
src/lib/ffmpeg.worker.ts | 62 +++++++++++++++++++++++++++++++++++-----
2 files changed, 60 insertions(+), 8 deletions(-)
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index 5cac4044..2314dfb9 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 { SubtitleItem } from "./subtitles";
export class FFmpegLoadError extends Error {}
@@ -25,6 +26,7 @@ type WorkerExportRequest = {
musicOptions?: BackgroundMusicOptions;
overlayFile?: SerializedFile;
overlayOptions?: ImageOverlayOptions;
+ subtitles?: SubtitleItem[];
};
type WorkerLoadResponse = { type: "ready" };
@@ -211,7 +213,8 @@ export async function exportVideo(
onProgress: (percent: number) => void,
signal?: AbortSignal,
musicOptions?: BackgroundMusicOptions,
- overlayOptions?: ImageOverlayOptions
+ overlayOptions?: ImageOverlayOptions,
+ subtitles?: SubtitleItem[]
): Promise {
await loadFFmpeg(signal, onProgress);
@@ -283,6 +286,7 @@ export async function exportVideo(
musicOptions: sanitizedMusicOptions,
overlayFile: overlayFilePayload,
overlayOptions: sanitizedOverlayOptions,
+ subtitles,
} as WorkerExportRequest,
transfers
);
diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts
index bdf9804b..59af726e 100644
--- a/src/lib/ffmpeg.worker.ts
+++ b/src/lib/ffmpeg.worker.ts
@@ -3,6 +3,7 @@ import { toBlobURL } from "@ffmpeg/util";
import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions } from "./types";
import { getPresetById } from "./presets";
import { buildTextFilter } from "./text-overlay";
+import { SubtitleItem } from "./subtitles";
const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.6/dist/esm";
@@ -27,6 +28,7 @@ type ExportRequest = {
musicOptions?: BackgroundMusicOptions;
overlayFile?: SerializedFile;
overlayOptions?: ImageOverlayOptions;
+ subtitles?: SubtitleItem[];
};
type LoadRequest = { type: "load" };
@@ -76,7 +78,42 @@ async function fetchWithIntegrity(url: string, mimeType: string): Promise 0 || recipe.trimEnd !== null) {
@@ -137,6 +174,13 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number):
filters.push(buildTextFilter(overlay, targetW, targetH));
});
+ // Add subtitles if present
+ if (subtitles && subtitles.length > 0) {
+ subtitles.forEach((sub) => {
+ filters.push(buildSubtitleFilter(sub, recipe, targetW, targetH));
+ });
+ }
+
return filters.join(",");
}
@@ -184,9 +228,10 @@ function buildArguments(
overlayInputName: string,
overlayOptions: ImageOverlayOptions | undefined,
hasOriginalAudio: boolean,
- videoDuration: number
+ videoDuration: number,
+ subtitles?: SubtitleItem[]
): string[] {
- const vf = buildVideoFilter(recipe, targetW, targetH);
+ const vf = buildVideoFilter(recipe, targetW, targetH, subtitles);
const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : "";
const afParts = [audioTrim, audioSpeed].filter(Boolean);
@@ -427,7 +472,7 @@ async function runExport(request: ExportRequest): Promise {
try {
if (recipe.format === "gif") {
- const vf = buildVideoFilter(recipe, targetW, targetH);
+ const vf = buildVideoFilter(recipe, targetW, targetH, request.subtitles);
const vfWithPalette = vf ? `${vf},palettegen` : "palettegen";
const vfWithPaletteUse = vf
? `[0:v]${vf}[x];[x][1:v]paletteuse`
@@ -498,7 +543,8 @@ async function runExport(request: ExportRequest): Promise {
overlayInputName,
request.overlayOptions,
true,
- videoDuration
+ videoDuration,
+ request.subtitles
);
let exitCode = await ffmpeg.exec(args, undefined, {
@@ -521,7 +567,8 @@ async function runExport(request: ExportRequest): Promise {
overlayInputName,
request.overlayOptions,
false,
- videoDuration
+ videoDuration,
+ request.subtitles
);
exitCode = await ffmpeg.exec(args, undefined, {
signal: activeExportAbortController?.signal,
@@ -543,7 +590,8 @@ async function runExport(request: ExportRequest): Promise {
overlayInputName,
request.overlayOptions,
!missingAudioDetected,
- videoDuration
+ videoDuration,
+ request.subtitles
);
const fallbackCode = await ffmpeg.exec(args, undefined, {
From 926c23f6d17377dbc0f26dd2f5ea08a42668b9ff Mon Sep 17 00:00:00 2001
From: Rucha
Date: Tue, 26 May 2026 00:34:56 +0530
Subject: [PATCH 7/7] fix(tsc): resolve hoisting and strict undefined check TS
compile errors
---
src/components/VideoPreview.tsx | 8 ++++----
src/lib/subtitles.ts | 25 ++++++++++++++++-------
src/lib/tests/subtitles.test.ts | 36 ++++++++++++++++-----------------
3 files changed, 40 insertions(+), 29 deletions(-)
diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx
index 6d97de04..c1f5245d 100644
--- a/src/components/VideoPreview.tsx
+++ b/src/components/VideoPreview.tsx
@@ -30,6 +30,10 @@ export default function VideoPreview({
parsedSubtitles = null,
}: Props) {
const [localCurrentTime, setLocalCurrentTime] = useState(0);
+ const [containerDimensions, setContainerDimensions] = useState({
+ width: 0,
+ height: 0,
+ });
useEffect(() => {
const video = videoRef.current;
@@ -66,10 +70,6 @@ export default function VideoPreview({
const [isLoading, setIsLoading] = useState(true);
const [showOverlay, setShowOverlay] = useState(false);
const [showComparison, setShowComparison] = useState(false);
- const [containerDimensions, setContainerDimensions] = useState({
- width: 0,
- height: 0,
- });
const previewContainerRef = useRef(null);
const onLoadedRef = useRef<(() => void) | null>(null);
diff --git a/src/lib/subtitles.ts b/src/lib/subtitles.ts
index dde11902..7e80bf66 100644
--- a/src/lib/subtitles.ts
+++ b/src/lib/subtitles.ts
@@ -28,7 +28,8 @@ export function parseSRT(content: string): SubtitleItem[] {
// 00:00:01,000 --> 00:00:04,000
// Subtitle text here...
let timestampLineIndex = 0;
- if (/^\d+$/.test(lines[0].trim())) {
+ const firstLine = lines[0];
+ if (firstLine && /^\d+$/.test(firstLine.trim())) {
timestampLineIndex = 1;
}
@@ -38,8 +39,12 @@ export function parseSRT(content: string): SubtitleItem[] {
const parts = timestampLine.split("-->");
if (parts.length !== 2) continue;
- const startTime = parseSRTTimestamp(parts[0].trim());
- const endTime = parseSRTTimestamp(parts[1].trim());
+ const part0 = parts[0];
+ const part1 = parts[1];
+ if (!part0 || !part1) continue;
+
+ const startTime = parseSRTTimestamp(part0.trim());
+ const endTime = parseSRTTimestamp(part1.trim());
if (startTime === null || endTime === null) continue;
@@ -68,10 +73,16 @@ function parseSRTTimestamp(timestamp: string): number | null {
const match = timestamp.match(regex);
if (!match) return null;
- const hours = parseInt(match[1], 10);
- const minutes = parseInt(match[2], 10);
- const seconds = parseInt(match[3], 10);
- const milliseconds = parseInt(match[4], 10);
+ const h = match[1];
+ const m = match[2];
+ const s = match[3];
+ const ms = match[4];
+ if (!h || !m || !s || !ms) return null;
+
+ const hours = parseInt(h, 10);
+ const minutes = parseInt(m, 10);
+ const seconds = parseInt(s, 10);
+ const milliseconds = parseInt(ms, 10);
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
}
diff --git a/src/lib/tests/subtitles.test.ts b/src/lib/tests/subtitles.test.ts
index af5cf59d..2836bd09 100644
--- a/src/lib/tests/subtitles.test.ts
+++ b/src/lib/tests/subtitles.test.ts
@@ -13,9 +13,9 @@ describe("parseSRT", () => {
Hello World!`;
const result = parseSRT(srt);
expect(result.length).toBe(1);
- expect(result[0].startTime).toBe(1.0);
- expect(result[0].endTime).toBe(4.5);
- expect(result[0].text).toBe("Hello World!");
+ expect(result[0]!.startTime).toBe(1.0);
+ expect(result[0]!.endTime).toBe(4.5);
+ expect(result[0]!.text).toBe("Hello World!");
});
it("correctly parses multiple subtitle blocks", () => {
@@ -28,13 +28,13 @@ Hello World!
This is a second caption.`;
const result = parseSRT(srt);
expect(result.length).toBe(2);
- expect(result[0].startTime).toBe(1.0);
- expect(result[0].endTime).toBe(3.0);
- expect(result[0].text).toBe("Hello World!");
+ expect(result[0]!.startTime).toBe(1.0);
+ expect(result[0]!.endTime).toBe(3.0);
+ expect(result[0]!.text).toBe("Hello World!");
- expect(result[1].startTime).toBe(4.25);
- expect(result[1].endTime).toBe(7.1);
- expect(result[1].text).toBe("This is a second caption.");
+ expect(result[1]!.startTime).toBe(4.25);
+ expect(result[1]!.endTime).toBe(7.1);
+ expect(result[1]!.text).toBe("This is a second caption.");
});
it("handles multi-line subtitles", () => {
@@ -45,9 +45,9 @@ subtitle text.
Enjoy it!`;
const result = parseSRT(srt);
expect(result.length).toBe(1);
- expect(result[0].startTime).toBe(75.123);
- expect(result[0].endTime).toBe(80.5);
- expect(result[0].text).toBe("This is a multi-line\nsubtitle text.\nEnjoy it!");
+ expect(result[0]!.startTime).toBe(75.123);
+ expect(result[0]!.endTime).toBe(80.5);
+ expect(result[0]!.text).toBe("This is a multi-line\nsubtitle text.\nEnjoy it!");
});
it("handles alternative timestamp separators (dot instead of comma)", () => {
@@ -56,17 +56,17 @@ Enjoy it!`;
Test dot timestamps.`;
const result = parseSRT(srt);
expect(result.length).toBe(1);
- expect(result[0].startTime).toBe(5.5);
- expect(result[0].endTime).toBe(8.2);
- expect(result[0].text).toBe("Test dot timestamps.");
+ expect(result[0]!.startTime).toBe(5.5);
+ expect(result[0]!.endTime).toBe(8.2);
+ expect(result[0]!.text).toBe("Test dot timestamps.");
});
it("handles Windows CRLF and trailing spaces", () => {
const srt = "1\r\n00:00:02,000 --> 00:00:05,000\r\nWindows CRLF Test\r\n\r\n2\r\n00:00:06,000 --> 00:00:08,000\r\nSecond Line\r\n";
const result = parseSRT(srt);
expect(result.length).toBe(2);
- expect(result[0].text).toBe("Windows CRLF Test");
- expect(result[1].text).toBe("Second Line");
+ expect(result[0]!.text).toBe("Windows CRLF Test");
+ expect(result[1]!.text).toBe("Second Line");
});
it("ignores blocks with invalid timestamps", () => {
@@ -79,6 +79,6 @@ Some text
Valid Subtitle`;
const result = parseSRT(srt);
expect(result.length).toBe(1);
- expect(result[0].text).toBe("Valid Subtitle");
+ expect(result[0]!.text).toBe("Valid Subtitle");
});
});