From 14b69181a49317ec33c975dfbc237e2350df62a9 Mon Sep 17 00:00:00 2001 From: Ajey Awasthi Date: Sun, 24 May 2026 04:48:36 +0530 Subject: [PATCH 1/2] feat: improve mobile layout & responsiveness (#912) --- bun.lock | 19 +++++++++- src/components/VideoEditor.tsx | 64 +++++++++++++++++++++++----------- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index 3b78ac97..c5aa7903 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "wasm-feature-detect": "^1.8.0", }, "devDependencies": { + "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "@types/bun": "^1.3.14", @@ -38,6 +39,8 @@ }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -218,6 +221,8 @@ "@testing-library/dom": ["@testing-library/dom@9.3.4", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.1.3", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ=="], + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + "@testing-library/react": ["@testing-library/react@14.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^9.0.0", "@types/react-dom": "^18.0.0" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ=="], "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], @@ -420,6 +425,8 @@ "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=="], "cssstyle": ["cssstyle@3.0.0", "", { "dependencies": { "rrweb-cssom": "^0.6.0" } }, "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg=="], @@ -458,7 +465,7 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], - "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "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=="], @@ -612,6 +619,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], @@ -762,6 +771,8 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "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=="], @@ -880,6 +891,8 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reframe": ["reframe@root:", {}], @@ -968,6 +981,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -1084,6 +1099,8 @@ "@testing-library/dom/aria-query": ["aria-query@5.1.3", "", { "dependencies": { "deep-equal": "^2.0.5" } }, "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ=="], + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@testing-library/react/@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index ef9ad3f7..d1f2854b 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -288,29 +288,51 @@ export default function VideoEditor() {
- -
-
-

- REFRAME -

-

- Your video, any format -

-
-
- - No login. No ads. 100% private - your video never leaves your device. -
-
- +
+
+

+ REFRAME +

+

+ Your video, any format +

+
+ + No login. No ads. 100% private. +
+
+
+ + No login. No ads. 100% private - your video never leaves your device. +
+
-
+
{!file && ( @@ -345,7 +367,7 @@ export default function VideoEditor() { )} {file && (
From 26e06eeae0f6bd34be3ffcdb534d1cd0450d12e2 Mon Sep 17 00:00:00 2001 From: Ajey Awasthi Date: Tue, 26 May 2026 14:54:46 +0530 Subject: [PATCH 2/2] feat: Add Smart Silence Detection & Auto Jump-Cut Editing --- src/components/TrimControl.tsx | 107 ++- src/components/__tests__/videoFilter.test.tsx | 161 ++++ src/hooks/useAudioWaveform.ts | 5 +- src/hooks/useSilenceDetection.ts | 66 ++ src/lib/constants.ts | 4 +- src/lib/ffmpef.worker.ts | 813 ++++++++++++++++++ src/lib/ffmpeg.ts | 481 +++++++---- src/lib/text-overlay.ts | 89 ++ src/lib/types.ts | 42 +- 9 files changed, 1613 insertions(+), 155 deletions(-) create mode 100644 src/components/__tests__/videoFilter.test.tsx create mode 100644 src/hooks/useSilenceDetection.ts create mode 100644 src/lib/ffmpef.worker.ts create mode 100644 src/lib/text-overlay.ts diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index 577dc1fa..6bf93312 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 +

+ + +
+ )} + + + +

+ 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/__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 f838e8a2..584bbb05 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,5 +23,6 @@ export const DEFAULT_RECIPE: EditRecipe = { denoise: false, soundOnCompletion: false, normalizeAudio: false, + jumpCutSegments: undefined, 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 94c2ddb3..0d061bc8 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -3,16 +3,15 @@ import { fetchFile, toBlobURL } from "@ffmpeg/util"; import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; import { getPresetById } from "./presets"; import { simd } from "wasm-feature-detect"; +import { buildTextFilter } from "./text-overlay"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; -// Added from main branch for subresource security verification const SRI_HASHES: Record = { "ffmpeg-core.js": "sha384-sKfkiFtvUk+vexk+0EUhEh366190/4WpgUAsUvaxEfyg7+E1Zt5Y5hrsU808g8Q9", "ffmpeg-core.wasm": "sha384-U1VDhkPYrM3wTCT4/vjSpSsKqG/UjljYrYCI4hBSJ02svbCkxuCi6U6u/peg5vpW", }; -// Added from main branch to perform secure binary verification async function fetchWithIntegrity(url: string, mimeType: string): Promise { const key = url.split("/").pop()!; const integrity = SRI_HASHES[key]; @@ -28,9 +27,6 @@ async function fetchWithIntegrity(url: string, mimeType: string): Promise { + 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[] = []; @@ -106,7 +209,6 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n filters.push("setpts=PTS-STARTPTS"); } - if (recipe.stabilization) { filters.push("deshake"); } @@ -132,8 +234,8 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n } if (recipe.speed !== 1) { - const pts = (1 / recipe.speed).toFixed(4); - filters.push(`setpts=${pts}*PTS`); + const pts = (1 / recipe.speed).toFixed(4); + filters.push(`setpts=${pts}*PTS`); } if (recipe.denoise) { @@ -146,7 +248,7 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n return filters.join(","); } - export function buildAudioFilter(speed: number, normalizeAudio: boolean): string { +export function buildAudioFilter(speed: number, normalizeAudio: boolean): string { if (speed <= 0) return ""; const filters: string[] = []; @@ -161,7 +263,7 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n remaining /= 2.0; } - if (Math.abs(remaining - 1.0) > 0.001) { + if (Math.abs(remaining - 1.0) > 0.001) { filters.push(`atempo=${Number(remaining.toFixed(4))}`); } @@ -176,126 +278,6 @@ function buildAudioTrimFilter(recipe: EditRecipe): string { return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`; } -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 -): 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 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 needsFilterComplex = hasOverlay || hasMusicTrack; - const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack); - - if (needsFilterComplex) { - 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 && af) { - filterParts.push(`[0:a]${af}[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"); - } - } else { - if (vf) args.push("-vf", vf); - if (!shouldKeepAudio) { - args.push("-an"); - } else if (af && hasOriginalAudio) { - args.push("-af", af); - } - } - - if (format === "webm") { - args.push("-c:v", "libvpx-vp9", "-b:v", "0", "-crf", String(recipe.quality)); - if (shouldKeepAudio) args.push("-c:a", "libopus"); - } else if (format === "mkv") { - args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "medium"); - if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); - } else { - args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "medium", "-movflags", "+faststart"); - if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k"); - } - - args.push(outputName); - return args; -} - export async function exportVideo( ffmpeg: FFmpeg, file: File, @@ -344,16 +326,22 @@ export async function exportVideo( onProgress(Math.min(99, Math.round(progress * 100))); }; - try { await ffmpeg.writeFile(inputName, await fetchFile(file), { signal }); - const vf = buildVideoFilter(recipe, targetW, targetH); - const audioTrim = buildAudioTrimFilter(recipe); - const audioSpeed = buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false); + 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 afParts = [audioTrim, audioSpeed].filter(Boolean); - const af = afParts.join(","); const hasMusicTrack = !!(musicOptions?.file && recipe.keepAudio); const musicInputName = `music_input_${sessionId}.mp3`; if (hasMusicTrack) { @@ -371,7 +359,7 @@ export async function exportVideo( ffmpeg.on("progress", handleProgress); - // ── Two-pass GIF export ────────────────────────────────────────────────── + // GIF export path if (recipe.format === "gif") { const vf = buildVideoFilter(recipe, targetW, targetH); const vfWithPalette = vf ? `${vf},palettegen` : "palettegen"; @@ -379,7 +367,6 @@ export async function exportVideo( ? `[0:v]${vf}[x];[x][1:v]paletteuse` : "[0:v][1:v]paletteuse"; - // Pass 1: generate colour palette const pass1Code = await ffmpeg.exec( ["-i", inputName, "-vf", vfWithPalette, "-y", paletteName], undefined, @@ -387,7 +374,6 @@ export async function exportVideo( ); if (pass1Code !== 0) throw new Error("GIF palette generation failed"); - // Pass 2: render GIF using the palette const pass2Code = await ffmpeg.exec( ["-i", inputName, "-i", paletteName, "-lavfi", vfWithPaletteUse, "-y", outputName], undefined, @@ -409,7 +395,6 @@ export async function exportVideo( format: "gif" as const, }; } - // ──────────────────────────────────────────────────────────────────────── let missingAudioDetected = false; const logListener = ({ message }: { message: string }) => { @@ -424,32 +409,32 @@ export async function exportVideo( }; ffmpeg.on("log", logListener); - // Attempt 1: Process with standard audio streams + // Attempt 1: Process with original audio let args = buildArguments( recipe, recipe.format, outputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, true + hasOverlay, overlayInputName, overlayOptions, true, videoDuration ); let exitCode = await ffmpeg.exec(args, undefined, { signal }); - // Attempt 2: Auto-recover if the file has no original audio track + // 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 + hasOverlay, overlayInputName, overlayOptions, false, videoDuration ); exitCode = await ffmpeg.exec(args, undefined, { signal }); } - // Fallback Attempt 3: Switch codecs to WebM if container errors happen + // Fallback: Try WebM if (exitCode !== 0) { args = buildArguments( recipe, "webm", fallbackOutputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected + hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected, videoDuration ); const fallbackCode = await ffmpeg.exec(args, undefined, { signal }); @@ -493,6 +478,210 @@ export async function exportVideo( } } +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); + + 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]"; + + 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"); + } + + } 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); + } + } + + 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"); + } + + 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; +} + export function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; diff --git a/src/lib/text-overlay.ts b/src/lib/text-overlay.ts new file mode 100644 index 00000000..2228c4ef --- /dev/null +++ b/src/lib/text-overlay.ts @@ -0,0 +1,89 @@ +import { TextOverlay } from "./types"; + +/** + * Generates a unique ID for a text overlay. + */ +export function generateTextOverlayId(): string { + return `text-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Creates a default text overlay with sensible defaults. + */ +export function createDefaultTextOverlay(): TextOverlay { + return { + id: generateTextOverlayId(), + text: "Enter text", + x: 50, // Centered horizontally + y: 20, // Near top + fontSize: 48, + color: "#ffffff", + fontWeight: "normal", + }; +} + +/** + * Calculates the position of a text overlay relative to the preview container. + * @param percentX - Horizontal position as percentage (0-100) + * @param percentY - Vertical position as percentage (0-100) + * @param containerWidth - Width of the preview container in pixels + * @param containerHeight - Height of the preview container in pixels + */ +export function getTextPixelPosition( + percentX: number, + percentY: number, + containerWidth: number, + containerHeight: number +): { left: number; top: number } { + return { + left: (percentX / 100) * containerWidth, + top: (percentY / 100) * containerHeight, + }; +} + +/** + * Converts pixel position back to percentage within the container. + */ +export function getTextPercentPosition( + pixelX: number, + pixelY: number, + containerWidth: number, + containerHeight: number +): { x: number; y: number } { + return { + x: Math.max(0, Math.min(100, (pixelX / containerWidth) * 100)), + y: Math.max(0, Math.min(100, (pixelY / containerHeight) * 100)), + }; +} + +/** + * Generates a drawText FFmpeg filter for a single text overlay. + * Escapes special characters and positions text on the output video. + */ +export function buildTextFilter( + overlay: TextOverlay, + targetWidth: number, + targetHeight: number +): string { + // Escape special characters for FFmpeg drawtext filter + const escapedText = overlay.text + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/:/g, "\\:"); + + // Convert percentage position to pixel position + const pixelX = Math.round((overlay.x / 100) * targetWidth); + const pixelY = Math.round((overlay.y / 100) * targetHeight); + + // 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" + }`; +} \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index 2014d039..5828983f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,22 @@ export const RECIPE_VERSION = 1; +export interface JumpCutSegment { + start: number; + end: number; +} + +export interface TextOverlay { + id: string; + text: string; + x: number; // Percentage (0-100) from left + y: number; // Percentage (0-100) from top + fontSize: number; // In pixels + color: string; // Hex color + fontWeight: "normal" | "bold" | "900"; +} + export interface EditRecipe { + textOverlays: TextOverlay[]; preset: string; customWidth: number; customHeight: number; @@ -20,6 +36,14 @@ export interface EditRecipe { saturation: number; soundOnCompletion: boolean; version: number; + silenceDetection?: { + enabled: boolean; + threshold: number; + minSilenceDuration: number; + padding: number; + }; + silentSegments?: Array<{ start: number; end: number }>; + jumpCutSegments?: JumpCutSegment[]; } export type OverlayPosition = @@ -58,11 +82,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; @@ -87,5 +109,15 @@ 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 (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