From cd6bdcc71dabd7f6a4bbadf5ebf8f25cfe09bb4a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 19:31:33 +0200 Subject: [PATCH 1/6] refactor(exporter): delete the web MP4 pipeline the native path replaced ExportDialog has rendered MP4 through exportMultiNative since the D3D compositor landed; the mp4 branch returns before ever reaching exportAxcutDocument, so VideoExporter was reachable only from the bench. GIF is the one format the document adapter still renders. Removes VideoExporter, WgslFrameRenderer, audioEncoder, muxer, nativeFrameSink, planarChunkQueue, perfTimings, videoDecoder, asyncVideoFrameQueue, the RenderPlan/audioConcat layer that only fed VideoExporter, and src/bench (every arm drove the web path). The renderer->ffmpeg IPC encoder goes with them: its own header says REFUTED and its only consumer was gated on a localStorage flag nothing sets. resolveCropAt + CropScheduleEntry move to exporter/cropSchedule.ts, which GIF still needs. Also drops ~29 modules with zero importers across components/ui, video-editor and ai-edition. tsc clean; 94 files / 1108 tests pass (baseline 109/1338 - the delta is test files covering the deleted code). --- electron/main.ts | 6 - electron/media/__fixtures__/fakeFfmpeg.cjs | 49 - electron/media/ffmpegCapabilities.test.ts | 379 --- electron/media/ffmpegCapabilities.ts | 301 --- electron/media/ffmpegEncodeSession.test.ts | 225 -- electron/media/ffmpegEncodeSession.ts | 257 --- electron/media/ffmpegExportIpc.ts | 62 - electron/media/ffmpegExportService.ts | 222 -- src/bench/runBench.ts | 627 ----- src/components/ai-edition/ArrowSvgs.tsx | 212 -- .../PreviewCompositor.module.css | 56 - .../preview-compositor/PreviewCompositor.tsx | 499 ---- src/components/ui/accordion.tsx | 55 - src/components/ui/card.tsx | 55 - src/components/ui/color-picker.tsx | 159 -- src/components/ui/content-clamp.tsx | 81 - src/components/ui/dropdown-menu.tsx | 194 -- src/components/ui/input.tsx | 23 - src/components/ui/item-content.tsx | 18 - src/components/ui/label.tsx | 20 - src/components/ui/select.tsx | 170 -- src/components/ui/slider.tsx | 23 - src/components/ui/switch.tsx | 30 - src/components/ui/toggle-group.tsx | 56 - src/components/ui/toggle.tsx | 43 - .../video-editor/EditorMenuBar.test.tsx | 176 -- src/components/video-editor/EditorMenuBar.tsx | 198 -- .../backgroundImageUpload.test.ts | 20 - .../video-editor/backgroundImageUpload.ts | 20 - .../video-editor/regionClipboard.test.ts | 203 -- .../video-editor/regionClipboard.ts | 158 -- .../video-editor/regionPlacement.test.ts | 54 - .../video-editor/regionPlacement.ts | 24 - .../ai-edition/annotations/background.test.ts | 2 +- src/lib/ai-edition/annotations/blurEffects.ts | 53 - src/lib/ai-edition/annotations/constants.ts | 57 - .../exporter/audioConcatAssembler.test.ts | 427 ---- .../exporter/audioConcatAssembler.ts | 129 -- .../exporter/audioConcatPlan.test.ts | 172 -- .../ai-edition/exporter/audioConcatPlan.ts | 94 - .../exporter/documentExporter.test.ts | 149 +- .../ai-edition/exporter/documentExporter.ts | 104 +- .../ai-edition/exporter/renderPlan.test.ts | 942 -------- src/lib/ai-edition/exporter/renderPlan.ts | 435 ---- .../ai-edition/store/zoomSuggestions.test.ts | 116 - src/lib/ai-edition/store/zoomSuggestions.ts | 71 - src/lib/exporter/asyncVideoFrameQueue.ts | 77 - src/lib/exporter/audioEncoder.test.ts | 72 - src/lib/exporter/audioEncoder.ts | 1227 ---------- src/lib/exporter/cropSchedule.ts | 26 + src/lib/exporter/gifExporter.ts | 2 +- src/lib/exporter/index.ts | 4 +- src/lib/exporter/muxer.ts | 96 - src/lib/exporter/nativeFrameSink.test.ts | 202 -- src/lib/exporter/nativeFrameSink.ts | 124 - src/lib/exporter/perfTimings.test.ts | 174 -- src/lib/exporter/perfTimings.ts | 138 -- src/lib/exporter/planarChunkQueue.test.ts | 83 - src/lib/exporter/planarChunkQueue.ts | 76 - src/lib/exporter/videoDecoder.ts | 55 - .../exporter/videoExporter.browser.test.ts | 86 - src/lib/exporter/videoExporter.test.ts | 311 --- src/lib/exporter/videoExporter.ts | 2024 ----------------- src/lib/exporter/wgslFrameRenderer.ts | 737 ------ src/main.tsx | 22 +- 65 files changed, 50 insertions(+), 12912 deletions(-) delete mode 100644 electron/media/__fixtures__/fakeFfmpeg.cjs delete mode 100644 electron/media/ffmpegCapabilities.test.ts delete mode 100644 electron/media/ffmpegCapabilities.ts delete mode 100644 electron/media/ffmpegEncodeSession.test.ts delete mode 100644 electron/media/ffmpegEncodeSession.ts delete mode 100644 electron/media/ffmpegExportIpc.ts delete mode 100644 electron/media/ffmpegExportService.ts delete mode 100644 src/bench/runBench.ts delete mode 100644 src/components/ai-edition/ArrowSvgs.tsx delete mode 100644 src/components/ai-edition/preview-compositor/PreviewCompositor.module.css delete mode 100644 src/components/ai-edition/preview-compositor/PreviewCompositor.tsx delete mode 100644 src/components/ui/accordion.tsx delete mode 100644 src/components/ui/card.tsx delete mode 100644 src/components/ui/color-picker.tsx delete mode 100644 src/components/ui/content-clamp.tsx delete mode 100644 src/components/ui/dropdown-menu.tsx delete mode 100644 src/components/ui/input.tsx delete mode 100644 src/components/ui/item-content.tsx delete mode 100644 src/components/ui/label.tsx delete mode 100644 src/components/ui/select.tsx delete mode 100644 src/components/ui/slider.tsx delete mode 100644 src/components/ui/switch.tsx delete mode 100644 src/components/ui/toggle-group.tsx delete mode 100644 src/components/ui/toggle.tsx delete mode 100644 src/components/video-editor/EditorMenuBar.test.tsx delete mode 100644 src/components/video-editor/EditorMenuBar.tsx delete mode 100644 src/components/video-editor/backgroundImageUpload.test.ts delete mode 100644 src/components/video-editor/backgroundImageUpload.ts delete mode 100644 src/components/video-editor/regionClipboard.test.ts delete mode 100644 src/components/video-editor/regionClipboard.ts delete mode 100644 src/components/video-editor/regionPlacement.test.ts delete mode 100644 src/components/video-editor/regionPlacement.ts delete mode 100644 src/lib/ai-edition/annotations/blurEffects.ts delete mode 100644 src/lib/ai-edition/annotations/constants.ts delete mode 100644 src/lib/ai-edition/exporter/audioConcatAssembler.test.ts delete mode 100644 src/lib/ai-edition/exporter/audioConcatAssembler.ts delete mode 100644 src/lib/ai-edition/exporter/audioConcatPlan.test.ts delete mode 100644 src/lib/ai-edition/exporter/audioConcatPlan.ts delete mode 100644 src/lib/ai-edition/exporter/renderPlan.test.ts delete mode 100644 src/lib/ai-edition/exporter/renderPlan.ts delete mode 100644 src/lib/ai-edition/store/zoomSuggestions.test.ts delete mode 100644 src/lib/ai-edition/store/zoomSuggestions.ts delete mode 100644 src/lib/exporter/asyncVideoFrameQueue.ts delete mode 100644 src/lib/exporter/audioEncoder.test.ts delete mode 100644 src/lib/exporter/audioEncoder.ts create mode 100644 src/lib/exporter/cropSchedule.ts delete mode 100644 src/lib/exporter/muxer.ts delete mode 100644 src/lib/exporter/nativeFrameSink.test.ts delete mode 100644 src/lib/exporter/nativeFrameSink.ts delete mode 100644 src/lib/exporter/perfTimings.test.ts delete mode 100644 src/lib/exporter/perfTimings.ts delete mode 100644 src/lib/exporter/planarChunkQueue.test.ts delete mode 100644 src/lib/exporter/planarChunkQueue.ts delete mode 100644 src/lib/exporter/videoDecoder.ts delete mode 100644 src/lib/exporter/videoExporter.browser.test.ts delete mode 100644 src/lib/exporter/videoExporter.test.ts delete mode 100644 src/lib/exporter/videoExporter.ts delete mode 100644 src/lib/exporter/wgslFrameRenderer.ts diff --git a/electron/main.ts b/electron/main.ts index 199782072b..a0cb07dcab 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -21,8 +21,6 @@ import { import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; import { installMainProcessErrorGuards } from "./main-process-errors"; -import { registerFfmpegExportIpc } from "./media/ffmpegExportIpc"; -import { cancelAllExports } from "./media/ffmpegExportService"; import { acquireStableInstanceLock } from "./singleInstanceLock"; import { registerSttIpc } from "./stt"; import { @@ -507,9 +505,6 @@ app.on("activate", () => { app.on("will-quit", () => { unregisterAllGlobalShortcuts(); stableInstanceLock?.release(); - // Kill any ffmpeg still encoding, or quitting mid-export leaves it orphaned - // holding the output file. Fire-and-forget: will-quit cannot await. - void cancelAllExports(); }); const appReady = hasSingleInstanceLock ? app.whenReady() : null; @@ -631,7 +626,6 @@ appReady?.then(async () => { showMainWindow(); } - registerFfmpegExportIpc(); registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, diff --git a/electron/media/__fixtures__/fakeFfmpeg.cjs b/electron/media/__fixtures__/fakeFfmpeg.cjs deleted file mode 100644 index dcdc2c9368..0000000000 --- a/electron/media/__fixtures__/fakeFfmpeg.cjs +++ /dev/null @@ -1,49 +0,0 @@ -// A stand-in for ffmpeg so ffmpegEncodeSession's tests stay hermetic — the real -// binary is not bundled yet, and we do not want tests that depend on a machine's -// encoders. Behaviour is driven by FAKE_FFMPEG_MODE: -// -// ok drain stdin, exit 0 -// fail drain stdin, print a known message + the byte count, exit 3 -// count drain stdin, print the byte count, exit 3 (so the count reaches the -// test through the session's stderr tail — the only channel it exposes) -// progress emit frame=N progress lines while draining, exit 0 -// hang drain stdin and never exit (for cancel()) -// -// It ignores the ffmpeg argv it is handed; buildFfmpegArgs is asserted separately. -const mode = process.env.FAKE_FFMPEG_MODE || "ok"; - -let bytes = 0; -let frames = 0; - -process.stdin.on("data", (chunk) => { - bytes += chunk.length; - if (mode === "progress") { - // Roughly one progress line per MiB, so a test writing a few MB sees several. - const next = Math.floor(bytes / (1024 * 1024)); - if (next > frames) { - frames = next; - process.stderr.write(`frame=${frames}\nfps=60\n`); - } - } -}); - -process.stdin.on("end", () => { - if (mode === "hang") return; - if (mode === "fail") { - process.stderr.write(`fake ffmpeg: deliberate failure for the test BYTES=${bytes}\n`); - process.exit(3); - } - if (mode === "count") { - process.stderr.write(`BYTES=${bytes}\n`); - process.exit(3); - } - process.exit(0); -}); - -// `hang` must survive stdin ending: keep the loop alive until we are killed. -if (mode === "hang") { - setInterval(() => { - // Nothing to do — an empty timer is exactly what keeps this process - // alive so cancel() has something real to kill. - }, 1 << 30); -} diff --git a/electron/media/ffmpegCapabilities.test.ts b/electron/media/ffmpegCapabilities.test.ts deleted file mode 100644 index c3e23d67bb..0000000000 --- a/electron/media/ffmpegCapabilities.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - candidateFfmpegPaths, - candidateVideoEncoders, - FFMPEG_BINARY_NAME, - isLgplBuild, - parseAvailableEncoders, - pickWorkingEncoder, - smokeTestArgs, -} from "./ffmpegCapabilities"; - -// Realistic slice of `ffmpeg -encoders` output (truncated for the test): -// header, capability description, separator, then a mix of video / audio / -// subtitle / data encoders. The parser must return every real encoder name -// and nothing from the header / separator / description rows. -const REALISTIC_ENCODERS_OUTPUT = [ - "Encoders:", - " V..... = Video", - " A..... = Audio", - " S..... = Subtitle", - " D..... = Data", - "------", - " V..... h264_qsv H.264 / AVC (Intel Quick Sync Video acceleration) (codec h264)", - " V....D h264_amf AMD AMF H.264 Encoder (codec h264)", - " V....D h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)", - " V....D libx264 libx264 H.264 / AVC / MPEG-4 AVC (codec h264)", - " A....D aac AAC (Advanced Audio Coding)", - " D..... bintext Binary text", - " S..... ass ASS (Advanced SSA Subtitle)", - " V..... h264_videotoolbox VideoToolbox H.264 Encoder (codec h264)", - " V....D h264_vaapi H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (codec h264) (deprecated)", -].join("\n"); - -describe("ffmpegCapabilities", () => { - describe("parseAvailableEncoders", () => { - it("parses every encoder from the realistic ffmpeg -encoders fixture", () => { - const encoders = parseAvailableEncoders(REALISTIC_ENCODERS_OUTPUT); - expect(encoders.has("h264_qsv")).toBe(true); - expect(encoders.has("h264_amf")).toBe(true); - expect(encoders.has("h264_nvenc")).toBe(true); - expect(encoders.has("libx264")).toBe(true); - expect(encoders.has("aac")).toBe(true); - expect(encoders.has("ass")).toBe(true); - expect(encoders.has("bintext")).toBe(true); - expect(encoders.has("h264_videotoolbox")).toBe(true); - expect(encoders.has("h264_vaapi")).toBe(true); - }); - - it("ignores header, separator and capability-description rows", () => { - const encoders = parseAvailableEncoders(REALISTIC_ENCODERS_OUTPUT); - // Header word itself must not be treated as an encoder. - expect(encoders.has("Encoders")).toBe(false); - // Second token of description lines is "=", never added. - expect(encoders.has("=")).toBe(false); - // Capability nouns at the end of description lines must not leak. - expect(encoders.has("Video")).toBe(false); - expect(encoders.has("Audio")).toBe(false); - expect(encoders.has("Subtitle")).toBe(false); - expect(encoders.has("Data")).toBe(false); - // Exactly the 9 real encoders from the fixture, nothing else. - expect(encoders.size).toBe(9); - }); - - it("returns an empty set for empty / no-encoder input", () => { - expect(parseAvailableEncoders("").size).toBe(0); - expect(parseAvailableEncoders("\n").size).toBe(0); - expect(parseAvailableEncoders("Encoders:\n------").size).toBe(0); - }); - - it("tolerates leading whitespace and - in flag positions", () => { - const stdout = [ - " V----D h264_nvenc something", - "\tA....D aac\t\t\tAAC (Advanced Audio Coding)", - " V....D libx264 libx264 (codec h264)", - ].join("\n"); - const encoders = parseAvailableEncoders(stdout); - expect(encoders.has("h264_nvenc")).toBe(true); - expect(encoders.has("aac")).toBe(true); - expect(encoders.has("libx264")).toBe(true); - }); - - it("accepts CRLF line endings", () => { - const encoders = parseAvailableEncoders("V....D h264_nvenc\r\nA....D aac\r\n"); - expect(encoders.has("h264_nvenc")).toBe(true); - expect(encoders.has("aac")).toBe(true); - expect(encoders.size).toBe(2); - }); - }); - - describe("candidateVideoEncoders", () => { - // libopenh264 is bundled in our LGPL ffmpeg, so it is present everywhere and - // sits at the floor of every list. There is no WebCodecs path any more, so a - // machine with no hardware must still export - just slowly. - const SW = "libopenh264"; - - it("win32 orders nvidia, intel, amd, then the OS, then software", () => { - const all = new Set(["h264_nvenc", "h264_qsv", "h264_amf", "h264_mf", SW]); - expect(candidateVideoEncoders(all, "win32")).toEqual([ - "h264_nvenc", - "h264_qsv", - "h264_amf", - "h264_mf", - SW, - ]); - }); - - it("drops what the build does not carry", () => { - expect(candidateVideoEncoders(new Set(["h264_amf", SW]), "win32")).toEqual(["h264_amf", SW]); - }); - - it("darwin is videotoolbox then software", () => { - expect(candidateVideoEncoders(new Set(["h264_videotoolbox", SW]), "darwin")).toEqual([ - "h264_videotoolbox", - SW, - ]); - }); - - it("linux is nvenc, vaapi, then software", () => { - expect(candidateVideoEncoders(new Set(["h264_nvenc", "h264_vaapi", SW]), "linux")).toEqual([ - "h264_nvenc", - "h264_vaapi", - SW, - ]); - }); - - it("falls back to software on a platform we have no list for", () => { - expect(candidateVideoEncoders(new Set([SW]), "freebsd")).toEqual([SW]); - }); - - it("never offers libx264, the fastest thing we measured, because it is GPL", () => { - expect(candidateVideoEncoders(new Set(["libx264", "libx265"]), "win32")).toEqual([]); - }); - }); - - describe("pickWorkingEncoder", () => { - const ALL = new Set(["h264_nvenc", "h264_qsv", "h264_amf", "h264_mf", "libopenh264"]); - const encoderIn = (args: string[]) => args[args.indexOf("-c:v") + 1]; - - it("skips encoders the build carries but the machine cannot run", async () => { - // The real failure this exists to prevent: our bundled binary lists - // nvenc/qsv/amf on every machine, so on an AMD box nvenc is present and - // dies with "Cannot load nvcuda.dll". Presence is not capability. - const works = new Set(["h264_amf", "h264_mf", "libopenh264"]); - const picked = await pickWorkingEncoder(ALL, "win32", (args) => works.has(encoderIn(args))); - expect(picked).toBe("h264_amf"); - }); - - it("takes the first that works, in preference order", async () => { - const picked = await pickWorkingEncoder(ALL, "win32", () => true); - expect(picked).toBe("h264_nvenc"); - }); - - it("lands on software when no hardware encoder runs", async () => { - const picked = await pickWorkingEncoder( - ALL, - "win32", - (args) => encoderIn(args) === "libopenh264", - ); - expect(picked).toBe("libopenh264"); - }); - - it("returns null when nothing works - the bundled ffmpeg is broken, not the machine", async () => { - expect(await pickWorkingEncoder(ALL, "win32", () => false)).toBeNull(); - }); - - it("stops probing once one passes", async () => { - const tried: string[] = []; - await pickWorkingEncoder(ALL, "win32", (args) => { - tried.push(encoderIn(args)); - return encoderIn(args) === "h264_qsv"; - }); - expect(tried).toEqual(["h264_nvenc", "h264_qsv"]); - }); - - it("awaits an async probe", async () => { - const picked = await pickWorkingEncoder(ALL, "win32", async (args) => { - await new Promise((r) => setTimeout(r, 1)); - return encoderIn(args) === "h264_mf"; - }); - expect(picked).toBe("h264_mf"); - }); - }); - - describe("smokeTestArgs", () => { - it("encodes one synthetic frame to nowhere, so it touches no files", () => { - const a = smokeTestArgs("h264_amf"); - expect(a[a.indexOf("-f") + 1]).toBe("lavfi"); - expect(a[a.indexOf("-c:v") + 1]).toBe("h264_amf"); - expect(a[a.indexOf("-frames:v") + 1]).toBe("1"); - expect(a.slice(-3)).toEqual(["-f", "null", "-"]); - }); - }); - - describe("isLgplBuild", () => { - it("rejects a build configured with --enable-gpl", () => { - const buildConf = "--prefix=/usr --enable-shared --enable-gpl --enable-version3"; - expect(isLgplBuild(buildConf)).toBe(false); - }); - - it("rejects a build configured with --enable-nonfree", () => { - const buildConf = "--prefix=/usr --enable-shared --enable-nonfree"; - expect(isLgplBuild(buildConf)).toBe(false); - }); - - it("accepts a clean LGPL build (no gpl or nonfree)", () => { - const buildConf = "--prefix=/usr --enable-shared --enable-version3 --enable-libvpx"; - expect(isLgplBuild(buildConf)).toBe(true); - }); - - it("does not match --enable-gpl as a substring of another flag", () => { - // gpl-something is a different flag entirely - not the one we care about. - expect(isLgplBuild("--enable-gpl-something")).toBe(true); - expect(isLgplBuild("--enable-nonfree-extra")).toBe(true); - }); - - it("accepts the ffmpeg banner `configuration:` line for a clean build", () => { - const banner = "configuration: --prefix=/usr --enable-shared --enable-version3"; - expect(isLgplBuild(banner)).toBe(true); - }); - - it("rejects the ffmpeg banner `configuration:` line when --enable-gpl is present", () => { - const banner = "configuration: --prefix=/usr --enable-gpl --enable-version3 --enable-libx264"; - expect(isLgplBuild(banner)).toBe(false); - }); - - it("rejects the ffmpeg banner `configuration:` line when --enable-nonfree is present", () => { - const banner = "configuration: --enable-shared --enable-nonfree --enable-libfdk-aac"; - expect(isLgplBuild(banner)).toBe(false); - }); - - it("returns true for an empty / whitespace-only build configuration", () => { - expect(isLgplBuild("")).toBe(true); - expect(isLgplBuild(" ")).toBe(true); - }); - }); - - describe("candidateFfmpegPaths", () => { - it("prepends the env override when set", () => { - const paths = candidateFfmpegPaths({ - here: "/fake/repo", - platform: "linux", - arch: "x64", - envOverride: "/custom/ffmpeg", - }); - expect(paths[0]).toBe("/custom/ffmpeg"); - expect(paths).toContain("/fake/repo/electron/native/bin/linux-x64/ffmpeg"); - }); - - it("emits ffmpeg.exe under - on win32", () => { - const paths = candidateFfmpegPaths({ - here: "C:/fake/repo", - platform: "win32", - arch: "x64", - }); - const resolved = paths.map((p) => p.replace(/\\/g, "/")); - expect(resolved).toContain("C:/fake/repo/electron/native/bin/win32-x64/ffmpeg.exe"); - expect(resolved).toContain("C:/fake/repo/electron/native/bin/ffmpeg.exe"); - }); - - it("emits bare ffmpeg on linux (no .exe anywhere)", () => { - const paths = candidateFfmpegPaths({ - here: "/fake/repo", - platform: "linux", - arch: "x64", - }); - expect(paths).toContain("/fake/repo/electron/native/bin/linux-x64/ffmpeg"); - expect(paths).toContain("/fake/repo/electron/native/bin/ffmpeg"); - expect(paths.every((p) => !p.endsWith(".exe"))).toBe(true); - }); - - it("emits bare ffmpeg on darwin (no .exe anywhere)", () => { - const paths = candidateFfmpegPaths({ - here: "/fake/repo", - platform: "darwin", - arch: "arm64", - }); - expect(paths).toContain("/fake/repo/electron/native/bin/darwin-arm64/ffmpeg"); - expect(paths.every((p) => !p.endsWith(".exe"))).toBe(true); - }); - - it("orders candidates: env > appPath > resourcesPath > here-tagged > here-bare", () => { - const paths = candidateFfmpegPaths({ - here: "/fake/repo", - platform: "linux", - arch: "x64", - appPath: "/app", - resourcesPath: "/res", - envOverride: "/env", - }); - // Priority 1: env override comes first. - expect(paths[0]).toBe("/env"); - // Priority 2: appPath-tagged candidate is present. - expect(paths).toContain("/app/electron/native/bin/linux-x64/ffmpeg"); - // Priority 3: resourcesPath-tagged candidate is present. - expect(paths).toContain("/res/electron/native/bin/linux-x64/ffmpeg"); - // Priority 4: here-tagged candidate is present. - expect(paths).toContain("/fake/repo/electron/native/bin/linux-x64/ffmpeg"); - // Priority 5: cross-arch fallthrough with no platform tag is present. - expect(paths).toContain("/fake/repo/electron/native/bin/ffmpeg"); - - // Order is consistent across the layered candidates. - const envIdx = paths.indexOf("/env"); - const appIdx = paths.indexOf("/app/electron/native/bin/linux-x64/ffmpeg"); - const resIdx = paths.indexOf("/res/electron/native/bin/linux-x64/ffmpeg"); - const hereIdx = paths.indexOf("/fake/repo/electron/native/bin/linux-x64/ffmpeg"); - const bareIdx = paths.indexOf("/fake/repo/electron/native/bin/ffmpeg"); - expect(envIdx).toBeLessThan(appIdx); - expect(appIdx).toBeLessThan(resIdx); - expect(resIdx).toBeLessThan(hereIdx); - expect(hereIdx).toBeLessThan(bareIdx); - }); - - it("omits appPath / resourcesPath candidates when those inputs are null", () => { - const paths = candidateFfmpegPaths({ - here: "/fake/repo", - platform: "linux", - arch: "x64", - appPath: null, - resourcesPath: null, - }); - expect(paths.some((p) => p.startsWith("/app/"))).toBe(false); - expect(paths.some((p) => p.startsWith("/res/"))).toBe(false); - expect(paths).toContain("/fake/repo/electron/native/bin/linux-x64/ffmpeg"); - expect(paths).toContain("/fake/repo/electron/native/bin/ffmpeg"); - }); - - it("skips platform-tagged candidates when platform or arch is missing", () => { - const paths = candidateFfmpegPaths({ here: "/fake/repo" }); - // Only the cross-arch fallthrough is emitted without a platform tag. - expect(paths).toEqual(["/fake/repo/electron/native/bin/ffmpeg"]); - }); - }); - - describe("FFMPEG_BINARY_NAME", () => { - it("returns ffmpeg.exe on win32", () => { - expect(FFMPEG_BINARY_NAME("win32")).toBe("ffmpeg.exe"); - }); - - it("returns bare ffmpeg on every other platform", () => { - expect(FFMPEG_BINARY_NAME("linux")).toBe("ffmpeg"); - expect(FFMPEG_BINARY_NAME("darwin")).toBe("ffmpeg"); - expect(FFMPEG_BINARY_NAME("freebsd")).toBe("ffmpeg"); - }); - }); -}); - -// Added after research surfaced the real GPL/nonfree surface (ffmpeg.org/legal.html): -// checking --enable-gpl alone is not the whole gate. -describe("isLgplBuild — GPL externals and nonfree traps", () => { - it("rejects each GPL-only external library", () => { - for (const lib of [ - "libx264", - "libx265", - "libxvid", - "libvidstab", - "librubberband", - "frei0r", - "avisynth", - ]) { - expect(isLgplBuild(`--prefix=/x --enable-${lib} --enable-shared`)).toBe(false); - } - }); - - it("rejects libfdk-aac — nonfree makes the binary unredistributable, and we encode AAC", () => { - expect(isLgplBuild("--prefix=/x --enable-libfdk-aac")).toBe(false); - }); - - it("accepts a hardware-only LGPL build with the native aac encoder", () => { - expect( - isLgplBuild( - "--prefix=/x --enable-shared --enable-amf --enable-nvenc --enable-libsvtav1 --enable-videotoolbox", - ), - ).toBe(true); - }); - - it("does not false-positive on a lib whose name merely contains a GPL one", () => { - expect(isLgplBuild("--enable-libx264-shim-not-real")).toBe(true); - }); -}); diff --git a/electron/media/ffmpegCapabilities.ts b/electron/media/ffmpegCapabilities.ts deleted file mode 100644 index 96d58823a4..0000000000 --- a/electron/media/ffmpegCapabilities.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Join path segments with forward slashes. Electron's docs and Node's - * own internals both accept forward slashes on every platform, and using - * `node:path`'s `path.join` on Windows would silently rewrite the - * forward-slashed inputs the tests pass into backslashes - breaking the - * pure-function contract where identical inputs produce identical outputs - * regardless of host OS. - */ -function joinPosix(...segments: string[]): string { - return segments.filter((s) => s.length > 0).join("/"); -} - -/** - * Resolves the bundled ffmpeg binary for the export path's hardware-accelerated - * encoder. The actual subprocess, muxing and bitstream handling live in a - * separate module; this file only does pure capability detection so the - * streaming layer can stay focused on I/O and the tests can run without ever - * spawning a process. - * - * The resolution pattern mirrors electron/stt/gpuDetector.ts: a single - * per-platform binary name (Win32 needs the .exe suffix so the OS image - * loader can resolve it) and a layered set of candidate paths so the same - * code works in npm run dev, the electron-builder staging tree and the - * packaged installer. - */ - -/** ffmpeg encoders we know how to drive, best-first per platform. */ -export type VideoEncoderId = - | "h264_nvenc" - | "h264_qsv" - | "h264_amf" - | "h264_videotoolbox" - | "h264_vaapi" - /** Media Foundation: reaches AMD/Intel/NVIDIA through the OS rather than a - * vendor SDK. A useful net when the vendor encoder is missing or broken. */ - | "h264_mf" - /** Cisco's OpenH264 — BSD, so LGPL-safe, and present in our bundled build. - * Software, therefore slow; it exists so that a machine with no usable - * hardware encoder can still export rather than being told it cannot. */ - | "libopenh264"; - -/** - * Conventional binary name. Win32's loader requires the .exe suffix; on - * every other platform ffmpeg is just ffmpeg. The argument exists so tests - * can drive the function without mutating process.platform. - */ -export const FFMPEG_BINARY_NAME: (platform: NodeJS.Platform) => string = (platform) => - platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; - -/** - * Encoder preference per platform, best first. The first entry present in - * `available` wins. - * - * Windows prefers NVIDIA, then Intel, then AMD: NVENC is the strongest and most - * predictable of the three, QSV next, AMF last. AMF being last is about - * throughput headroom, not quality — measured output was frame-identical to - * software on the reference machine. Even AMF on an integrated Radeon measured - * ~165 fps at 1080p versus ~8 fps for WebCodecs, so last place is still ~20x - * what we shipped before. - * - * `h264_mf` (Media Foundation) sits after the vendor encoders as an OS-level - * net: it reaches AMD/Intel/NVIDIA silicon without their SDKs, so it can save a - * machine whose vendor path is missing or broken. - * - * Every list ends in `libopenh264` — software, BSD-licensed, bundled. It is the - * floor, not a fallback to somewhere else: there is deliberately no WebCodecs - * path any more, so a machine with no usable hardware encoder still exports - * through this same code, mux and all, just slowly. That is why this function - * cannot return null. - */ -const ENCODER_PREFERENCE: Partial> = { - win32: ["h264_nvenc", "h264_qsv", "h264_amf", "h264_mf", "libopenh264"], - darwin: ["h264_videotoolbox", "libopenh264"], - linux: ["h264_nvenc", "h264_vaapi", "libopenh264"], -}; - -/** Last resort on an unrecognised platform: if ffmpeg runs at all, this is there. */ -const UNIVERSAL_FALLBACK: VideoEncoderId = "libopenh264"; - -/** Match ffmpeg -encoders flag columns: 6 capability characters from - * V/A/S/D plus . (unset) and - (also unset on some builds). The flag - * prefix is followed by whitespace, then the encoder name. */ -const ENCODER_FLAG_PATTERN = /^[-VASD.]{6}$/; - -/** Encoder names are always identifier-shaped; rejects description lines like - * "V..... = Video" whose second token is "=". */ -const ENCODER_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - -/** - * Where to look for the bundled ffmpeg, in priority order: - * 1. OPENSCREEN_FFMPEG_EXE env override (debug builds) - * 2. /electron/native/bin/-/ (dev npm run - * dev and electron-builder --dir unpacked staging) - * 3. /electron/native/bin/-/ - * (packaged installer - NSIS / dmg / AppImage put natives under - * resources/) - * 4. here/electron/native/bin/-/ (older checkout - * shape + bare-bones tests) - * 5. here/electron/native/bin/ (cross-arch fallthrough - same name, - * no platform tag, lets a single checked-in binary serve every host) - * - * ponytail: on Windows we accept both ffmpeg.exe and bare ffmpeg as - * candidate names so a checkout that pre-dates the suffix fix still resolves - * to a valid file. - * - * Everything is read through the options bag so this function stays pure - - * the streaming layer injects appPath / resourcesPath from the Electron - * context, and tests pass literals. - */ -export function candidateFfmpegPaths( - opts: { - here?: string; - platform?: NodeJS.Platform; - arch?: string; - appPath?: string | null; - resourcesPath?: string | null; - envOverride?: string | null; - } = {}, -): string[] { - const here = opts.here ?? process.cwd(); - const platform = opts.platform; - const arch = opts.arch; - const appPath = opts.appPath ?? null; - const resourcesPath = opts.resourcesPath ?? null; - const envOverride = opts.envOverride ?? null; - - const tag = platform && arch ? `${platform}-${arch}` : null; - const primary = platform ? FFMPEG_BINARY_NAME(platform) : "ffmpeg"; - const names = platform === "win32" ? [primary, "ffmpeg"] : [primary]; - - const appPathSegments = - appPath && tag ? names.map((n) => joinPosix(appPath, "electron", "native", "bin", tag, n)) : []; - const resourceSegments = - resourcesPath && tag - ? names.map((n) => joinPosix(resourcesPath, "electron", "native", "bin", tag, n)) - : []; - const hereSegments = tag - ? names.map((n) => joinPosix(here, "electron", "native", "bin", tag, n)) - : []; - - return [ - ...(envOverride ? [envOverride] : []), - ...appPathSegments, - ...resourceSegments, - ...hereSegments, - // Cross-arch fallthrough: bare name with no platform tag - covers the - // case where a dev checked in a single ffmpeg for their host only. - ...names.map((n) => joinPosix(here, "electron", "native", "bin", n)), - ]; -} - -/** - * Parse ffmpeg -encoders stdout into the set of available encoder names. - * Returns every encoder (video, audio, subtitle, data) - the caller filters - * down to the VideoEncoderIds it cares about, so a future codec like - * hevc_nvenc doesn't require touching this parser. - * - * Tolerant of leading whitespace (real ffmpeg output is sometimes - * space-padded under the banner) and of - in flag positions (some builds - * render -- instead of .. for unset capabilities). - */ -export function parseAvailableEncoders(stdout: string): Set { - const available = new Set(); - for (const rawLine of stdout.split(/\r?\n/)) { - const line = rawLine.trim(); - if (line.length === 0) continue; - const tokens = line.split(/\s+/); - if (tokens.length < 2) continue; - if (!ENCODER_FLAG_PATTERN.test(tokens[0])) continue; - if (!ENCODER_NAME_PATTERN.test(tokens[1])) continue; - available.add(tokens[1]); - } - return available; -} - -/** - * The encoders worth *trying* on `platform`, best first, filtered to those the - * binary actually carries. - * - * **This is a shortlist, not a choice.** `ffmpeg -encoders` reports what was - * compiled in, which for a portable build is every vendor at once: our own - * bundled binary offers h264_nvenc, h264_qsv and h264_amf on a machine that has - * only an AMD GPU, where nvenc dies with "Cannot load nvcuda.dll" and qsv with - * "Error creating a MFX session". Presence proves nothing about the hardware. - * Only {@link smokeTestArgs} settles it — see {@link pickWorkingEncoder}. - * - * Note we never reach for libx264 even though it is the fastest thing we - * measured (201 fps). That is a licensing call, not a speed one — it is GPL and - * would relicense this MIT app (see isLgplBuild). libopenh264 is BSD and costs - * us nothing but throughput on the rare machine that needs it. - */ -export function candidateVideoEncoders( - available: ReadonlySet, - platform: NodeJS.Platform, -): VideoEncoderId[] { - const order = ENCODER_PREFERENCE[platform] ?? [UNIVERSAL_FALLBACK]; - return order.filter((id) => available.has(id)); -} - -/** - * argv for a one-frame encode that answers the only question that matters: does - * this encoder work *on this machine*? Synthesises its own input (`lavfi`) and - * throws the output away (`-f null`), so it touches no files and takes ~100 ms. - */ -export function smokeTestArgs(encoder: VideoEncoderId): string[] { - return [ - "-hide_banner", - "-v", - "error", - "-f", - "lavfi", - "-i", - "color=c=black:s=320x240:d=0.1", - "-frames:v", - "1", - "-c:v", - encoder, - "-f", - "null", - "-", - ]; -} - -/** - * First encoder in the platform's preference order that survives a real one-frame - * encode. `runSmokeTest` returns true when ffmpeg exits 0 for the given argv — - * injected so this stays pure and testable without spawning anything. - * - * Returns null only when nothing works, which means the bundled ffmpeg is broken - * or missing rather than the machine being unsupported: libopenh264 is software - * and part of the build, so it should always pass. Callers must treat null as a - * hard error — with no WebCodecs path any more, there is nothing else to try. - */ -export async function pickWorkingEncoder( - available: ReadonlySet, - platform: NodeJS.Platform, - runSmokeTest: (args: string[]) => Promise | boolean, -): Promise { - for (const id of candidateVideoEncoders(available, platform)) { - if (await runSmokeTest(smokeTestArgs(id))) return id; - } - return null; -} - -/** - * External libraries ffmpeg documents as GPL-only. Enabling any one of them - * relicenses the WHOLE binary to GPL, which would contaminate this MIT app. - * Belt-and-braces: `--enable-gpl` is *required* to build these, so checking - * the flag alone should suffice — but a third-party build could be patched, - * and this is the list that actually decides the licence, so assert on it too. - * Source: ffmpeg.org/legal.html. - */ -const GPL_EXTERNAL_LIBS = [ - "libx264", - "libx265", - "libxvid", - "libxavs", - "libxavs2", - "libdavs2", - "libvidstab", - "librubberband", - "libcdio", - "frei0r", - "avisynth", -] as const; - -/** - * Libraries that make the binary *unredistributable* — worse than GPL, since - * no licence lets us ship the result at all. `libfdk-aac` is the trap here: - * it is the AAC encoder everyone reaches for, and we encode AAC. Use ffmpeg's - * native `aac` encoder instead. Source: ffmpeg.org/legal.html. - */ -const NONFREE_LIBS = ["libfdk-aac", "libfdk_aac", "openssl"] as const; - -/** - * True iff the ffmpeg build has NO GPL and NO nonfree component. - * - * ffmpeg is LGPL **by default** — there is no `--disable-gpl`; GPL only - * appears if someone passes `--enable-gpl` (pulling x264/x265/…) or - * `--enable-nonfree` (fdk-aac, OpenSSL-combined builds). It is all-or-nothing: - * one GPL component relicenses the entire binary. - * - * This exists so CI can fail a build that would relicense the app — the whole - * reason we build/ship ffmpeg ourselves is that we control these flags. - * - * Feed it `ffmpeg -buildconf` output, or the banner's `configuration:` line. - */ -export function isLgplBuild(buildConf: string): boolean { - // Token-bounded so `--enable-gpl` matches but `--enable-gpl-something` does - // not. The banner's `configuration:` line and a raw `-buildconf` dump are - // both space-separated flags. - if (/(?:^|\s)--enable-(?:gpl|nonfree)(?=\s|$)/.test(buildConf)) return false; - - for (const lib of [...GPL_EXTERNAL_LIBS, ...NONFREE_LIBS]) { - // Matches --enable-libx264 and the bare token some -buildconf dumps use. - if (new RegExp(String.raw`(?:^|\s)(?:--enable-)?${lib}(?=\s|$)`).test(buildConf)) { - return false; - } - } - return true; -} diff --git a/electron/media/ffmpegEncodeSession.test.ts b/electron/media/ffmpegEncodeSession.test.ts deleted file mode 100644 index f33032f8e5..0000000000 --- a/electron/media/ffmpegEncodeSession.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - buildFfmpegArgs, - type FfmpegEncodeOptions, - parseProgress, - startFfmpegEncodeSession, -} from "./ffmpegEncodeSession"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const FAKE = path.join(here, "__fixtures__", "fakeFfmpeg.cjs"); - -/** Drives the session against the fake ffmpeg instead of the real (unbundled) binary. */ -function fakeOpts(over: Partial = {}): FfmpegEncodeOptions { - return { - ffmpegPath: process.execPath, - ffmpegArgsPrefix: [FAKE], - outputPath: path.join(here, "__fixtures__", "out.mp4"), - encoder: "h264_amf", - width: 1920, - height: 1080, - frameRate: 60, - bitrate: 8_000_000, - pixelFormat: "nv12", - ...over, - }; -} - -function withMode(mode: string, fn: () => Promise): Promise { - const prev = process.env.FAKE_FFMPEG_MODE; - process.env.FAKE_FFMPEG_MODE = mode; - return fn().finally(() => { - if (prev === undefined) delete process.env.FAKE_FFMPEG_MODE; - else process.env.FAKE_FFMPEG_MODE = prev; - }); -} - -describe("buildFfmpegArgs", () => { - it("feeds rawvideo on stdin with the frame geometry ffmpeg cannot infer", () => { - const a = buildFfmpegArgs(fakeOpts({ ffmpegArgsPrefix: undefined })); - expect(a).toContain("-f"); - expect(a[a.indexOf("-f") + 1]).toBe("rawvideo"); - expect(a[a.indexOf("-s") + 1]).toBe("1920x1080"); - expect(a[a.indexOf("-r") + 1]).toBe("60"); - expect(a[a.indexOf("-i") + 1]).toBe("pipe:0"); - }); - - it("passes the pixel format through for both frame layouts", () => { - for (const pixelFormat of ["nv12", "bgra", "rgba"] as const) { - const a = buildFfmpegArgs(fakeOpts({ pixelFormat })); - expect(a[a.indexOf("-pix_fmt") + 1]).toBe(pixelFormat); - } - }); - - it("selects the encoder and bitrate, and disables audio when there is none", () => { - const a = buildFfmpegArgs(fakeOpts({ encoder: "h264_videotoolbox", bitrate: 12_000_000 })); - expect(a[a.indexOf("-c:v") + 1]).toBe("h264_videotoolbox"); - expect(a[a.indexOf("-b:v") + 1]).toBe("12000000"); - expect(a).toContain("-an"); - }); - - it("declares the PCM as a second raw input and lets ffmpeg encode AAC", () => { - const a = buildFfmpegArgs( - fakeOpts({ - audio: { path: "/tmp/a.pcm", sampleRate: 48000, channels: 2, bitrate: 192_000 }, - }), - ); - // f32le with an explicit rate/layout: raw PCM carries no header to infer from. - expect(a[a.indexOf("-f", a.indexOf("-i")) + 1]).toBe("f32le"); - expect(a[a.indexOf("-ar") + 1]).toBe("48000"); - expect(a[a.indexOf("-ac") + 1]).toBe("2"); - expect(a).toContain("/tmp/a.pcm"); - expect(a[a.indexOf("-c:a") + 1]).toBe("aac"); - expect(a[a.indexOf("-b:a") + 1]).toBe("192000"); - expect(a).not.toContain("-an"); - }); - - it("declares both inputs before any codec option, as ffmpeg requires", () => { - const a = buildFfmpegArgs( - fakeOpts({ - audio: { path: "/tmp/a.pcm", sampleRate: 48000, channels: 2, bitrate: 192_000 }, - }), - ); - const lastInput = a.lastIndexOf("-i"); - expect(lastInput).toBeLessThan(a.indexOf("-c:v")); - expect(lastInput).toBeLessThan(a.indexOf("-c:a")); - // pipe:0 is input 0 and the PCM is input 1 — that ordering is what makes - // video stream 0 and audio stream 1 in the output. - expect(a.indexOf("pipe:0")).toBeLessThan(a.indexOf("/tmp/a.pcm")); - }); - - it("asks for machine-readable progress on stderr", () => { - const a = buildFfmpegArgs(fakeOpts()); - expect(a[a.indexOf("-progress") + 1]).toBe("pipe:2"); - expect(a).toContain("-nostats"); - }); - - it("puts the output path last so nothing can be mistaken for it", () => { - const a = buildFfmpegArgs(fakeOpts({ outputPath: "/tmp/x.mp4" })); - expect(a[a.length - 1]).toBe("/tmp/x.mp4"); - expect(a[a.length - 2]).toBe("-y"); - }); - - it("keeps extraEncoderArgs with the encoder, before the output", () => { - const a = buildFfmpegArgs(fakeOpts({ extraEncoderArgs: ["-quality", "speed"] })); - expect(a.indexOf("-quality")).toBeGreaterThan(a.indexOf("-c:v")); - expect(a.indexOf("-quality")).toBeLessThan(a.indexOf("-y")); - }); - - it("puts ffmpegArgsPrefix first so a wrapper can be invoked", () => { - expect(buildFfmpegArgs(fakeOpts({ ffmpegArgsPrefix: ["/w.js"] }))[0]).toBe("/w.js"); - }); -}); - -describe("parseProgress", () => { - it("reads frame= out of a progress block", () => { - expect(parseProgress("frame=42\nfps=60\nout_time_ms=700000\n")).toEqual({ frame: 42 }); - }); - - it("returns the freshest count when a chunk carries several blocks", () => { - expect(parseProgress("frame=1\nfps=60\nframe=2\nfps=60\nframe=3\n")).toEqual({ frame: 3 }); - }); - - it("ignores a chunk with no frame count", () => { - expect(parseProgress("fps=60\nbitrate=N/A\n")).toBeNull(); - expect(parseProgress("")).toBeNull(); - }); - - it("ignores a frame= torn across a chunk boundary rather than reporting a truncated number", () => { - // The stream splits anywhere; "frame=12" here is the head of "frame=1234". - // Reporting 12 would make progress jump backwards on the next chunk. - expect(parseProgress("fps=60\nframe=12")).toBeNull(); - expect(parseProgress("34\nfps=60\nframe=1234\n")).toEqual({ frame: 1234 }); - }); - - it("tolerates surrounding garbage", () => { - expect(parseProgress("some error text\nframe=7\n")).toEqual({ frame: 7 }); - }); -}); - -describe("startFfmpegEncodeSession", () => { - it("resolves with the output path when ffmpeg exits cleanly", async () => { - await withMode("ok", async () => { - const s = startFfmpegEncodeSession(fakeOpts()); - await s.writeFrame(new Uint8Array(1024)); - await expect(s.finish()).resolves.toEqual({ outputPath: fakeOpts().outputPath }); - }); - }); - - it("rejects with the stderr tail when ffmpeg exits non-zero", async () => { - await withMode("fail", async () => { - const s = startFfmpegEncodeSession(fakeOpts()); - await s.writeFrame(new Uint8Array(1024)); - await expect(s.finish()).rejects.toThrow(/deliberate failure for the test/); - }); - }); - - it("rejects rather than emitting an unhandled error when the binary does not exist", async () => { - const s = startFfmpegEncodeSession( - fakeOpts({ ffmpegPath: path.join(here, "no-such-binary-xyz"), ffmpegArgsPrefix: [] }), - ); - await expect(s.finish()).rejects.toThrow(); - }); - - it("loses no bytes under backpressure", async () => { - // The pipe only sustains ~500 MB/s because writeFrame awaits 'drain'. Ignore - // the false return and frames pile up in memory; drop them and the count comes - // up short. 52 MB is far past the pipe buffer, so this only passes if - // backpressure is actually honoured. - // - // The fake reports its byte total on stderr, and the session surfaces stderr - // through the error path — so ask it to fail after draining and read the tail. - await withMode("fail", async () => { - const FRAME = (1920 * 1080 * 3) / 2; // NV12 - const COUNT = 18; // ~52 MB - const s = startFfmpegEncodeSession(fakeOpts()); - for (let i = 0; i < COUNT; i++) await s.writeFrame(new Uint8Array(FRAME)); - await expect(s.finish()).rejects.toThrow(/deliberate failure/); - expect(s.framesEncoded).toBe(0); // no progress lines in this mode - }); - }); - - it("delivers every byte to ffmpeg's stdin", async () => { - // Same contract as above, asserted directly: the fake counts what it received - // and prints BYTES=, which reaches us via the failure path's stderr tail. - await withMode("count", async () => { - const FRAME = 1024 * 1024; - const COUNT = 40; // 40 MB — well past any pipe buffer - const s = startFfmpegEncodeSession(fakeOpts()); - for (let i = 0; i < COUNT; i++) await s.writeFrame(new Uint8Array(FRAME)); - await expect(s.finish()).rejects.toThrow(new RegExp(`BYTES=${FRAME * COUNT}\\b`)); - }); - }); - - it("reports progress from ffmpeg's stderr", async () => { - await withMode("progress", async () => { - const seen: number[] = []; - const s = startFfmpegEncodeSession(fakeOpts(), { onProgress: (n) => seen.push(n) }); - for (let i = 0; i < 4; i++) await s.writeFrame(new Uint8Array(1024 * 1024)); - await s.finish(); - expect(seen.length).toBeGreaterThan(0); - expect(s.framesEncoded).toBe(seen[seen.length - 1]); - }); - }); - - it("cancel() kills a hung ffmpeg and leaves finish() able to settle", async () => { - await withMode("hang", async () => { - const s = startFfmpegEncodeSession(fakeOpts()); - await s.writeFrame(new Uint8Array(1024)); - await s.cancel(); - // The whole point: a cancelled export must not leave finish() pending - // forever on a process that will never exit on its own. - await expect(s.finish()).resolves.toEqual({ outputPath: fakeOpts().outputPath }); - }); - }); - - it("refuses writes after cancel instead of throwing EPIPE at the main process", async () => { - await withMode("hang", async () => { - const s = startFfmpegEncodeSession(fakeOpts()); - await s.cancel(); - await expect(s.writeFrame(new Uint8Array(16))).rejects.toThrow(/closed/); - }); - }); -}); diff --git a/electron/media/ffmpegEncodeSession.ts b/electron/media/ffmpegEncodeSession.ts deleted file mode 100644 index 496b85075e..0000000000 --- a/electron/media/ffmpegEncodeSession.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { ChildProcessByStdio } from "node:child_process"; -import { spawn } from "node:child_process"; -import { once } from "node:events"; -import type { Readable, Writable } from "node:stream"; - -/** We pipe stdin and stderr and discard stdout — ffmpeg writes the file itself. */ -type FfmpegChild = ChildProcessByStdio; - -/** - * Streams raw frames from the export pipeline into a bundled native ffmpeg, - * which encodes with the platform's hardware encoder and writes the file - * itself. Replaces WebCodecs + the JS muxer on the export path: WebCodecs - * reaches the same silicon but measures ~8 fps @1080p against ffmpeg's ~165. - * - * Runs in the main process. Deliberately free of Electron imports so it stays - * unit-testable outside Electron. Binary path and encoder come from - * {@link ../media/ffmpegCapabilities}; this module never resolves them itself. - */ - -export interface FfmpegEncodeOptions { - ffmpegPath: string; - /** - * argv inserted before ours. Tests use it to point `ffmpegPath` at - * `process.execPath` and run a fake-ffmpeg script instead of the real binary. - */ - ffmpegArgsPrefix?: string[]; - outputPath: string; - /** e.g. "h264_amf" — chosen by selectVideoEncoder(). */ - encoder: string; - width: number; - height: number; - frameRate: number; - /** bits per second */ - bitrate: number; - /** - * `nv12` is the fast path (3.0 MB/frame @1080p, produced by a GPU packing - * step). `bgra` is what a canvas VideoFrame gives us directly (7.9 MB) — - * Chromium refuses to convert to NV12, so ffmpeg's swscale does it instead. - */ - pixelFormat: "nv12" | "bgra" | "rgba"; - extraEncoderArgs?: string[]; - /** - * Optional second input: the export's assembled PCM, already laid out at the - * concat plan's offsets. Given this, ffmpeg encodes AAC and muxes the final - * file itself — which is the whole point of routing audio through here rather - * than muxing in JS. - * - * Raw f32le rather than WAV: it is what we already hold in memory, it needs no - * header, and it skips the int16 round-trip a WAV would impose. - */ - audio?: { - /** Path to raw interleaved float32 PCM. */ - path: string; - sampleRate: number; - channels: number; - /** bits per second */ - bitrate: number; - }; -} - -export interface FfmpegEncodeSession { - /** Resolves once ffmpeg has accepted the frame; awaits `drain` under backpressure. */ - writeFrame(frame: Uint8Array): Promise; - /** Closes stdin; resolves on a clean exit, rejects with the stderr tail otherwise. */ - finish(): Promise<{ outputPath: string }>; - /** Kills the process tree. `finish()` must not hang afterwards. */ - cancel(): Promise; - readonly framesEncoded: number; -} - -/** Keep the last of ffmpeg's stderr: on failure it is the only diagnostic anyone gets. */ -const STDERR_TAIL_BYTES = 4096; - -/** - * The argv we hand ffmpeg. Split out from the process handling so the shape can - * be asserted without spawning anything. - * - * `-progress pipe:2` asks for machine-readable `key=value` progress on stderr, - * which is what {@link parseProgress} reads; `-nostats` silences the human - * progress line that would otherwise interleave with it. - * - * All inputs must precede the output options, so audio (input 1) is declared - * right after the video pipe (input 0) and its codec is chosen further down. - */ -export function buildFfmpegArgs(opts: FfmpegEncodeOptions): string[] { - return [ - ...(opts.ffmpegArgsPrefix ?? []), - "-hide_banner", - "-v", - "error", - // input 0: raw frames on stdin. rawvideo carries no geometry, so ffmpeg - // cannot infer any of this — it must be told. - "-f", - "rawvideo", - "-pix_fmt", - opts.pixelFormat, - "-s", - `${opts.width}x${opts.height}`, - "-r", - String(opts.frameRate), - "-i", - "pipe:0", - // input 1: the assembled PCM, when there is audio. - ...(opts.audio - ? [ - "-f", - "f32le", - "-ar", - String(opts.audio.sampleRate), - "-ac", - String(opts.audio.channels), - "-i", - opts.audio.path, - ] - : []), - "-c:v", - opts.encoder, - "-b:v", - String(opts.bitrate), - ...(opts.extraEncoderArgs ?? []), - ...(opts.audio ? ["-c:a", "aac", "-b:a", String(opts.audio.bitrate)] : ["-an"]), - // Video is exactly as long as the frames we push; audio is sized from the - // same per-segment frame counts, so a mismatch means an upstream bug we - // want to see rather than have ffmpeg quietly pad or truncate. - "-progress", - "pipe:2", - "-nostats", - "-y", - opts.outputPath, - ]; -} - -/** - * Pulls `frame=N` out of a chunk of ffmpeg's `-progress` output, or null when - * the chunk carries no frame count. - * - * Callers feed raw stream chunks, which split anywhere — including mid-line and - * mid-number. Matching only on a `frame=` followed by digits AND a terminator - * means a torn `frame=12` at a chunk boundary is ignored rather than reported as - * frame 12; the next chunk carries the whole line anyway. Progress is monotonic, - * so taking the LAST match in a chunk gives the freshest count. - */ -export function parseProgress(chunk: string): { frame: number } | null { - let last: number | null = null; - // Require real trailing whitespace, NOT end-of-string: a chunk ending in - // "frame=12" is the head of "frame=1234", and end-of-string in the lookahead - // would happily report 12. ffmpeg's -progress always terminates each - // key=value with a newline, so a complete line always has its terminator. - for (const m of chunk.matchAll(/(?:^|\s)frame=\s*(\d+)(?=\s)/g)) { - const n = Number.parseInt(m[1], 10); - if (Number.isFinite(n)) last = n; - } - return last === null ? null : { frame: last }; -} - -/** Windows leaves orphans behind a bare kill() — ffmpeg would keep holding the output file. */ -function killTree(child: FfmpegChild): void { - if (child.exitCode !== null || child.signalCode !== null) return; - if (process.platform === "win32" && child.pid !== undefined) { - spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on( - "error", - () => { - // taskkill missing (unlikely) — fall back to the best we have. - child.kill("SIGKILL"); - }, - ); - } else { - child.kill("SIGKILL"); - } -} - -export function startFfmpegEncodeSession( - opts: FfmpegEncodeOptions, - hooks?: { onProgress?: (framesEncoded: number) => void }, -): FfmpegEncodeSession { - const child: FfmpegChild = spawn(opts.ffmpegPath, buildFfmpegArgs(opts), { - stdio: ["pipe", "ignore", "pipe"], - }); - - let framesEncoded = 0; - let stderrTail = ""; - let cancelled = false; - let closed = false; - let spawnError: Error | null = null; - - // Attached before the first write so a spawn failure surfaces through - // finish() instead of as an unhandled 'error' event. - const exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }> = new Promise( - (resolve) => { - child.on("error", (err) => { - spawnError = err instanceof Error ? err : new Error(String(err)); - resolve({ code: null, signal: null }); - }); - child.on("close", (code, signal) => { - closed = true; - resolve({ code, signal }); - }); - }, - ); - - child.stdin.on("error", () => { - // A killed ffmpeg closes the pipe under us mid-write. EPIPE here is - // expected and must never reach the main process as an unhandled error. - }); - - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { - stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_BYTES); - const p = parseProgress(chunk); - if (p) { - framesEncoded = p.frame; - hooks?.onProgress?.(framesEncoded); - } - }); - - return { - get framesEncoded() { - return framesEncoded; - }, - - async writeFrame(frame: Uint8Array): Promise { - if (cancelled || closed) throw new Error("ffmpeg encode session is closed"); - if (spawnError) throw spawnError; - // Buffer.from(typedArray) COPIES. Wrapping the caller's memory instead - // measured +31% end-to-end (26 -> 34 fps): at 3-8 MB a frame, a stray - // copy per frame is gigabytes of pure memcpy across an export. - const view = Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength); - // The pipe sustains ~500 MB/s ONLY if we respect backpressure. Ignoring - // the false return buffers frames in memory without going any faster. - if (!child.stdin.write(view)) { - await once(child.stdin, "drain"); - } - }, - - async finish(): Promise<{ outputPath: string }> { - if (!cancelled && !closed) child.stdin.end(); - const { code, signal } = await exited; - if (spawnError) throw spawnError; - if (cancelled) return { outputPath: opts.outputPath }; - if (code !== 0) { - const how = signal ? `signal ${signal}` : `exit code ${code}`; - throw new Error(`ffmpeg failed (${how})${stderrTail ? `: ${stderrTail.trim()}` : ""}`); - } - return { outputPath: opts.outputPath }; - }, - - async cancel(): Promise { - if (cancelled || closed) return; - cancelled = true; - // End stdin first so ffmpeg is not blocked writing into a full pipe - // while we wait on the kill. - child.stdin.destroy(); - killTree(child); - await exited; - }, - }; -} diff --git a/electron/media/ffmpegExportIpc.ts b/electron/media/ffmpegExportIpc.ts deleted file mode 100644 index d072879554..0000000000 --- a/electron/media/ffmpegExportIpc.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ipcMain } from "electron"; -import { - cancelExport, - finishExport, - resolveExportCapabilities, - type StartExportRequest, - startExport, - writeExportFrame, -} from "./ffmpegExportService"; - -/** - * IPC surface for the native export encoder. - * - * The renderer composites and extracts frames but cannot spawn ffmpeg — it is - * sandboxed and stays that way. Dropping the sandbox (option A') was measured - * and buys nothing: with the crossing at exactly zero the pipeline still loses - * to WebCodecs, because the wall is the compositor, not this path. See - * technical-documentation/architecture/export-pipeline.md - * - * This whole path is REFUTED (§5): feeding native ffmpeg from the renderer is - * 2.1x SLOWER than what we ship. It survives as bench scaffolding, not as a - * plan. - * - * Frames are `send`, not `invoke`: they are one-way and there is nothing to - * return. Flow control is the renderer's credit window (8 frames in flight), - * acknowledged by `EXPORT_FRAME_ACK` — measured worth +56% over stop-and-wait. - */ - -export const EXPORT_CAPABILITIES = "export:capabilities"; -export const EXPORT_START = "export:start"; -export const EXPORT_FRAME = "export:frame"; -export const EXPORT_FRAME_ACK = "export:frame-ack"; -export const EXPORT_FINISH = "export:finish"; -export const EXPORT_CANCEL = "export:cancel"; - -export function registerFfmpegExportIpc(): void { - ipcMain.handle(EXPORT_CAPABILITIES, async () => { - const { encoder } = await resolveExportCapabilities(); - return { encoder }; - }); - - ipcMain.handle(EXPORT_START, async (_e, req: StartExportRequest) => startExport(req)); - - ipcMain.on(EXPORT_FRAME, async (e, sessionId: string, frame: ArrayBuffer) => { - try { - await writeExportFrame(sessionId, frame); - // Ack even on the last frame: the renderer's window must refill or it - // will stall short of the end. - if (!e.sender.isDestroyed()) e.sender.send(EXPORT_FRAME_ACK, sessionId, null); - } catch (err) { - // A write failure means ffmpeg died mid-export. Report it on the ack - // channel rather than throwing into an ipcMain.on handler, where the - // rejection would be unhandled and the renderer would wait forever. - if (!e.sender.isDestroyed()) { - e.sender.send(EXPORT_FRAME_ACK, sessionId, (err as Error).message); - } - } - }); - - ipcMain.handle(EXPORT_FINISH, async (_e, sessionId: string) => finishExport(sessionId)); - ipcMain.handle(EXPORT_CANCEL, async (_e, sessionId: string) => cancelExport(sessionId)); -} diff --git a/electron/media/ffmpegExportService.ts b/electron/media/ffmpegExportService.ts deleted file mode 100644 index 5ab2bb7060..0000000000 --- a/electron/media/ffmpegExportService.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { - candidateFfmpegPaths, - parseAvailableEncoders, - pickWorkingEncoder, - type VideoEncoderId, -} from "./ffmpegCapabilities"; -import { type FfmpegEncodeSession, startFfmpegEncodeSession } from "./ffmpegEncodeSession"; - -/** - * Main-process side of the native export encoder: resolves the bundled ffmpeg, - * proves which encoder actually works on this machine, and owns the live encode - * sessions the renderer streams frames into. - * - * The renderer cannot spawn anything (it is sandboxed, and stays that way), so - * frames cross to us over IPC and we feed ffmpeg's stdin. ffmpeg encodes, muxes - * the audio and writes the file — there is no JS muxer and no WebCodecs path - * behind this. - */ - -export interface StartExportRequest { - /** - * Omit to have main write to a temp file and return the path. - * - * The renderer is sandboxed and does not get to name a path main will write - * to — that would hand a compromised renderer an arbitrary file write. A - * user-chosen destination must come from a main-side save dialog, and the - * finished temp file is moved there. - */ - outputPath?: string; - width: number; - height: number; - frameRate: number; - bitrate: number; - pixelFormat: "nv12" | "bgra" | "rgba"; - /** Raw interleaved float32 PCM. ffmpeg encodes AAC and muxes it. */ - audio?: { pcm: ArrayBuffer; sampleRate: number; channels: number; bitrate: number }; -} - -export interface StartExportResult { - sessionId: string; - encoder: VideoEncoderId; - /** Where ffmpeg is actually writing — main's choice when the caller omitted one. */ - outputPath: string; -} - -/** Resolved once per process: the probe costs a few spawns, the answer never changes. */ -let capabilitiesPromise: Promise<{ ffmpegPath: string; encoder: VideoEncoderId }> | null = null; - -async function firstExisting(paths: string[]): Promise { - for (const p of paths) { - try { - await fs.access(p); - return p; - } catch { - // Expected: candidateFfmpegPaths lists every layout we might be running - // under (dev tree, unpacked staging, packaged resources); most miss. - } - } - return null; -} - -function runFfmpeg(ffmpegPath: string, args: string[]): Promise<{ code: number; stdout: string }> { - return new Promise((resolve) => { - const child = spawn(ffmpegPath, args, { stdio: ["ignore", "pipe", "ignore"] }); - let stdout = ""; - child.stdout?.setEncoding("utf8"); - child.stdout?.on("data", (c: string) => { - stdout += c; - }); - child.on("error", () => resolve({ code: -1, stdout: "" })); - child.on("close", (code) => resolve({ code: code ?? -1, stdout })); - }); -} - -/** - * Which encoder this machine can really use. Cached: the smoke tests spawn ffmpeg - * a few times, and hardware does not change under a running app. - * - * The smoke test is the whole point. `ffmpeg -encoders` reports what was compiled - * in — our portable build lists nvenc, qsv and amf on every machine — so on an - * AMD box nvenc is "available" and then dies with "Cannot load nvcuda.dll". - * Presence is not capability; only a real one-frame encode settles it. - */ -export function resolveExportCapabilities(): Promise<{ - ffmpegPath: string; - encoder: VideoEncoderId; -}> { - capabilitiesPromise ??= (async () => { - const ffmpegPath = await firstExisting( - candidateFfmpegPaths({ - platform: process.platform, - arch: process.arch, - appPath: process.env.OPENSCREEN_APP_PATH ?? null, - resourcesPath: process.resourcesPath ?? null, - envOverride: process.env.OPENSCREEN_FFMPEG_EXE ?? null, - }), - ); - if (!ffmpegPath) { - throw new Error( - "Bundled ffmpeg not found. Run `npm run fetch:ffmpeg` in development; " + - "in a packaged build this means the binary was not shipped.", - ); - } - - const listed = await runFfmpeg(ffmpegPath, ["-hide_banner", "-encoders"]); - const available = parseAvailableEncoders(listed.stdout); - const encoder = await pickWorkingEncoder( - available, - process.platform, - async (args) => (await runFfmpeg(ffmpegPath, args)).code === 0, - ); - if (!encoder) { - // libopenh264 is software and part of our build, so it should always pass. - // Reaching here means the binary is broken, not that the machine is - // unsupported — and there is no other path to fall back to. - throw new Error( - "No usable H.264 encoder in the bundled ffmpeg — not even software. " + - "The bundled binary is broken or was replaced.", - ); - } - return { ffmpegPath, encoder }; - })(); - return capabilitiesPromise; -} - -interface LiveSession { - session: FfmpegEncodeSession; - /** Temp PCM handed to ffmpeg as its second input; ours to delete. */ - audioPath: string | null; - tmpDir: string | null; -} - -const sessions = new Map(); -let nextId = 1; - -export async function startExport(req: StartExportRequest): Promise { - const { ffmpegPath, encoder } = await resolveExportCapabilities(); - - const sessionId = `export-${nextId++}`; - - let audioPath: string | null = null; - let tmpDir: string | null = null; - if (req.audio && req.audio.pcm.byteLength > 0) { - // ffmpeg reads raw f32le straight from disk. Writing the PCM out beats a - // second pipe: it is small (a few MB), and stdin is already carrying video. - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-export-")); - audioPath = path.join(tmpDir, "audio.f32le"); - await fs.writeFile(audioPath, Buffer.from(req.audio.pcm)); - } - - // Deliberately NOT inside tmpDir: dispose() wipes that, and the output has to - // outlive the session so it can be moved to the user's destination. - const outputPath = req.outputPath ?? path.join(os.tmpdir(), `openscreen-${sessionId}.mp4`); - - const session = startFfmpegEncodeSession({ - ffmpegPath, - encoder, - outputPath, - width: req.width, - height: req.height, - frameRate: req.frameRate, - bitrate: req.bitrate, - pixelFormat: req.pixelFormat, - audio: - audioPath && req.audio - ? { - path: audioPath, - sampleRate: req.audio.sampleRate, - channels: req.audio.channels, - bitrate: req.audio.bitrate, - } - : undefined, - }); - - sessions.set(sessionId, { session, audioPath, tmpDir }); - return { sessionId, encoder, outputPath }; -} - -function requireSession(sessionId: string): LiveSession { - const live = sessions.get(sessionId); - if (!live) throw new Error(`Unknown export session: ${sessionId}`); - return live; -} - -export async function writeExportFrame(sessionId: string, frame: ArrayBuffer): Promise { - // Buffer.from(arrayBuffer) wraps rather than copies — the +31% we measured - // depends on not duplicating a multi-MB frame here. - await requireSession(sessionId).session.writeFrame(new Uint8Array(frame)); -} - -async function dispose(sessionId: string, live: LiveSession): Promise { - sessions.delete(sessionId); - if (live.tmpDir) await fs.rm(live.tmpDir, { recursive: true, force: true }); -} - -export async function finishExport(sessionId: string): Promise<{ outputPath: string }> { - const live = requireSession(sessionId); - try { - return await live.session.finish(); - } finally { - await dispose(sessionId, live); - } -} - -export async function cancelExport(sessionId: string): Promise { - const live = sessions.get(sessionId); - if (!live) return; // Already finished or never started: cancelling is idempotent. - try { - await live.session.cancel(); - } finally { - await dispose(sessionId, live); - } -} - -/** Kill every live session — called when the app quits so no ffmpeg is orphaned. */ -export async function cancelAllExports(): Promise { - await Promise.all([...sessions.keys()].map((id) => cancelExport(id))); -} diff --git a/src/bench/runBench.ts b/src/bench/runBench.ts deleted file mode 100644 index c14ac9a7d2..0000000000 --- a/src/bench/runBench.ts +++ /dev/null @@ -1,627 +0,0 @@ -/** - * Headless export bench — the real pipeline, driven from a command line. - * - * Runs inside the app's own editor window (windowType=bench), so it exercises - * the real preload, the real sandbox, the real GPU and the real main-process - * ffmpeg. It loads a real saved project and calls exportAxcutDocument, the same - * entry point ExportDialog uses — nothing about the export path is simulated. - * - * Why it exists: driving the export through the UI costs ~5 minutes a run and - * silently injects confounds (an open DevTools panel handicapped one arm of the - * first A/B; a window that lost focus stole another). A command gives repeats - * cheaply, and repeats are what tell drift apart from effect. - * - * Deliberately NOT React: the bench replaces the app's UI entirely, so nothing - * renders alongside the export. - * - * npm run bench:export -- --project=os_parity --arms=webcodecs,native --runs=3 - */ - -import { exportAxcutDocument } from "@/lib/ai-edition/exporter/documentExporter"; -import { type AxcutDocument, zoomRegionSchema } from "@/lib/ai-edition/schema"; -import type { ExportQuality } from "@/lib/exporter"; -import { nativeBridgeClient } from "@/native/client"; - -export interface BenchArm { - /** localStorage flags applied before the run — the levers under test. */ - nativeEncode: boolean; - readFrequently: boolean; - /** Extract every frame and discard it. Diagnostic only — writes no file. */ - dropFrames?: boolean; - /** Composite every frame and stop there. Diagnostic only — writes no file. */ - compositeOnly?: boolean; - /** Undo the 2026-07-17 compositor fixes, to attribute them. */ - legacyCompositor?: boolean; - /** Gate G0: sync the GPU after compositing, in a `fence` stage of its own. */ - gpuFence?: boolean; - /** Diagnostic: shadow path minus the gaussians. Renders no shadow. */ - shadowNoFilter?: boolean; - /** The §8b compositor: one WGSL program. POC — renders no text/cursor/3D. */ - wgslCompositor?: boolean; - /** - * Effects this arm adds to --effects, so an effect can be A/B'd INSIDE one - * interleaved run. - * - * --effects alone cannot answer "what does this effect cost": it is one value - * for the whole session, so the comparison becomes two sessions — and this - * machine drifts further between sessions (up to 62%) than any effect is - * worth. That is the mistake this bench exists to prevent. - */ - addEffects?: string[]; -} - -export interface BenchRunResult { - arm: string; - run: number; - ok: boolean; - error?: string; - /** The exporter's frame-loop wall — what the arms are compared on. */ - wallMs: number; - /** Everything outside the loop (project load, renderer init), reported not compared. */ - setupMs?: number; - frames: number; - fps: number; - /** Per-stage totals in ms, keyed as StageTimings names it. */ - stages: Record; - /** Shadow cache hits/misses for the run — see BenchArm.addEffects / Step 3. */ - shadow?: { hits: number; misses: number }; -} - -const params = new URLSearchParams(window.location.search); - -/** The dialog's labels, so the bench is asked for what the UI shows. */ -const QUALITY: Record = { - "720p": "medium", - "1080p": "good", - source: "source", -}; - -const BENCH_PARAMS = { - project: params.get("project"), - arms: (params.get("arms") ?? "webcodecs,native").split(",").filter(Boolean), - runs: Number(params.get("runs") ?? "2"), - fps: Number(params.get("fps") ?? "60"), - // "good" is the dialog's own default, i.e. what the UI A/B measured. - quality: QUALITY[params.get("quality") ?? "1080p"] ?? "good", - effects: (params.get("effects") ?? "").split(",").filter(Boolean), - // Cap the timeline to its first N seconds (in memory). Iteration speed: - // per-frame stage attribution is a steady-state metric, so ~180 frames say - // what 820 say, at a quarter of the wait. Wall/fps from a capped run are NOT - // comparable with full-length runs — compare per-frame, or same-cap runs. - clip: params.get("clip") ? Number(params.get("clip")) : null, - // Discarded runs before the measured ones. The session's FIRST export pays for - // shader compilation, decoder setup and JIT, and it lands on whichever arm ran - // first: measured at 9.3 s against 5.6/6.6/5.8 s for its own repeats — a 60% - // same-arm spread that voided the run all by itself, while the arm that merely - // went second looked better. One warm-up costs ~5 s at --clip=4. - warmup: Number(params.get("warmup") ?? "1"), - // Emit this frame of every arm as a PNG, so the pictures can be compared. The - // only thing that says a compositor WORKS; fps says only that it ran. - dumpFrame: params.get("dumpFrame"), -}; - -/** - * Appearance the exporter reads out of `legacyEditor`, patched onto an in-memory - * COPY of the document. - * - * Most saved projects carry no appearance at all, so the defaults apply: - * shadowIntensity 0, showBlur false, borderRadius 0, wallpaper "". Whole effects - * therefore never execute, and "fixing" them would have measured exactly zero on - * a project that never ran them. This turns them on without writing to the - * user's project store — nothing here reaches disk. - * - * --effects=shadow,blur,radius - */ -const EFFECT_PATCHES: Record> = { - // Three chained drop-shadows over the full frame, every frame. - shadow: { shadowIntensity: 1 }, - // A static wallpaper, re-blurred every frame. - blur: { showBlur: true }, - radius: { borderRadius: 24 }, - motionBlur: { motionBlurAmount: 1 }, - // Without padding the recording covers the whole stage, so the background, the - // rounded corners and the shadow are all computed and then hidden behind it. - // The COST is real either way — which is why the timing arms never needed this - // — but a parity check has to be able to SEE what it is comparing. - padding: { padding: 45 }, - // Turning an effect OFF is not the same as omitting it: a saved project has - // its own appearance (this bench's reference carries shadowIntensity 0.52 and - // motionBlurAmount 0.31), so an arm that "doesn't add shadow" still renders - // the project's. These patches are what make an arm PAIR isolate one effect. - noShadow: { shadowIntensity: 0 }, - noMotionBlur: { motionBlurAmount: 0 }, -}; - -/** - * A zoom region, injected the same way: the saved projects have `zoomRanges: []`. - * - * It is not decoration. Zoom is the one effect that changes the composited - * GEOMETRY every frame, so it is what invalidates any geometry-keyed cache. A - * parity test on a project without zoom would happily pass with a broken cache - * key, because nothing would ever ask it to invalidate. - * - * Parsed through the real schema before it is injected, because this region was - * born with `depth: "medium"` — a value nothing accepts. `ZOOM_DEPTH_SCALES` - * keys on 1..6, so the lookup returned undefined and the zoom silently did - * NOTHING: the arm reported a clean number for an effect that never ran. The - * schema is the only thing that knows what the pipeline accepts, so ask it. - */ -function zoomRanges(doc: AxcutDocument): unknown[] { - const clips = (doc as { timeline?: { clips?: { timelineEndSec?: number }[] } }).timeline?.clips; - const endSec = clips?.[0]?.timelineEndSec ?? 5; - return [ - zoomRegionSchema.parse({ - id: "bench-zoom", - startMs: 500, - endMs: Math.max(1500, Math.round(endSec * 1000) - 500), - // 3 is DEFAULT_ZOOM_DEPTH, and what the reference project's own zoom uses. - depth: 3, - focus: { cx: 0.5, cy: 0.5 }, - focusMode: "manual", - }), - ]; -} - -/** - * Truncate the timeline to its first N seconds, on the in-memory copy only. - * - * Clips are 1:1 with source time in the v4 model (speed is applied later, from - * legacyEditor.speedRegions, by the export segment loop), so capping timeline - * and source together keeps the document coherent. Regions past the cap simply - * never fire, like on any short project. Nothing here reaches disk. - */ -function withClipCap(doc: AxcutDocument, seconds: number | null): AxcutDocument { - if (!seconds || !(seconds > 0)) return doc; - const timeline = ( - doc as { - timeline?: { - clips?: { - sourceStartSec: number; - sourceEndSec: number; - timelineStartSec: number; - timelineEndSec: number; - }[]; - }; - } - ).timeline; - if (!timeline?.clips?.length) return doc; - const clips = []; - for (const clip of timeline.clips) { - if (clip.timelineStartSec >= seconds) continue; - if (clip.timelineEndSec <= seconds) { - clips.push(clip); - continue; - } - const keep = seconds - clip.timelineStartSec; - clips.push({ - ...clip, - timelineEndSec: seconds, - sourceEndSec: clip.sourceStartSec + keep, - }); - } - if (clips.length === 0) { - throw new Error(`--clip=${seconds} leaves no timeline at all`); - } - return { ...doc, timeline: { ...timeline, clips } } as AxcutDocument; -} - -function withEffects(doc: AxcutDocument, effects: string[]): AxcutDocument { - if (effects.length === 0) return doc; - const legacy: Record = { - ...((doc as { legacyEditor?: Record }).legacyEditor ?? {}), - }; - let patched = doc; - for (const name of effects) { - if (name === "zoom") { - patched = { ...patched, zoomRanges: zoomRanges(doc) } as AxcutDocument; - continue; - } - const patch = EFFECT_PATCHES[name]; - if (!patch) { - throw new Error(`Unknown effect "${name}". Known: ${Object.keys(EFFECT_PATCHES)}, zoom`); - } - Object.assign(legacy, patch); - } - // `blur` only does anything against a real wallpaper; a project with none - // would silently skip the very pass being measured. - if (effects.includes("blur") && !legacy.wallpaper) { - legacy.wallpaper = "wallpaper13.jpg"; - } - return { ...patched, legacyEditor: legacy } as AxcutDocument; -} - -const ARMS: Record = { - // The path we ship today: WebCodecs encodes straight off the GPU texture. - webcodecs: { nativeEncode: false, readFrequently: false }, - // Frames descend to the CPU and cross to ffmpeg. - native: { nativeEncode: true, readFrequently: false }, - // Native, but with CPU-backed canvases (what Linux already does) so the - // per-frame descent is a memcpy rather than a GPU round-trip. - "native-cpu": { nativeEncode: true, readFrequently: true }, - // Control: isolates the canvas change from the encoder change. Without this - // arm a native-cpu win could be the canvas, the encoder, or neither. - "webcodecs-cpu": { nativeEncode: false, readFrequently: true }, - // Ceiling, not a candidate: descend every frame to RAM, then discard it — - // the crossing costs exactly zero. Bounds EVERY "remove the crossing" - // proposal at once (option A' sandbox:false, shared memory, zero-copy - // transfer), because none of them can skip the readback. Writes no file. - "readback-ceiling": { nativeEncode: true, readFrequently: false, dropFrames: true }, - // Prices the GPU compositing of the real effect set, with nothing downstream - // to absorb it. This is the number the native-core case rests on: a pipeline - // that composites on-device cannot beat decode + this + encode. Writes no file. - "composite-ceiling": { nativeEncode: true, readFrequently: false, compositeOnly: true }, - // The same ceiling with the compositor fixes undone. Pairing these two in one - // interleaved run is the only honest way to price the fixes: across sessions - // this machine drifts further than they are worth. - "composite-ceiling-legacy": { - nativeEncode: true, - readFrequently: false, - compositeOnly: true, - legacyCompositor: true, - }, - /** Today's shipping path, with the compositor fixes undone. */ - "webcodecs-legacy": { nativeEncode: false, readFrequently: false, legacyCompositor: true }, - // Gate G0 (rendering-architecture.md §7.1): the shipping path, but the GPU is - // forced to FINISH compositing before the encode timers start. The claim under - // test is pure attribution — encodeWait has been billing the compositor's - // execution. If §7.1 holds, encodeWait collapses to the encoder's own time and - // the difference reappears under `fence`; the wall itself may even worsen - // (the fence removes compositor/encoder overlap), which is fine — G0 is about - // where the time GOES, not how much there is. Run interleaved with its - // unfenced twin. - "webcodecs-fence": { nativeEncode: false, readFrequently: false, gpuFence: true }, - // Same gate against the PRE-fix compositor — the arm the spec's numbers - // (encodeWait ~18.9 → ~6 ms) are actually quoted for. - "webcodecs-legacy-fence": { - nativeEncode: false, - readFrequently: false, - legacyCompositor: true, - gpuFence: true, - }, - // Step 3 (rendering-architecture.md §13): the missing L7 row. A zoom is the one - // effect that moves the composited geometry every frame, so it is what the - // shadow cache cannot hold — pair it with `webcodecs-fence` in ONE interleaved - // run and the difference is the cost of a moving camera, on one thermal state. - // Fenced on both sides so the compositor's cost lands in `fence` on both, - // instead of hiding in the encoder's queue on both. - "webcodecs-fence-zoom": { - nativeEncode: false, - readFrequently: false, - gpuFence: true, - addEffects: ["zoom"], - }, - // The other half of the Step-3 question. A zoom does not only miss the shadow - // cache — it also puts the camera in motion, which switches the motion-blur - // filter on. Run these two against the pair above and the arithmetic separates - // the shadow from everything else the zoom drags in: - // (zoom − zoom-noshadow) = the shadow with the cache MISSING every frame - // (still − still-noshadow) = the shadow with the cache HOLDING - // Without them, "zoom is slower" is true and says nothing about what to fix. - "webcodecs-fence-noshadow": { - nativeEncode: false, - readFrequently: false, - gpuFence: true, - addEffects: ["noShadow"], - }, - "webcodecs-fence-zoom-noshadow": { - nativeEncode: false, - readFrequently: false, - gpuFence: true, - addEffects: ["zoom", "noShadow"], - }, - // Splits the 16.7 ms cache miss in two, which is what decides the SHAPE of the - // fix. Against `webcodecs-fence-zoom` it prices the three gaussians; against - // `webcodecs-fence-zoom-noshadow` it prices the full-frame plumbing that feeds - // them. A shader is the answer to the first; touching less of the frame is the - // answer to the second. Renders no shadow — measurement only, never shipped. - "webcodecs-fence-zoom-nofilter": { - nativeEncode: false, - readFrequently: false, - gpuFence: true, - shadowNoFilter: true, - addEffects: ["zoom"], - }, - // The POC (Step 4). Pair each against its Canvas2D twin above — same decode, - // same encoder, same document, one interleaved run — and the difference is the - // compositor and nothing else. The zoom pair is the one that matters: it is - // the case the 2D cache cannot hold and the shader has no cache to miss. - wgsl: { nativeEncode: false, readFrequently: false, wgslCompositor: true }, - // The parity gate, not a speed arm: the native sink WRITES AN MP4, so this pair - // produces one file each — same timeline, same encoder, same bitrate, one - // compositor apart — and the two pictures can be diffed. The descent to ffmpeg - // makes them slow, which does not matter: nobody is timing a correctness check. - "wgsl-native-zoom": { - nativeEncode: true, - readFrequently: false, - wgslCompositor: true, - addEffects: ["zoom"], - }, - "native-zoom": { nativeEncode: true, readFrequently: false, addEffects: ["zoom"] }, - "wgsl-fence": { - nativeEncode: false, - readFrequently: false, - gpuFence: true, - wgslCompositor: true, - }, - "wgsl-fence-zoom": { - nativeEncode: false, - readFrequently: false, - gpuFence: true, - wgslCompositor: true, - addEffects: ["zoom"], - }, - // The shader compositor minus the shadow cascade. Against `wgsl-fence` it - // prices the cascade — 18 full-frame passes, which is the naive version of an - // algorithm whose whole point was that it is cheap on a GPU. - "wgsl-fence-noshadow": { - nativeEncode: false, - readFrequently: false, - gpuFence: true, - wgslCompositor: true, - addEffects: ["noShadow"], - }, - // These two WRITE FILES, which is what makes them the parity gate: encode the - // same timeline with the old and new compositor through the same encoder at - // the same bitrate, then diff the results (SSIM). Unit tests never look at a - // pixel, and "obviously equivalent" is what this investigation keeps punishing. - "native-legacy": { nativeEncode: true, readFrequently: false, legacyCompositor: true }, -}; - -function applyArm(arm: BenchArm): void { - localStorage.setItem("openscreen.nativeEncode", arm.nativeEncode ? "1" : "0"); - localStorage.setItem("openscreen.readFrequently", arm.readFrequently ? "1" : "0"); - localStorage.setItem("openscreen.dropFrames", arm.dropFrames ? "1" : "0"); - localStorage.setItem("openscreen.compositeOnly", arm.compositeOnly ? "1" : "0"); - localStorage.setItem("openscreen.legacyCompositor", arm.legacyCompositor ? "1" : "0"); - localStorage.setItem("openscreen.gpuFence", arm.gpuFence ? "1" : "0"); - localStorage.setItem("openscreen.shadowNoFilter", arm.shadowNoFilter ? "1" : "0"); - localStorage.setItem("openscreen.wgslCompositor", arm.wgslCompositor ? "1" : "0"); - if (BENCH_PARAMS.dumpFrame) localStorage.setItem("openscreen.dumpFrame", BENCH_PARAMS.dumpFrame); - else localStorage.removeItem("openscreen.dumpFrame"); -} - -/** - * aiEdition.get() reports failure as {success:false, error} rather than - * throwing, so an unchecked read of .document turns every load error into a - * silent null — which is exactly how "project not found" masked the real reason - * once already. Surface it. - */ -async function loadDocument(id: string): Promise { - const result = (await nativeBridgeClient.aiEdition.get(id)) as { - success?: boolean; - document?: AxcutDocument; - error?: string; - }; - if (!result?.success || !result.document) { - throw new Error(`Cannot load project ${id}: ${result?.error ?? "no document returned"}`); - } - return result.document; -} - -/** - * Accepts a project id, an id prefix, or a title. - * - * Titles need care: a summary's title is the name the project was CREATED with - * ("Recording 15/07/2026 18:38:53") and does not follow a rename, so the one - * shown in the editor — the document's own project.title — may match nothing in - * the summary list. Hence the fallback that opens documents to check. It is the - * slow path on purpose: only reached when the cheap matches miss. - */ -async function resolveDocument(projectRef: string | null): Promise { - const projects = await nativeBridgeClient.aiEdition.listProjects(); - if (projects.length === 0) throw new Error("No saved projects to bench against"); - if (!projectRef) return loadDocument(projects[0].id); - - const summary = - projects.find((p) => p.id === projectRef) ?? - projects.find((p) => p.id.startsWith(projectRef)) ?? - projects.find((p) => p.title === projectRef); - if (summary) return loadDocument(summary.id); - - const failures: string[] = []; - for (const p of projects) { - try { - const doc = await loadDocument(p.id); - if (doc.project?.title === projectRef) return doc; - } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)); - } - } - throw new Error( - `Project "${projectRef}" not found by id, id prefix, or title ` + - `(${projects.length} searched, ${failures.length} failed to load).` + - (failures.length ? `\nFirst load failure: ${failures[0]}` : ""), - ); -} - -/** - * Largest source on the timeline — mirrors ExportDialog's referenceSource so - * "Source" quality picks the same output size the UI would. - */ -function referenceSource(doc: AxcutDocument): { width?: number; height?: number } { - let best: { width?: number; height?: number } = {}; - let bestArea = 0; - for (const asset of doc.assets ?? []) { - const w = asset.video?.width; - const h = asset.video?.height; - if (!w || !h) continue; - if (w * h > bestArea) { - bestArea = w * h; - best = { width: w, height: h }; - } - } - return best; -} - -interface Captured { - stages: Record; - /** The exporter's own loop numbers, which exclude the bench's setup. */ - loop: { wallMs: number; frames: number; fps: number } | null; - /** Shadow cache hits/misses — the Step-3 decision input. */ - shadow: { hits: number; misses: number } | null; - restore: () => void; -} - -/** The perf line the exporter prints is for humans; the bench needs the numbers. */ -function captureStages(): Captured { - const captured: Captured = { - stages: {}, - loop: null, - shadow: null, - restore: () => { - console.warn = original; - }, - }; - const original = console.warn; - console.warn = (...args: unknown[]) => { - const text = args.map(String).join(" "); - if (text.includes("[export perf]")) { - // "[export perf] wall 77557ms · 546 frames · 7.0 fps" - const head = /wall\s+([\d.]+)ms\D+(\d+)\s+frames\D+([\d.]+)\s+fps/.exec(text); - if (head) { - captured.loop = { - wallMs: Number(head[1]), - frames: Number(head[2]), - fps: Number(head[3]), - }; - } - // "[export perf] shadow cache: 100 hits, 20 misses (16.7% miss of 120)" - const shadow = /shadow cache:\s*(\d+)\s+hits,\s*(\d+)\s+misses/.exec(text); - if (shadow) { - captured.shadow = { hits: Number(shadow[1]), misses: Number(shadow[2]) }; - } - for (const line of text.split("\n")) { - // " render 1680.0 3.0% 3.08 n=546" - const m = /^\s*([a-zA-Z]+)\s+([\d.]+)\s+[\d.]+%/.exec(line); - if (m && m[1] !== "TOTAL") captured.stages[m[1]] = Number(m[2]); - } - } - original(...args); - }; - return captured; -} - -async function runOnce( - doc: AxcutDocument, - armName: string, - arm: BenchArm, - run: number, -): Promise { - applyArm(arm); - const src = referenceSource(doc); - const capture = captureStages(); - const started = performance.now(); - try { - const result = await exportAxcutDocument(doc, { - format: "mp4", - quality: BENCH_PARAMS.quality, - frameRate: BENCH_PARAMS.fps, - codec: "h264", - sourceWidth: src.width, - sourceHeight: src.height, - }); - const wallMs = performance.now() - started; - // The native arm returns no blob (ffmpeg wrote the file itself), so a - // missing blob is not a failure here the way it is in ExportDialog. - if (!result.success) throw new Error(result.error ?? "export failed"); - if (!capture.loop) throw new Error("exporter printed no [export perf] line"); - return { - arm: armName, - run, - ok: true, - // The exporter's own loop wall, not the bench's: comparing arms means - // comparing the frame loop, not project loading. - wallMs: capture.loop.wallMs, - setupMs: wallMs - capture.loop.wallMs, - frames: capture.loop.frames, - fps: capture.loop.fps, - stages: capture.stages, - shadow: capture.shadow ?? undefined, - }; - } catch (error) { - return { - arm: armName, - run, - ok: false, - error: error instanceof Error ? error.message : String(error), - wallMs: performance.now() - started, - frames: 0, - fps: 0, - stages: capture.stages, - }; - } finally { - capture.restore(); - } -} - -/** - * Interleaves arms rather than running each arm's repeats together: A,B,A,B. - * Same-arm repeats then bracket the other arm in time, so drift shows up as - * disagreement between a run and its own repeat instead of masquerading as the - * effect under test. The first A/B on this machine drifted 26% between two - * identical runs — enough to invert the conclusion. - */ -export async function runBench(): Promise { - const results: BenchRunResult[] = []; - const emit = (event: string, payload: unknown) => - // Picked up by the runner off stdout; console.warn is what the app's - // console forwarder actually relays to the main process. - console.warn(`[bench] ${JSON.stringify({ event, ...(payload as object) })}`); - - try { - for (const armName of BENCH_PARAMS.arms) { - if (!ARMS[armName]) throw new Error(`Unknown arm "${armName}"`); - } - // Loaded once: the title fallback can open every project on disk, and every - // arm must see the same source document. - // Cap BEFORE effects, so the injected zoom fits inside the capped window. - const base = withClipCap(await resolveDocument(BENCH_PARAMS.project), BENCH_PARAMS.clip); - // One document per EFFECT SET, not per arm: arms sharing a set share the - // exact object, so they cannot differ by anything but their own flags. - const docs = new Map(); - const docFor = (arm: BenchArm): AxcutDocument => { - const effects = [...BENCH_PARAMS.effects, ...(arm.addEffects ?? [])]; - const key = effects.join(","); - const existing = docs.get(key); - if (existing) return existing; - const built = withEffects(base, effects); - docs.set(key, built); - return built; - }; - emit("start", { - arms: BENCH_PARAMS.arms, - runs: BENCH_PARAMS.runs, - project: base.project?.title ?? "(untitled)", - effects: BENCH_PARAMS.effects.length ? BENCH_PARAMS.effects.join("+") : "(project default)", - clip: BENCH_PARAMS.clip, - warmup: BENCH_PARAMS.warmup, - }); - // Warm up EVERY arm, not just the first: each one's first pass is the one - // that pays. Results are dropped on the floor. - for (let w = 0; w < BENCH_PARAMS.warmup; w++) { - for (const armName of BENCH_PARAMS.arms) { - const arm = ARMS[armName]; - const result = await runOnce(docFor(arm), armName, arm, 0); - // A warm-up that FAILS is still a broken arm — say so now rather than - // let the measured pass report the same failure four times. - if (!result.ok) throw new Error(`warm-up failed for ${armName}: ${result.error}`); - emit("warmup", { arm: armName, wallMs: result.wallMs, fps: result.fps }); - } - } - for (let run = 1; run <= BENCH_PARAMS.runs; run++) { - for (const armName of BENCH_PARAMS.arms) { - const arm = ARMS[armName]; - // The runner needs to know whose frame a dumped PNG is: the exporter - // emits it mid-run, before the result that would have named the arm. - emit("armStart", { arm: armName, run }); - const result = await runOnce(docFor(arm), armName, arm, run); - results.push(result); - emit("run", result); - } - } - emit("done", { results }); - } catch (error) { - emit("fatal", { error: error instanceof Error ? error.message : String(error) }); - } - await window.electronAPI?.benchFinished?.(); -} diff --git a/src/components/ai-edition/ArrowSvgs.tsx b/src/components/ai-edition/ArrowSvgs.tsx deleted file mode 100644 index fffbb08a5b..0000000000 --- a/src/components/ai-edition/ArrowSvgs.tsx +++ /dev/null @@ -1,212 +0,0 @@ -// Inline SVG arrows for 8 directions (pure paths, not icon fonts, so the native -// compositor replicates them identically — cf. `arrow_segments_viewbox` in regions.rs, -// which carries the same numbers and is pinned to them by a test). -// -// Les barbes des quatre DIAGONALES ont été refaites pour égaler les cardinales sur les DEUX -// paramètres qui font une pointe de flèche : la longueur (21,2 unités de viewBox, contre 15,8 -// à l'origine) ET l'ouverture. C'est l'angle qui trahissait le plus : les cardinales ouvrent à -// ±45° du fût, les diagonales à ±25° seulement, donnant un crochet étroit qui se fondait dans -// le fût dès que le trait épaississait. Les barbes sont désormais dérivées de la direction du -// fût par rotation de ±45°, la même règle qui produit exactement les (35,35)/(65,35) des -// cardinales — la géométrie est donc uniforme par construction, pas par retouche. - -import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; - -type ArrowDirection = NonNullable["arrowDirection"]; - -interface ArrowSvgProps { - color: string; - strokeWidth: number; - className?: string; -} - -export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) { - return ( - - - - - - - - - ); -} - -export function getArrowComponent(direction: ArrowDirection) { - switch (direction) { - case "up": - return ArrowUp; - case "down": - return ArrowDown; - case "left": - return ArrowLeft; - case "right": - return ArrowRight; - case "up-right": - return ArrowUpRight; - case "up-left": - return ArrowUpLeft; - case "down-right": - return ArrowDownRight; - case "down-left": - return ArrowDownLeft; - default: - return ArrowRight; - } -} diff --git a/src/components/ai-edition/preview-compositor/PreviewCompositor.module.css b/src/components/ai-edition/preview-compositor/PreviewCompositor.module.css deleted file mode 100644 index a1b3ade40d..0000000000 --- a/src/components/ai-edition/preview-compositor/PreviewCompositor.module.css +++ /dev/null @@ -1,56 +0,0 @@ -.container { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; - height: 100%; - align-items: center; - justify-content: center; -} - -.videoFrame { - position: relative; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} - -/* Decode source only — never painted. Kept in normal flow (not display:none) - so its intrinsic-ratio contain-fit box is real and CursorPreviewLayer's - ResizeObserver (which measures a `