diff --git a/bun.lock b/bun.lock
index 5d472fd5..b59fc302 100644
--- a/bun.lock
+++ b/bun.lock
@@ -441,6 +441,9 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
+ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
@@ -481,6 +484,8 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+ "domexception": ["domexception@4.0.0", "", { "dependencies": { "webidl-conversions": "^7.0.0" } }, "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -777,6 +782,8 @@
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx
index d48bdd69..a20ac671 100644
--- a/src/components/TrimControl.tsx
+++ b/src/components/TrimControl.tsx
@@ -2,9 +2,10 @@
import { EditRecipe } from "@/lib/types";
import { useState, useEffect, useRef, useCallback } from "react";
-import { AlertCircle } from "lucide-react";
-import { formatDuration } from "@/lib/utils";
+import { AlertCircle, Zap, X } from "lucide-react";
+import { formatDuration, cn } from "@/lib/utils";
import { useAudioWaveform } from "@/hooks/useAudioWaveform";
+import { useSilenceDetection } from "@/hooks/useSilenceDetection";
import WaveformCanvas from "@/components/WaveformCanvas";
const MIN_CLIP_DURATION = 0.1;
@@ -26,12 +27,16 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
);
const { waveform, isLoading: waveformLoading } = useAudioWaveform(file);
+ const { silentSegments } = useSilenceDetection(file, 0.02);
const hasAudio = waveform.length > 0;
useEffect(() => {
setStartInput(recipe.trimStart.toString());
}, [recipe.trimStart]);
+ const activeJumpCuts = recipe.jumpCutSegments;
+ const hasJumpCuts = !!activeJumpCuts && activeJumpCuts.length > 0;
+
const clipLength =
(recipe.trimEnd ?? duration) - recipe.trimStart;
@@ -175,6 +180,46 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
const inputClass =
"w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";
+ const generateJumpCuts = useCallback(() => {
+ if (silentSegments.length === 0) return;
+
+ const significantSilences = silentSegments.filter(
+ segment => (segment.end - segment.start) >= 0.5
+ );
+
+ if (significantSilences.length === 0) {
+ alert("No significant silent sections detected. Adjust your video threshold or check the audio.");
+ return;
+ }
+
+ const keepSegments: Array<{ start: number; end: number }> = [];
+ let cursor = 0;
+
+ for (const silence of significantSilences) {
+ if (silence.start > cursor) {
+ keepSegments.push({
+ start: cursor,
+ end: silence.start,
+ });
+ }
+
+ cursor = silence.end;
+ }
+
+ if (cursor < duration) {
+ keepSegments.push({
+ start: cursor,
+ end: duration,
+ });
+ }
+
+ onChange({ jumpCutSegments: keepSegments });
+ }, [silentSegments, duration, onChange]);
+
+ const clearJumpCuts = useCallback(() => {
+ onChange({ jumpCutSegments: undefined });
+ }, [onChange]);
+
return (
{duration > 0 && (
@@ -201,6 +246,30 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
right: `${((duration - (recipe.trimEnd ?? duration)) / duration) * 100}%`,
}}
/>
+ {silentSegments.map((segment, idx) => (
+
+ ))}
+ {hasJumpCuts && activeJumpCuts!.map((seg, idx) => (
+
+ ))}
)}
+
+
+ {hasJumpCuts && (
+
+
+ {activeJumpCuts!.length} keep segment{activeJumpCuts!.length !== 1 ? "s" : ""} active
+
+
+
+
+ Clear
+
+
+ )}
+
+
+
+ {hasJumpCuts ? "Regenerate Jump Cuts" : "Generate Jump Cuts"}
+
+
+
+ 1. Detect Silence to generate red silence markers.
+ {" "}2. Generate Jump Cuts to create green keep-segments.
+ {" "}3. Export the video to apply FFmpeg jump cuts.
+
);
}
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index 1e4e9f0d..3285836d 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -354,7 +354,7 @@ export default function VideoEditor() {
paddingTop: 'clamp(0.5rem,2vw,0.75rem)',
}}
>
-
+
No login. No ads. 100% private.
@@ -362,7 +362,7 @@ export default function VideoEditor() {
className="flex flex-wrap justify-center text-center items-center gap-2 text-sm font-heading font-semibold uppercase tracking-widest text-[var(--muted)] pb-1"
style={{ justifyContent: 'center', textAlign: 'center', margin: '0', width: 'auto' }}
>
-
+
No login. No ads. 100% private - your video never leaves your device.
diff --git a/src/components/__tests__/videoFilter.test.tsx b/src/components/__tests__/videoFilter.test.tsx
new file mode 100644
index 00000000..56855539
--- /dev/null
+++ b/src/components/__tests__/videoFilter.test.tsx
@@ -0,0 +1,161 @@
+import { describe, it, expect } from "vitest";
+import { buildJumpCutFilterComplex } from "../../lib/ffmpeg";
+import { DEFAULT_RECIPE } from "../../lib/constants";
+
+const base = (overrides = {}) => ({ ...DEFAULT_RECIPE, ...overrides });
+
+describe("buildJumpCutFilterComplex", () => {
+
+ it("returns empty filterComplex when no segments are provided", () => {
+ const { filterComplex } = buildJumpCutFilterComplex(
+ base({ jumpCutSegments: [] }),
+ 1280, 720, false
+ );
+ expect(filterComplex).toBe("");
+ });
+
+ it("returns empty filterComplex when jumpCutSegments is undefined", () => {
+ const { filterComplex } = buildJumpCutFilterComplex(
+ base(),
+ 1280, 720, false
+ );
+ expect(filterComplex).toBe("");
+ });
+
+ it("trims each segment with its own atrim/trim bounds", () => {
+ const recipe = base({
+ jumpCutSegments: [
+ { start: 0, end: 3 },
+ { start: 7, end: 12 },
+ ],
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("trim=start=0:end=3");
+ expect(filterComplex).toContain("trim=start=7:end=12");
+ });
+
+ it("each segment resets timestamps with setpts=PTS-STARTPTS", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 2, end: 5 }],
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("setpts=PTS-STARTPTS");
+ });
+
+ it("uses concat filter with correct n= count — 2 segments", () => {
+ const recipe = base({
+ jumpCutSegments: [
+ { start: 0, end: 2 },
+ { start: 5, end: 8 },
+ ],
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("concat=n=2:v=1:a=0");
+ });
+
+ it("uses concat filter with correct n= count — 3 segments", () => {
+ const recipe = base({
+ jumpCutSegments: [
+ { start: 0, end: 2 },
+ { start: 5, end: 8 },
+ { start: 10, end: 14 },
+ ],
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("concat=n=3:v=1:a=0");
+ });
+
+ it("includes a=1 in concat when hasAudio is true", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 3 }, { start: 6, end: 10 }],
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
+ expect(filterComplex).toContain("concat=n=2:v=1:a=1");
+ });
+
+ it("includes atrim for each audio segment when hasAudio is true", () => {
+ const recipe = base({
+ jumpCutSegments: [
+ { start: 1, end: 4 },
+ { start: 8, end: 11 },
+ ],
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
+ expect(filterComplex).toContain("atrim=start=1:end=4");
+ expect(filterComplex).toContain("atrim=start=8:end=11");
+ });
+
+ it("does NOT include atrim when hasAudio is false", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 5 }],
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).not.toContain("atrim");
+ });
+
+ it("applies speed (atempo) to each audio segment", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 5 }],
+ speed: 1.5,
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
+ expect(filterComplex).toContain("atempo=1.5000");
+ });
+
+ it("output labels are [vout] and [aout] with audio", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 3 }],
+ });
+ const { videoOut, audioOut } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
+ expect(videoOut).toBe("[vout]");
+ expect(audioOut).toBe("[aout]");
+ });
+
+ it("audioOut is empty string when hasAudio is false", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 3 }],
+ });
+ const { audioOut } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(audioOut).toBe("");
+ });
+
+ it("applies rotation to each segment", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 5 }],
+ rotate: 90,
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("transpose=1");
+ });
+
+ it("applies eq filter to each segment when color adjustments are non-neutral", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 5 }],
+ brightness: 0.2,
+ contrast: 1.1,
+ saturation: 0.9,
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("eq=brightness=0.2:contrast=1.1:saturation=0.9");
+ });
+
+ it("applies fit framing (scale+pad) to each segment", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 5 }],
+ framing: "fit",
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("force_original_aspect_ratio=decrease");
+ expect(filterComplex).toContain("pad=1280:720");
+ });
+
+ it("applies fill framing (scale+crop) to each segment", () => {
+ const recipe = base({
+ jumpCutSegments: [{ start: 0, end: 5 }],
+ framing: "fill",
+ });
+ const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
+ expect(filterComplex).toContain("force_original_aspect_ratio=increase");
+ expect(filterComplex).toContain("crop=1280:720");
+ });
+});
\ No newline at end of file
diff --git a/src/hooks/useAudioWaveform.ts b/src/hooks/useAudioWaveform.ts
index 25ed57d5..653ec9b8 100644
--- a/src/hooks/useAudioWaveform.ts
+++ b/src/hooks/useAudioWaveform.ts
@@ -33,6 +33,7 @@ export function useAudioWaveform(
) {
const [waveform, setWaveform] = useState([]);
const [isLoading, setIsLoading] = useState(false);
+ const [duration, setDuration] = useState(0);
useEffect(() => {
let isCancelled = false;
@@ -66,10 +67,12 @@ export function useAudioWaveform(
if (!isCancelled) {
setWaveform(peaks);
+ setDuration(audioBuffer.duration);
}
} catch {
if (!isCancelled) {
setWaveform([]);
+ setDuration(0);
}
} finally {
await audioContext?.close();
@@ -86,5 +89,5 @@ export function useAudioWaveform(
};
}, [barCount, file]);
- return { waveform, isLoading };
+ return { waveform, isLoading, duration };
}
diff --git a/src/hooks/useSilenceDetection.ts b/src/hooks/useSilenceDetection.ts
new file mode 100644
index 00000000..5abc5947
--- /dev/null
+++ b/src/hooks/useSilenceDetection.ts
@@ -0,0 +1,66 @@
+import { useEffect, useState } from "react";
+import { useAudioWaveform } from "./useAudioWaveform";
+
+export function useSilenceDetection(
+ file: File | null,
+ threshold: number = 0.02,
+ minSilenceDuration: number = 0.5
+) {
+ const { waveform, duration } = useAudioWaveform(file);
+
+ const [silentSegments, setSilentSegments] = useState<
+ Array<{ start: number; end: number }>
+ >([]);
+
+ useEffect(() => {
+ if (!waveform.length || !duration) return;
+
+ const segments: Array<{ start: number; end: number }> = [];
+
+ let inSilence = false;
+ let silenceStart = 0;
+
+ waveform.forEach((amplitude, index) => {
+ // normalized waveform values: 0 → 1
+ const isSilent = Math.abs(amplitude) < threshold;
+
+ const time = (index / waveform.length) * duration;
+
+ if (isSilent && !inSilence) {
+ silenceStart = time;
+ inSilence = true;
+ }
+
+ else if (!isSilent && inSilence) {
+ const silenceDuration = time - silenceStart;
+
+ if (silenceDuration >= minSilenceDuration) {
+ segments.push({
+ start: silenceStart,
+ end: time,
+ });
+ }
+
+ inSilence = false;
+ }
+ });
+
+ // handle silence at end of clip
+ if (inSilence) {
+ const silenceDuration = duration - silenceStart;
+
+ if (silenceDuration >= minSilenceDuration) {
+ segments.push({
+ start: silenceStart,
+ end: duration,
+ });
+ }
+ }
+
+ console.log("Detected silence segments:", segments);
+
+ setSilentSegments(segments);
+ }, [waveform, duration, threshold, minSilenceDuration]);
+
+ return { silentSegments };
+}
\ No newline at end of file
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index e713f779..2011b201 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -4,6 +4,7 @@ import { RECIPE_VERSION } from "./types"
export const SPEED_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4] as const;
export const DEFAULT_RECIPE: EditRecipe = {
+ textOverlays: [],
preset: "vertical-9-16",
customWidth: 1920,
customHeight: 1080,
@@ -22,6 +23,7 @@ export const DEFAULT_RECIPE: EditRecipe = {
denoise: false,
soundOnCompletion: false,
normalizeAudio: false,
+ jumpCutSegments: undefined,
textOverlays: [],
version: RECIPE_VERSION,
-};
+};
\ No newline at end of file
diff --git a/src/lib/ffmpef.worker.ts b/src/lib/ffmpef.worker.ts
new file mode 100644
index 00000000..2e69998e
--- /dev/null
+++ b/src/lib/ffmpef.worker.ts
@@ -0,0 +1,813 @@
+import { FFmpeg } from "@ffmpeg/ffmpeg";
+import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions } from "./types";
+import { getPresetById } from "./presets";
+import { buildTextFilter } from "./text-overlay"; // Import the real implementation
+
+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";
+const SRI_HASHES: Record = {
+ "ffmpeg-core.js": "sha384-sKfkiFtvUk+vexk+0EUhEh366190/4WpgUAsUvaxEfyg7+E1Zt5Y5hrsU808g8Q9",
+ "ffmpeg-core.wasm": "sha384-U1VDhkPYrM3wTCT4/vjSpSsKqG/UjljYrYCI4hBSJ02svbCkxuCi6U6u/peg5vpW",
+};
+
+type SerializedFile = {
+ name: string;
+ type: string;
+ data: ArrayBuffer;
+};
+
+type ExportRequest = {
+ type: "export";
+ id: string;
+ file: SerializedFile;
+ recipe: EditRecipe;
+ videoDuration: number;
+ musicFile?: SerializedFile;
+ musicOptions?: BackgroundMusicOptions;
+ overlayFile?: SerializedFile;
+ overlayOptions?: ImageOverlayOptions;
+};
+
+type LoadRequest = { type: "load" };
+type CancelRequest = { type: "cancel" };
+type TerminateRequest = { type: "terminate" };
+type WorkerCommand = LoadRequest | ExportRequest | CancelRequest | TerminateRequest;
+
+type ProgressPayload = { type: "progress"; percent: number };
+type ReadyPayload = { type: "ready" };
+type ResultPayload = {
+ type: "result";
+ id: string;
+ data: ArrayBuffer;
+ mimeType: string;
+ size: number;
+ width: number;
+ height: number;
+ format: "mp4" | "webm" | "mkv" | "gif";
+};
+type ErrorPayload = { type: "error"; id?: string; message: string };
+type CancelledPayload = { type: "cancelled"; id?: string };
+type WorkerResponse = ProgressPayload | ReadyPayload | ResultPayload | ErrorPayload | CancelledPayload;
+
+let ffmpeg: FFmpeg | null = null;
+let ffmpegLoaded = false;
+let activeExportAbortController: AbortController | null = null;
+let activeExportId: string | null = null;
+
+async function fetchWithIntegrity(url: string, mimeType: string): Promise {
+ const key = url.split("/").pop()!;
+ const integrity = SRI_HASHES[key];
+
+ if (!integrity) {
+ throw new Error(`[SRI] No hash found for: ${key}`);
+ }
+
+ const response = await fetch(url, { integrity, credentials: "omit" });
+ const blob = new Blob([await response.arrayBuffer()], { type: mimeType });
+ return URL.createObjectURL(blob);
+}
+
+// ─── Filter builders ──────────────────────────────────────────────────────────
+
+function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string {
+ const filters: string[] = [];
+
+ if (recipe.trimStart > 0 || recipe.trimEnd !== null) {
+ const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999;
+ filters.push(`trim=start=${recipe.trimStart}:end=${end}`);
+ filters.push("setpts=PTS-STARTPTS");
+ }
+
+ if (recipe.stabilization) {
+ filters.push("deshake");
+ }
+
+ 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(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`,
+ `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black`
+ );
+ } else {
+ filters.push(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`,
+ `crop=${targetW}:${targetH}`
+ );
+ }
+
+ if (recipe.speed !== 1) {
+ const pts = (1 / recipe.speed).toFixed(4);
+ filters.push(`setpts=${pts}*PTS`);
+ }
+
+ if (recipe.denoise) {
+ filters.push("hqdn3d=1.5:1.5:6:6");
+ }
+
+ const needsEq =
+ recipe.brightness !== 0 ||
+ recipe.contrast !== 1 ||
+ recipe.saturation !== 1;
+
+ if (needsEq) {
+ filters.push(
+ `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`
+ );
+ }
+
+ const textOverlays = recipe.textOverlays ?? [];
+ textOverlays.forEach((overlay) => {
+ filters.push(buildTextFilter(overlay, targetW, targetH));
+ });
+
+ return filters.join(",");
+}
+
+function buildAudioFilter(speed: number, normalizeAudio: boolean): string {
+ if (speed <= 0) return "";
+ 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))}`);
+ }
+
+ if (normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11");
+
+ return filters.join(",");
+}
+
+function buildAudioTrimFilter(recipe: EditRecipe): string {
+ if (recipe.trimStart === 0 && recipe.trimEnd === null) return "";
+ const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999;
+ return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`;
+}
+
+// ─── Jump-cut segment filter builders ────────────────────────────────────────
+
+function buildSegmentVideoFilter(
+ recipe: EditRecipe,
+ segStart: number,
+ segEnd: number,
+ targetW: number,
+ targetH: number
+): string {
+ const filters: string[] = [];
+
+ filters.push(`trim=start=${segStart}:end=${segEnd}`);
+
+ if (recipe.stabilization) filters.push("deshake");
+
+ 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(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`,
+ `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black`
+ );
+ } else {
+ filters.push(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`,
+ `crop=${targetW}:${targetH}`
+ );
+ }
+
+ // Reset timestamps — required before concat
+ filters.push("setpts=PTS-STARTPTS");
+
+ if (recipe.speed !== 1) {
+ const pts = (1 / recipe.speed).toFixed(4);
+ filters.push(`setpts=${pts}*PTS`);
+ }
+
+ if (recipe.denoise) filters.push("hqdn3d=1.5:1.5:6:6");
+
+ const needsEq =
+ recipe.brightness !== 0 ||
+ recipe.contrast !== 1 ||
+ recipe.saturation !== 1;
+ if (needsEq) {
+ filters.push(
+ `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`
+ );
+ }
+
+ (recipe.textOverlays ?? []).forEach((overlay) => {
+ filters.push(buildTextFilter(overlay, targetW, targetH));
+ });
+
+ return filters.join(",");
+}
+
+function buildSegmentAudioFilter(
+ recipe: EditRecipe,
+ segStart: number,
+ segEnd: number
+): string {
+ const parts: string[] = [];
+ parts.push(`atrim=start=${segStart}:end=${segEnd}`);
+ parts.push("asetpts=PTS-STARTPTS");
+ if (recipe.speed !== 1) {
+ parts.push(`atempo=${recipe.speed.toFixed(4)}`);
+ }
+ if (recipe.normalizeAudio) parts.push("loudnorm=I=-14:TP=-1.5:LRA=11");
+ return parts.join(",");
+}
+
+function buildJumpCutFilterComplex(
+ recipe: EditRecipe,
+ targetW: number,
+ targetH: number,
+ hasAudio: boolean
+): { filterComplex: string; videoOut: string; audioOut: string } {
+ const segments = recipe.jumpCutSegments;
+ if (!segments || segments.length === 0) {
+ return { filterComplex: "", videoOut: "", audioOut: "" };
+ }
+
+ const parts: string[] = [];
+ segments.forEach((seg, i) => {
+ const vf = buildSegmentVideoFilter(recipe, seg.start, seg.end, targetW, targetH);
+ parts.push(`[0:v]${vf}[v${i}]`);
+ if (hasAudio) {
+ const af = buildSegmentAudioFilter(recipe, seg.start, seg.end);
+ parts.push(`[0:a]${af}[a${i}]`);
+ }
+ });
+
+ const vPads = segments.map((_, i) => `[v${i}]`).join("");
+ const aPads = hasAudio ? segments.map((_, i) => `[a${i}]`).join("") : "";
+ const n = segments.length;
+ const aFlag = hasAudio ? 1 : 0;
+
+ parts.push(
+ `${vPads}${aPads}concat=n=${n}:v=1:a=${aFlag}[vout]${hasAudio ? "[aout]" : ""}`
+ );
+
+ return {
+ filterComplex: parts.join(";"),
+ videoOut: "[vout]",
+ audioOut: hasAudio ? "[aout]" : "",
+ };
+}
+
+// ─── Argument builder ─────────────────────────────────────────────────────────
+
+function buildArguments(
+ recipe: EditRecipe,
+ format: "mp4" | "webm" | "mkv" | "gif",
+ outputName: string,
+ inputName: string,
+ targetW: number,
+ targetH: number,
+ hasMusicTrack: boolean,
+ musicInputName: string,
+ musicOptions: BackgroundMusicOptions | undefined,
+ hasOverlay: boolean,
+ overlayInputName: string,
+ overlayOptions: ImageOverlayOptions | undefined,
+ hasOriginalAudio: boolean,
+ videoDuration: number
+): string[] {
+ const hasJumpCuts = (recipe.jumpCutSegments?.length ?? 0) > 0;
+
+ const args: string[] = [];
+ args.push("-i", inputName);
+
+ if (hasMusicTrack) {
+ if (musicOptions!.loopMusic) args.push("-stream_loop", "-1");
+ args.push("-i", musicInputName);
+ }
+ if (hasOverlay) {
+ args.push("-i", overlayInputName);
+ }
+
+ const musicIdx = 1;
+ const overlayIdx = hasMusicTrack ? 2 : 1;
+ const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack);
+
+ // ── Jump-cut path ──────────────────────────────────────────────────────────
+ if (hasJumpCuts) {
+ const filterParts: string[] = [];
+
+ const { filterComplex: jumpCutFC, videoOut: concatV, audioOut: concatA } =
+ buildJumpCutFilterComplex(recipe, targetW, targetH, hasOriginalAudio);
+
+ filterParts.push(jumpCutFC);
+
+ let videoOut = concatV; // "[vout]" from concat
+ if (hasOverlay) {
+ const scaledW = overlayOptions!.size;
+ const alpha = (overlayOptions!.opacity / 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";
+ filterParts.push(
+ `[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`
+ );
+ filterParts.push(`${videoOut}[logo]overlay=${pos}[vfinal]`);
+ videoOut = "[vfinal]";
+ }
+
+ let audioOut = concatA; // "[aout]" or "" when no original audio
+ if (shouldKeepAudio && hasMusicTrack) {
+ const musicVol = (musicOptions!.musicVolume / 100).toFixed(2);
+ if (hasOriginalAudio && concatA) {
+ const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2);
+ filterParts.push(`${concatA}volume=${origVol}[orig]`);
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`);
+ filterParts.push(
+ `[orig][music]amix=inputs=2:duration=first:dropout_transition=0[afinal]`
+ );
+ audioOut = "[afinal]";
+ } else {
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[afinal]`);
+ audioOut = "[afinal]";
+ }
+ }
+
+ args.push("-filter_complex", filterParts.join(";"));
+ args.push("-map", videoOut);
+
+ if (!shouldKeepAudio) {
+ args.push("-an");
+ } else if (audioOut) {
+ args.push("-map", audioOut);
+ }
+
+ // ── filter_complex path (overlay and/or music, no jump cuts) ──────────────
+ } else if (hasOverlay || hasMusicTrack) {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
+ const audioSpeed = hasOriginalAudio
+ ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false)
+ : "";
+ const afParts = [audioTrim, audioSpeed].filter(Boolean);
+
+ const filterParts: string[] = [];
+ let videoOut = "[0:v]";
+
+ if (vf) {
+ filterParts.push(`[0:v]${vf}[vbase]`);
+ videoOut = "[vbase]";
+ }
+
+ if (hasOverlay) {
+ const scaledW = overlayOptions!.size;
+ const alpha = (overlayOptions!.opacity / 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";
+ filterParts.push(
+ `[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`
+ );
+ filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`);
+ videoOut = "[vout]";
+ }
+
+ let audioOut = "";
+ if (shouldKeepAudio) {
+ if (hasMusicTrack) {
+ const musicVol = (musicOptions!.musicVolume / 100).toFixed(2);
+ if (hasOriginalAudio) {
+ const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2);
+ const origChain = afParts.length > 0
+ ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]`
+ : `[0:a]volume=${origVol}[orig]`;
+ filterParts.push(origChain);
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`);
+ filterParts.push(
+ `[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`
+ );
+ audioOut = "[aout]";
+ } else {
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`);
+ audioOut = "[aout]";
+ }
+ } else if (hasOriginalAudio && afParts.length > 0) {
+ filterParts.push(`[0:a]${afParts.join(",")}[aout]`);
+ audioOut = "[aout]";
+ }
+ }
+
+ if (filterParts.length > 0) {
+ args.push("-filter_complex", filterParts.join(";"));
+ }
+ args.push("-map", videoOut === "[0:v]" ? "0:v" : videoOut);
+
+ if (!shouldKeepAudio) {
+ args.push("-an");
+ } else if (audioOut) {
+ args.push("-map", audioOut);
+ } else if (hasOriginalAudio) {
+ args.push("-map", "0:a");
+ }
+
+ // ── Simple path (no filter_complex needed) ─────────────────────────────────
+ } else {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
+ const audioSpeed = hasOriginalAudio
+ ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false)
+ : "";
+ const afParts = [audioTrim, audioSpeed].filter(Boolean);
+ const af = afParts.join(",");
+
+ if (vf) args.push("-vf", vf);
+ if (!shouldKeepAudio) {
+ args.push("-an");
+ } else if (af && hasOriginalAudio) {
+ args.push("-af", af);
+ }
+ }
+
+ // ── Codec flags ────────────────────────────────────────────────────────────
+ if (format === "webm") {
+ args.push(
+ "-c:v", "libvpx-vp9",
+ "-b:v", "0",
+ "-crf", String(recipe.quality),
+ "-cpu-used", "4",
+ "-deadline", "realtime"
+ );
+ if (shouldKeepAudio) args.push("-c:a", "libopus");
+ } else if (format === "mkv") {
+ args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast");
+ if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
+ } else {
+ args.push(
+ "-c:v", "libx264",
+ "-crf", String(recipe.quality),
+ "-preset", "ultrafast",
+ "-movflags", "+faststart"
+ );
+ if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
+ }
+
+ // Output duration cap — only for non-jump-cut speed changes
+ if (!hasJumpCuts && recipe.speed !== 1) {
+ const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
+ const outputDuration = sourceDuration / recipe.speed;
+ args.push("-t", outputDuration.toFixed(6));
+ }
+
+ args.push(outputName);
+ return args;
+}
+
+// ─── FFmpeg core loader ───────────────────────────────────────────────────────
+
+async function loadCore(onProgress?: (percent: number) => void): Promise {
+ if (ffmpegLoaded) {
+ onProgress?.(100);
+ return;
+ }
+
+ ffmpeg = new FFmpeg();
+
+ const isIsolated = typeof self !== "undefined" && self.crossOriginIsolated;
+ const baseURL = isIsolated ? MT_CORE_BASE_URL : CORE_BASE_URL;
+
+ const handleProgress = ({ progress }: { progress: number }) => {
+ onProgress?.(Math.round(progress * 100));
+ };
+
+ ffmpeg.on("progress", handleProgress);
+
+ try {
+ await ffmpeg.load({
+ coreURL: await fetchWithIntegrity(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
+ wasmURL: await fetchWithIntegrity(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
+ ...(isIsolated && {
+ workerURL: await fetchWithIntegrity(`${baseURL}/ffmpeg-core.worker.js`, "text/javascript"),
+ }),
+ });
+
+ ffmpegLoaded = true;
+ onProgress?.(100);
+ } finally {
+ ffmpeg.off("progress", handleProgress);
+ }
+}
+
+function serializeFileBuffer(file: SerializedFile): Uint8Array {
+ return new Uint8Array(file.data);
+}
+
+function getOutputConfig(format: string, sessionId: string) {
+ switch (format) {
+ case "webm":
+ return { filename: `output_${sessionId}.webm`, mimeType: "video/webm" };
+ case "mkv":
+ return { filename: `output_${sessionId}.mkv`, mimeType: "video/x-matroska" };
+ case "gif":
+ return { filename: `output_${sessionId}.gif`, mimeType: "image/gif" };
+ default:
+ return { filename: `output_${sessionId}.mp4`, mimeType: "video/mp4" };
+ }
+}
+
+async function removeFile(path: string) {
+ if (!ffmpeg) return;
+ try {
+ await ffmpeg.deleteFile(path);
+ } catch {
+ // ignore cleanup failures
+ }
+}
+
+// ─── Export runner ────────────────────────────────────────────────────────────
+
+async function runExport(request: ExportRequest): Promise {
+ if (!ffmpeg) throw new Error("FFmpeg engine is not loaded.");
+ if (activeExportAbortController?.signal.aborted) {
+ throw new Error("Export cancelled");
+ }
+
+ const sessionId = request.id;
+ const recipe = request.recipe;
+ let targetW: number;
+ let targetH: number;
+
+ if (recipe.preset === "custom") {
+ targetW = recipe.customWidth;
+ targetH = recipe.customHeight;
+ } else {
+ const preset = getPresetById(recipe.preset);
+ targetW = preset?.width ?? 1920;
+ targetH = preset?.height ?? 1080;
+ }
+
+ targetW = Math.round(targetW / 2) * 2;
+ targetH = Math.round(targetH / 2) * 2;
+
+ const ext = request.file.name.split(".").pop() ?? "mp4";
+ const inputName = `input_${sessionId}.${ext}`;
+
+ const { filename: outputName, mimeType } = getOutputConfig(recipe.format, sessionId);
+ const fallbackOutputName = `fallback_${sessionId}.webm`;
+ const paletteName = `palette_${sessionId}.png`;
+ const cleanupFiles = new Set([inputName, outputName, fallbackOutputName, paletteName]);
+
+ const fileBytes = serializeFileBuffer(request.file);
+ await ffmpeg.writeFile(inputName, fileBytes, { signal: activeExportAbortController?.signal });
+
+ const hasMusicTrack = !!(request.musicFile && recipe.keepAudio);
+ const musicInputName = `music_input_${sessionId}.mp3`;
+ if (hasMusicTrack) {
+ cleanupFiles.add(musicInputName);
+ await ffmpeg.writeFile(musicInputName, serializeFileBuffer(request.musicFile!), {
+ signal: activeExportAbortController?.signal,
+ });
+ }
+
+ const hasOverlay = !!request.overlayFile;
+ const overlayExt = request.overlayFile?.name.split(".").pop() ?? "png";
+ const overlayInputName = `overlay_${sessionId}.${overlayExt}`;
+ if (hasOverlay) {
+ cleanupFiles.add(overlayInputName);
+ await ffmpeg.writeFile(overlayInputName, serializeFileBuffer(request.overlayFile!), {
+ signal: activeExportAbortController?.signal,
+ });
+ }
+
+ const videoDuration = request.videoDuration;
+
+ const handleProgress = ({ progress }: { progress: number }) => {
+ if (activeExportId !== sessionId) return;
+ postMessage({ type: "progress", percent: Math.min(99, Math.round(progress * 100)) });
+ };
+
+ let logListener: ((event: { message: string }) => void) | null = null;
+ ffmpeg.on("progress", handleProgress);
+
+ 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 gifDurationArgs = recipe.speed !== 1
+ ? (() => {
+ const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
+ const outputDuration = sourceDuration / recipe.speed;
+ return ["-t", outputDuration.toFixed(6)];
+ })()
+ : [];
+
+ const pass1Code = await ffmpeg.exec(
+ ["-i", inputName, "-vf", vfWithPalette, ...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],
+ undefined,
+ { signal: activeExportAbortController?.signal }
+ );
+ if (pass2Code !== 0) throw new Error("GIF export failed");
+
+ const data = await ffmpeg.readFile(outputName, undefined, {
+ signal: activeExportAbortController?.signal,
+ });
+ const payload = (data as Uint8Array).buffer as ArrayBuffer;
+ return {
+ type: "result",
+ id: sessionId,
+ data: payload,
+ mimeType: "image/gif",
+ size: payload.byteLength,
+ width: targetW,
+ height: targetH,
+ format: "gif",
+ };
+ }
+
+ let missingAudioDetected = false;
+ logListener = ({ message }: { message: string }) => {
+ const msg = message.toLowerCase();
+ if (
+ msg.includes("matches no streams") ||
+ msg.includes("specifier '0:a'") ||
+ msg.includes("input pad 0 on filter src")
+ ) {
+ missingAudioDetected = true;
+ }
+ };
+ ffmpeg.on("log", logListener);
+
+ let args = buildArguments(
+ recipe, recipe.format, outputName, inputName, targetW, targetH,
+ hasMusicTrack, musicInputName, request.musicOptions,
+ hasOverlay, overlayInputName, request.overlayOptions,
+ true, videoDuration
+ );
+
+ let exitCode = await ffmpeg.exec(args, undefined, {
+ signal: activeExportAbortController?.signal,
+ });
+
+ // Auto-recover: file has no original audio track
+ if (exitCode !== 0 && missingAudioDetected) {
+ missingAudioDetected = false;
+ args = buildArguments(
+ recipe, recipe.format, outputName, inputName, targetW, targetH,
+ hasMusicTrack, musicInputName, request.musicOptions,
+ hasOverlay, overlayInputName, request.overlayOptions,
+ false, videoDuration
+ );
+ exitCode = await ffmpeg.exec(args, undefined, {
+ signal: activeExportAbortController?.signal,
+ });
+ }
+
+ // Fallback: switch to WebM if container errors occur
+ if (exitCode !== 0) {
+ args = buildArguments(
+ recipe, "webm", fallbackOutputName, inputName, targetW, targetH,
+ hasMusicTrack, musicInputName, request.musicOptions,
+ hasOverlay, overlayInputName, request.overlayOptions,
+ !missingAudioDetected, videoDuration
+ );
+
+ const fallbackCode = await ffmpeg.exec(args, undefined, {
+ signal: activeExportAbortController?.signal,
+ });
+ if (fallbackCode !== 0) throw new Error("Export failed");
+
+ const data = await ffmpeg.readFile(fallbackOutputName, undefined, {
+ signal: activeExportAbortController?.signal,
+ });
+ const payload = (data as Uint8Array).buffer as ArrayBuffer;
+ return {
+ type: "result",
+ id: sessionId,
+ data: payload,
+ mimeType: "video/webm",
+ size: payload.byteLength,
+ width: targetW,
+ height: targetH,
+ format: "webm",
+ };
+ }
+
+ const data = await ffmpeg.readFile(outputName, undefined, {
+ signal: activeExportAbortController?.signal,
+ });
+ const payload = (data as Uint8Array).buffer as ArrayBuffer;
+ return {
+ type: "result",
+ id: sessionId,
+ data: payload,
+ mimeType,
+ size: payload.byteLength,
+ width: targetW,
+ height: targetH,
+ format: recipe.format,
+ };
+ } finally {
+ ffmpeg.off("progress", handleProgress);
+ if (logListener) ffmpeg.off("log", logListener);
+ for (const path of cleanupFiles) {
+ await removeFile(path);
+ }
+ }
+}
+
+// ─── Message handler ──────────────────────────────────────────────────────────
+
+async function handleCommand(message: WorkerCommand) {
+ switch (message.type) {
+ case "load": {
+ try {
+ await loadCore();
+ postMessage({ type: "ready" });
+ } catch (error) {
+ postMessage({ type: "error", message: (error as Error).message });
+ }
+ return;
+ }
+ case "export": {
+ if (!ffmpeg) {
+ postMessage({ type: "error", id: message.id, message: "FFmpeg engine is not loaded." });
+ return;
+ }
+ if (activeExportAbortController?.signal.aborted) {
+ postMessage({ type: "cancelled", id: message.id });
+ return;
+ }
+
+ activeExportAbortController = new AbortController();
+ activeExportId = message.id;
+
+ try {
+ const result = await runExport(message);
+ if (activeExportAbortController?.signal.aborted) {
+ postMessage({ type: "cancelled", id: message.id });
+ return;
+ }
+ postMessage({ ...result }, [result.data]);
+ } catch (error) {
+ if (activeExportAbortController?.signal.aborted) {
+ postMessage({ type: "cancelled", id: message.id });
+ } else {
+ postMessage({ type: "error", id: message.id, message: (error as Error).message });
+ }
+ } finally {
+ activeExportAbortController = null;
+ activeExportId = null;
+ }
+ return;
+ }
+ case "cancel": {
+ if (activeExportAbortController && !activeExportAbortController.signal.aborted) {
+ activeExportAbortController.abort();
+ }
+ return;
+ }
+ case "terminate": {
+ if (ffmpeg) ffmpeg.terminate();
+ ffmpeg = null;
+ ffmpegLoaded = false;
+ self.close();
+ return;
+ }
+ }
+}
+
+self.addEventListener("message", (event) => {
+ handleCommand(event.data as WorkerCommand).catch((error) => {
+ postMessage({ type: "error", message: (error as Error).message });
+ });
+});
\ No newline at end of file
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index 5cac4044..48e92c3f 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -4,28 +4,14 @@ import { buildTextFilter } from "./text-overlay";
export class FFmpegLoadError extends Error {}
-const FFMPEG_WORKER_URL =
- typeof window !== "undefined"
- ? new URL("./ffmpeg.worker.ts", import.meta.url)
- : null;
-
-type SerializedFile = {
- name: string;
- type: string;
- data: ArrayBuffer;
+const SRI_HASHES: Record = {
+ "ffmpeg-core.js": "sha384-sKfkiFtvUk+vexk+0EUhEh366190/4WpgUAsUvaxEfyg7+E1Zt5Y5hrsU808g8Q9",
+ "ffmpeg-core.wasm": "sha384-U1VDhkPYrM3wTCT4/vjSpSsKqG/UjljYrYCI4hBSJ02svbCkxuCi6U6u/peg5vpW",
};
-type WorkerExportRequest = {
- type: "export";
- id: string;
- file: SerializedFile;
- recipe: EditRecipe;
- videoDuration: number;
- musicFile?: SerializedFile;
- musicOptions?: BackgroundMusicOptions;
- overlayFile?: SerializedFile;
- overlayOptions?: ImageOverlayOptions;
-};
+async function fetchWithIntegrity(url: string, mimeType: string): Promise {
+ const key = url.split("/").pop()!;
+ const integrity = SRI_HASHES[key];
type WorkerLoadResponse = { type: "ready" };
type WorkerProgressResponse = { type: "progress"; percent: number };
@@ -132,27 +118,10 @@ function handleWorkerMessage(event: MessageEvent) {
return;
}
- workerReadyReject?.(new FFmpegLoadError(data.message));
- workerReady = null;
- workerReadyResolve = null;
- workerReadyReject = null;
- resetWorker();
- return;
- }
-
- if (data.type === "cancelled") {
- if (data.id && pendingExport?.id === data.id) {
- pendingExport.reject(new DOMException("Export cancelled", "AbortError"));
- pendingExport = null;
- pendingProgress = null;
- }
- return;
- }
-}
-
-async function ensureWorker() {
- if (!ffmpegWorker) {
- createWorker();
+export class FFmpegLoadError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "FFmpegLoadError";
}
}
@@ -323,6 +292,113 @@ function buildSessionId(): string {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
+function buildSegmentVideoFilter(
+ recipe: EditRecipe,
+ segStart: number,
+ segEnd: number,
+ targetW: number,
+ targetH: number
+): string {
+ const filters: string[] = [];
+
+ filters.push(`trim=start=${segStart}:end=${segEnd}`);
+
+ if (recipe.stabilization) filters.push("deshake");
+
+ 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(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`,
+ `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black`
+ );
+ } else {
+ filters.push(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`,
+ `crop=${targetW}:${targetH}`
+ );
+ }
+
+ filters.push("setpts=PTS-STARTPTS");
+
+ if (recipe.speed !== 1) {
+ const pts = (1 / recipe.speed).toFixed(4);
+ filters.push(`setpts=${pts}*PTS`);
+ }
+
+ if (recipe.denoise) filters.push("hqdn3d=1.5:1.5:6:6");
+
+ const needsEq =
+ recipe.brightness !== 0 ||
+ recipe.contrast !== 1 ||
+ recipe.saturation !== 1;
+ if (needsEq) {
+ filters.push(
+ `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`
+ );
+ }
+
+ (recipe.textOverlays ?? []).forEach((overlay: any) => {
+ filters.push(buildTextFilter(overlay, targetW, targetH));
+ });
+
+ return filters.join(",");
+}
+
+function buildSegmentAudioFilter(
+ recipe: EditRecipe,
+ segStart: number,
+ segEnd: number
+): string {
+ const parts: string[] = [];
+ parts.push(`atrim=start=${segStart}:end=${segEnd}`);
+ parts.push("asetpts=PTS-STARTPTS");
+ if (recipe.speed !== 1) {
+ const rate = recipe.speed.toFixed(4);
+ parts.push(`atempo=${rate}`);
+ }
+ if (recipe.normalizeAudio) parts.push("loudnorm");
+ return parts.join(",");
+}
+
+export function buildJumpCutFilterComplex(
+ recipe: EditRecipe,
+ targetW: number,
+ targetH: number,
+ hasAudio: boolean
+): { filterComplex: string; videoOut: string; audioOut: string } {
+ const segments = recipe.jumpCutSegments;
+ if (!segments || segments.length === 0) {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const af = hasAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : "";
+ return { filterComplex: "", videoOut: vf ? "" : "", audioOut: af };
+ }
+ const parts: string[] = [];
+ segments.forEach((seg, i) => {
+ const vf = buildSegmentVideoFilter(recipe, seg.start, seg.end, targetW, targetH);
+ parts.push(`[0:v]${vf}[v${i}]`);
+ if (hasAudio) {
+ const af = buildSegmentAudioFilter(recipe, seg.start, seg.end);
+ parts.push(`[0:a]${af}[a${i}]`);
+ }
+ });
+ const vPads = segments.map((_, i) => `[v${i}]`).join("");
+ const aPads = hasAudio ? segments.map((_, i) => `[a${i}]`).join("") : "";
+ const n = segments.length;
+ const aFlag = hasAudio ? 1 : 0;
+
+ parts.push(
+ `${vPads}${aPads}concat=n=${n}:v=1:a=${aFlag}[vout]${hasAudio ? "[aout]" : ""}`
+ );
+ return {
+ filterComplex: parts.join(";"),
+ videoOut: "[vout]",
+ audioOut: hasAudio ? "[aout]" : "",
+ };
+}
+
export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string {
const filters: string[] = [];
@@ -420,6 +496,206 @@ function buildAudioTrimFilter(recipe: EditRecipe): string {
return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`;
}
+export async function exportVideo(
+ ffmpeg: FFmpeg,
+ file: File,
+ recipe: EditRecipe,
+ onProgress: (percent: number) => void,
+ signal?: AbortSignal,
+ musicOptions?: BackgroundMusicOptions,
+ overlayOptions?: ImageOverlayOptions
+): Promise {
+ const sessionId = buildSessionId();
+ let targetW: number, targetH: number;
+ if (recipe.preset === "custom") {
+ targetW = recipe.customWidth;
+ targetH = recipe.customHeight;
+ } else {
+ const preset = getPresetById(recipe.preset);
+ targetW = preset?.width ?? 1920;
+ targetH = preset?.height ?? 1080;
+ }
+
+ targetW = Math.round(targetW / 2) * 2;
+ targetH = Math.round(targetH / 2) * 2;
+
+ const ext = file.name.split(".").pop() ?? "mp4";
+ const inputName = `input_${sessionId}.${ext}`;
+
+ const getOutputConfig = (format: string) => {
+ switch (format) {
+ case "webm":
+ return { filename: `output_${sessionId}.webm`, mimeType: "video/webm" };
+ case "mkv":
+ return { filename: `output_${sessionId}.mkv`, mimeType: "video/x-matroska" };
+ case "gif":
+ return { filename: `output_${sessionId}.gif`, mimeType: "image/gif" };
+ default:
+ return { filename: `output_${sessionId}.mp4`, mimeType: "video/mp4" };
+ }
+ };
+
+ const { filename: outputName, mimeType } = getOutputConfig(recipe.format);
+ const fallbackOutputName = `fallback_${sessionId}.webm`;
+ const paletteName = `palette_${sessionId}.png`;
+ const cleanupFiles = new Set([inputName, outputName, fallbackOutputName, paletteName]);
+
+ const handleProgress = ({ progress }: { progress: number }) => {
+ onProgress(Math.min(99, Math.round(progress * 100)));
+ };
+
+ try {
+ await ffmpeg.writeFile(inputName, await fetchFile(file), { signal });
+
+ const videoDuration = await new Promise((resolve, reject) => {
+ const videoElement = document.createElement("video");
+ videoElement.src = URL.createObjectURL(file);
+ videoElement.onloadedmetadata = () => {
+ resolve(videoElement.duration);
+ URL.revokeObjectURL(videoElement.src);
+ };
+ videoElement.onerror = () => {
+ reject(new Error("Failed to load video metadata"));
+ URL.revokeObjectURL(videoElement.src);
+ };
+ });
+
+ const hasMusicTrack = !!(musicOptions?.file && recipe.keepAudio);
+ const musicInputName = `music_input_${sessionId}.mp3`;
+ if (hasMusicTrack) {
+ await ffmpeg.writeFile(musicInputName, await fetchFile(musicOptions!.file!), { signal });
+ cleanupFiles.add(musicInputName);
+ }
+
+ const hasOverlay = !!(overlayOptions?.file);
+ const overlayExt = overlayOptions?.file?.name.split(".").pop() ?? "png";
+ const overlayInputName = `overlay_${sessionId}.${overlayExt}`;
+ if (hasOverlay) {
+ await ffmpeg.writeFile(overlayInputName, await fetchFile(overlayOptions!.file!), { signal });
+ cleanupFiles.add(overlayInputName);
+ }
+
+ ffmpeg.on("progress", handleProgress);
+
+ // GIF export path
+ 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 pass1Code = await ffmpeg.exec(
+ ["-i", inputName, "-vf", vfWithPalette, "-y", paletteName],
+ undefined,
+ { signal }
+ );
+ if (pass1Code !== 0) throw new Error("GIF palette generation failed");
+
+ const pass2Code = await ffmpeg.exec(
+ ["-i", inputName, "-i", paletteName, "-lavfi", vfWithPaletteUse, "-y", outputName],
+ undefined,
+ { signal }
+ );
+ if (pass2Code !== 0) throw new Error("GIF export failed");
+
+ const data = await ffmpeg.readFile(outputName, undefined, { signal });
+ const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "image/gif" });
+
+ ffmpeg.off("progress", handleProgress);
+ onProgress(100);
+ return {
+ blobUrl: URL.createObjectURL(blob),
+ blob,
+ size: blob.size,
+ width: targetW,
+ height: targetH,
+ format: "gif" as const,
+ };
+ }
+
+ let missingAudioDetected = false;
+ const logListener = ({ message }: { message: string }) => {
+ const msg = message.toLowerCase();
+ if (
+ msg.includes("matches no streams") ||
+ msg.includes("specifier '0:a'") ||
+ msg.includes("input pad 0 on filter src")
+ ) {
+ missingAudioDetected = true;
+ }
+ };
+ ffmpeg.on("log", logListener);
+
+ // Attempt 1: Process with original audio
+ let args = buildArguments(
+ recipe, recipe.format, outputName, inputName, targetW, targetH,
+ hasMusicTrack, musicInputName, musicOptions,
+ hasOverlay, overlayInputName, overlayOptions, true, videoDuration
+ );
+
+ let exitCode = await ffmpeg.exec(args, undefined, { signal });
+
+ // Attempt 2: Auto-recover if no original audio
+ if (exitCode !== 0 && missingAudioDetected) {
+ missingAudioDetected = false;
+ args = buildArguments(
+ recipe, recipe.format, outputName, inputName, targetW, targetH,
+ hasMusicTrack, musicInputName, musicOptions,
+ hasOverlay, overlayInputName, overlayOptions, false, videoDuration
+ );
+ exitCode = await ffmpeg.exec(args, undefined, { signal });
+ }
+
+ // Fallback: Try WebM
+ if (exitCode !== 0) {
+ args = buildArguments(
+ recipe, "webm", fallbackOutputName, inputName, targetW, targetH,
+ hasMusicTrack, musicInputName, musicOptions,
+ hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected, videoDuration
+ );
+
+ const fallbackCode = await ffmpeg.exec(args, undefined, { signal });
+ if (fallbackCode !== 0) throw new Error("Export failed");
+
+ const data = await ffmpeg.readFile(fallbackOutputName, undefined, { signal });
+ const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" });
+
+ ffmpeg.off("log", logListener);
+ onProgress(100);
+ return {
+ blobUrl: URL.createObjectURL(blob),
+ blob,
+ size: blob.size,
+ width: targetW,
+ height: targetH,
+ format: "webm",
+ };
+ }
+
+ const data = await ffmpeg.readFile(outputName, undefined, { signal });
+ const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: mimeType });
+
+ ffmpeg.off("log", logListener);
+ onProgress(100);
+ return {
+ blobUrl: URL.createObjectURL(blob),
+ blob,
+ size: blob.size,
+ width: targetW,
+ height: targetH,
+ format: recipe.format as "mp4" | "webm" | "mkv",
+ };
+ } finally {
+ ffmpeg.off("progress", handleProgress);
+ for (const path of cleanupFiles) {
+ try {
+ await ffmpeg.deleteFile(path);
+ } catch {}
+ }
+ }
+}
+
function buildArguments(
recipe: EditRecipe,
format: "mp4" | "webm" | "mkv" | "gif",
@@ -436,17 +712,11 @@ function buildArguments(
hasOriginalAudio: boolean,
videoDuration: number
): string[] {
- const vf = buildVideoFilter(recipe, targetW, targetH);
- const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
- const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : "";
- const afParts = [audioTrim, audioSpeed].filter(Boolean);
- const af = afParts.join(",");
-
- const musicIdx = 1;
- const overlayIdx = hasMusicTrack ? 2 : 1;
+ const hasJumpCuts = (recipe.jumpCutSegments?.length ?? 0) > 0;
const args: string[] = [];
args.push("-i", inputName);
+
if (hasMusicTrack) {
if (musicOptions!.loopMusic) args.push("-stream_loop", "-1");
args.push("-i", musicInputName);
@@ -455,10 +725,69 @@ function buildArguments(
args.push("-i", overlayInputName);
}
- const needsFilterComplex = hasOverlay || hasMusicTrack;
+ const musicIdx = 1;
+ const overlayIdx = hasMusicTrack ? 2 : 1;
const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack);
- if (needsFilterComplex) {
+ if (hasJumpCuts) {
+ const filterParts: string[] = [];
+ const { filterComplex: jumpCutFC, videoOut: concatV, audioOut: concatA } =
+ buildJumpCutFilterComplex(recipe, targetW, targetH, hasOriginalAudio);
+
+ filterParts.push(jumpCutFC);
+
+ let videoOut = concatV;
+ if (hasOverlay) {
+ const scaledW = overlayOptions!.size;
+ const alpha = (overlayOptions!.opacity / 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";
+ filterParts.push(
+ `[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`
+ );
+ filterParts.push(`${videoOut}[logo]overlay=${pos}[vfinal]`);
+ videoOut = "[vfinal]";
+ }
+
+ let audioOut = concatA;
+ if (shouldKeepAudio && hasMusicTrack) {
+ const musicVol = (musicOptions!.musicVolume / 100).toFixed(2);
+ if (hasOriginalAudio && concatA) {
+ const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2);
+ filterParts.push(`${concatA}volume=${origVol}[orig]`);
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`);
+ filterParts.push(
+ `[orig][music]amix=inputs=2:duration=first:dropout_transition=0[afinal]`
+ );
+ audioOut = "[afinal]";
+ } else {
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[afinal]`);
+ audioOut = "[afinal]";
+ }
+ }
+
+ args.push("-filter_complex", filterParts.join(";"));
+ args.push("-map", videoOut);
+
+ if (!shouldKeepAudio) {
+ args.push("-an");
+ } else if (audioOut) {
+ args.push("-map", audioOut);
+ }
+
+ } else if (hasOverlay || hasMusicTrack) {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
+ const audioSpeed = hasOriginalAudio
+ ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false)
+ : "";
+ const afParts = [audioTrim, audioSpeed].filter(Boolean);
+
const filterParts: string[] = [];
let videoOut = "[0:v]";
@@ -477,7 +806,9 @@ function buildArguments(
"bottom-right": "W-w-20:H-h-20",
};
const pos = posMap[overlayOptions!.position] ?? "W-w-20:H-h-20";
- filterParts.push(`[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`);
+ filterParts.push(
+ `[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`
+ );
filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`);
videoOut = "[vout]";
}
@@ -487,20 +818,22 @@ function buildArguments(
if (hasMusicTrack) {
const musicVol = (musicOptions!.musicVolume / 100).toFixed(2);
if (hasOriginalAudio) {
- const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2);
+ const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2);
const origChain = afParts.length > 0
? `[0:a]${afParts.join(",")},volume=${origVol}[orig]`
: `[0:a]volume=${origVol}[orig]`;
filterParts.push(origChain);
filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`);
- filterParts.push(`[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`);
+ filterParts.push(
+ `[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`
+ );
audioOut = "[aout]";
} else {
filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`);
audioOut = "[aout]";
}
- } else if (hasOriginalAudio && af) {
- filterParts.push(`[0:a]${af}[aout]`);
+ } else if (hasOriginalAudio && afParts.length > 0) {
+ filterParts.push(`[0:a]${afParts.join(",")}[aout]`);
audioOut = "[aout]";
}
}
@@ -517,7 +850,16 @@ function buildArguments(
} else if (hasOriginalAudio) {
args.push("-map", "0:a");
}
+
} else {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
+ const audioSpeed = hasOriginalAudio
+ ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false)
+ : "";
+ const afParts = [audioTrim, audioSpeed].filter(Boolean);
+ const af = afParts.join(",");
+
if (vf) args.push("-vf", vf);
if (!shouldKeepAudio) {
args.push("-an");
@@ -539,13 +881,16 @@ function buildArguments(
args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast");
if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
} else {
- args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast", "-movflags", "+faststart");
+ args.push(
+ "-c:v", "libx264",
+ "-crf", String(recipe.quality),
+ "-preset", "ultrafast",
+ "-movflags", "+faststart"
+ );
if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
}
- // Add explicit output duration when speed != 1 to prevent slight duration
- // overshoot caused by encoder/filter pipeline frame flush at stream end.
- if (recipe.speed !== 1) {
+ if (!hasJumpCuts && recipe.speed !== 1) {
const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
const outputDuration = sourceDuration / recipe.speed;
args.push("-t", outputDuration.toFixed(6));
diff --git a/src/lib/text-overlay.ts b/src/lib/text-overlay.ts
index b7ca5c5c..5aa23489 100644
--- a/src/lib/text-overlay.ts
+++ b/src/lib/text-overlay.ts
@@ -20,7 +20,6 @@ export function createDefaultTextOverlay(): TextOverlay {
fontSize: 48,
color: "#ffffff",
fontWeight: "normal",
- fontFamily: "Arial", // Default to Arial for immediate visibility
};
}
@@ -78,30 +77,15 @@ export function buildTextFilter(
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}'`;
- }
-
- // Add custom font file path if available
- if (fontFileParam) {
- filter += `:${fontFileParam}`;
- }
-
- return filter;
+ // Build the drawtext filter with proper escaping
+ // Using 'fontsize' and 'fontcolor' parameters
+ // Note: Font file path may not be available in all environments,
+ // so we rely on the system default font
+ return `drawtext=text='${escapedText}':x=${pixelX}:y=${pixelY}:fontsize=${overlay.fontSize}:fontcolor=${overlay.color}:fontweight=${
+ overlay.fontWeight === "900"
+ ? "bold"
+ : overlay.fontWeight === "bold"
+ ? "bold"
+ : "normal"
+ }`;
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index deb816b8..14835db0 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -16,6 +16,7 @@ export interface TextOverlay {
}
export interface EditRecipe {
+ textOverlays: TextOverlay[];
preset: string;
customWidth: number;
customHeight: number;
@@ -36,6 +37,14 @@ export interface EditRecipe {
soundOnCompletion: boolean;
textOverlays: TextOverlay[];
version: number;
+ silenceDetection?: {
+ enabled: boolean;
+ threshold: number;
+ minSilenceDuration: number;
+ padding: number;
+ };
+ silentSegments?: Array<{ start: number; end: number }>;
+ jumpCutSegments?: JumpCutSegment[];
}
export type OverlayPosition =
@@ -75,11 +84,9 @@ export type ExportStatus =
| "done"
| "error";
-export const MAX_FILE_SIZE =
- 2 * 1024 * 1024 * 1024;
+export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
-export const WARNING_FILE_SIZE =
- 500 * 1024 * 1024; // 500MB
+export const WARNING_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export function isValidRecipe(value: unknown): value is EditRecipe {
if (!value || typeof value !== "object") return false;
@@ -105,5 +112,15 @@ export function isValidRecipe(value: unknown): value is EditRecipe {
if (typeof v.soundOnCompletion !== "boolean") return false;
if (!Array.isArray(v.textOverlays)) return false;
+ if (v.jumpCutSegments !== undefined) {
+ if (!Array.isArray(v.jumpCutSegments)) return false;
+ for (const seg of v.jumpCutSegments) {
+ if (typeof seg !== "object" || seg === null) return false;
+ if (typeof seg.start !== "number" || !isFinite(seg.start)) return false;
+ if (typeof seg.end !== "number" || !isFinite(seg.end)) return false;
+ if (seg.start >= seg.end) return false;
+ }
+ }
+
return true;
-}
+}
\ No newline at end of file