diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b9ee1ac..87ae7e80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,12 @@ jobs: - name: Timeline export tests run: npm run test:timeline - - name: ffmpeg watchdog tests + # The native-engine tests render through the same static ffmpeg the + # desktop app bundles (they skip, rather than fail, without it). + - name: Fetch native ffmpeg + run: npm run fetch:ffmpeg -- --host + + - name: ffmpeg tests run: npm run test:ffmpeg - name: Build Next.js diff --git a/.gitignore b/.gitignore index 7f0fda5a..d7c82845 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ next-env.d.ts # runtime assets copied from node_modules on postinstall /public/vendor/ + +# native ffmpeg for the desktop app (scripts/fetch-ffmpeg.mjs) +/build/ffmpeg/ diff --git a/RELEASING.md b/RELEASING.md index 51941904..c4ed9a76 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -77,6 +77,31 @@ emulation can leave V8 without the SSE4.1 it requires to enable Wasm SIMD, and every wasm binary the app ships is a SIMD build, so the editor cannot start at all. See `lib/wasmFeatures.ts`. +## Native ffmpeg + +The desktop app extracts audio and renders exports with a bundled static +ffmpeg run from the main process (`electron/media.ts`), not ffmpeg.wasm — the +wasm heap caps out at 2 GiB and holds the whole render in memory, which is what +made long and high-resolution desktop exports fail. + +`npm run dist` / `npm run release` run `npm run fetch:ffmpeg` first, which +downloads the pinned [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static) +release for every arch the current OS builds (mac: x64 + arm64; win: x64, also +used for arm64 under emulation; linux: x64), checks it against the SHA-256 +digests in `scripts/fetch-ffmpeg.mjs`, and writes it to +`build/ffmpeg/-/` (gitignored). `build.extraResources` copies the +matching one into `resources/ffmpeg/`, and `scripts/after-pack.cjs` fails the +build if it's missing. osx-sign signs it with the rest of the bundle. + +The binaries are GPL builds; their LICENSE and README ship next to them. To +move to a newer upstream release, update `RELEASE_TAG` and the digests +(`gh api repos/eugeneware/ffmpeg-static/releases/tags/` lists them). + +If the bundled binary is missing or won't start on a user's machine, the app +falls back to ffmpeg.wasm and reports `stage=native-media-fallback` to Sentry. +`RESCRIPT_MEDIA_ENGINE=wasm` forces the wasm engine; `RESCRIPT_FFMPEG_PATH` +points at a specific binary. + ## How signing & notarization work (macOS) - `build.mac` in `package.json` sets `hardenedRuntime: true`, points at diff --git a/components/Editor.tsx b/components/Editor.tsx index 8ca39454..1bf1f3fc 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -4,7 +4,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels"; import { useEditorStore } from "@/lib/store"; import { getCutRanges, isWordCutOut } from "@/lib/edits"; -import { extractAudio, getFFmpeg, releaseFFmpeg } from "@/lib/ffmpeg"; +import { getFFmpeg, releaseFFmpeg } from "@/lib/ffmpeg"; +import { activeMediaEngine, extractAudio } from "@/lib/mediaEngine"; import { VAD_SAMPLE_RATE } from "@/lib/vad"; import { isNetworkError } from "@/lib/network"; import { isElectron } from "@/lib/platform"; @@ -216,14 +217,26 @@ export default function Editor() { (async () => { const s = useEditorStore.getState(); try { - s.setProgress({ message: en["progress.loadingMediaEngine"], value: null }); - await getFFmpeg(); + // The desktop app extracts with native ffmpeg in the main process, so + // there is no wasm core to load (or to release afterwards). + if ((await activeMediaEngine()) === "wasm") { + s.setProgress({ message: en["progress.loadingMediaEngine"], value: null }); + await getFFmpeg(); + } s.setProgress({ message: en["progress.extractingAudio"], value: null }); - const audio = await extractAudio(videoFile); + const audio = await extractAudio(videoFile, (staging) => { + // A restored project whose original moved: its saved bytes are + // copied out for ffmpeg first, which takes a moment on big files. + s.setProgress({ + message: en[staging ? "progress.preparingMedia" : "progress.extractingAudio"], + value: null, + }); + }); s.setAudio(audio); // ffmpeg's gigabyte is pure overhead from here until the user exports, // and holding it through model instantiation is what makes WebKit kill - // the tab. Export re-initialises it lazily from the HTTP cache. + // the tab. Export re-initialises it lazily from the HTTP cache. A no-op + // when nothing was loaded. await releaseFFmpeg(); if (restoreOnly || !audio) { s.setStatus("ready"); diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index abe97bc4..21748efc 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -6,20 +6,25 @@ import { Download, FileText, Film, + FolderOpen, Music, X, } from "lucide-react"; -import { useEditorStore } from "@/lib/store"; +import { useEditorStore, type ExportResult } from "@/lib/store"; import { reportError } from "@/lib/sentry"; import { trackEvent } from "@/lib/telemetry"; import { formatTime, getCutRanges, getEditedDuration, getKeepRanges } from "@/lib/edits"; +import type { + AudioExportFormat, + VideoExportFormat, + VideoExportResolution, +} from "@/lib/exportArgs"; +import { activeMediaEngine, exportMedia, type MediaEngine } from "@/lib/mediaEngine"; import { - exportAudio, - exportVideo, - type AudioExportFormat, - type VideoExportFormat, - type VideoExportResolution, -} from "@/lib/ffmpeg"; + discardNativeExport, + revealNativeExport, + saveNativeExport, +} from "@/lib/nativeMedia"; import { downloadTranscript, type TranscriptFormat, @@ -40,6 +45,18 @@ import { en } from "@/lib/i18n/messages/en"; type ExportTab = "video" | "audio" | "transcript" | "timeline"; +/** "Show in Finder" / "Show in Explorer" / "Show in folder", by desktop OS. */ +function revealLabelKey(): + | "export.showInFinder" + | "export.showInExplorer" + | "export.showInFolder" { + const platform = + typeof window === "undefined" ? undefined : window.rescriptDesktop?.platform; + if (platform === "darwin") return "export.showInFinder"; + if (platform === "win32") return "export.showInExplorer"; + return "export.showInFolder"; +} + /** Document formats that support an optional timestamps toggle. */ const DOC_FORMATS = new Set(["txt", "md", "docx", "pdf"]); /** Caption formats that support the short-cues toggle. */ @@ -102,8 +119,8 @@ export default function ExportDialog() { const hasAudioTrack = useEditorStore((s) => s.hasAudio); const status = useEditorStore((s) => s.status); const setStatus = useEditorStore((s) => s.setStatus); - const exportUrl = useEditorStore((s) => s.exportUrl); - const setExportUrl = useEditorStore((s) => s.setExportUrl); + const exportResult = useEditorStore((s) => s.exportResult); + const setExportResult = useEditorStore((s) => s.setExportResult); const isAudioProject = mediaKind === "audio"; const [tab, setTab] = useState("video"); @@ -121,6 +138,9 @@ export default function ExportDialog() { const [timelineBusy, setTimelineBusy] = useState(false); const [progress, setProgress] = useState(0); const [error, setError] = useState(null); + /** Engine of the current / last render — native renders say so in the copy. */ + const [renderEngine, setRenderEngine] = useState(null); + const [saving, setSaving] = useState(false); const cuts = useCutRanges(); const editedDuration = useMemo( @@ -136,7 +156,7 @@ export default function ExportDialog() { const timelineMeta = TIMELINE_FORMATS.find((f) => f.value === timelineFormat); const showTimelineFrameRate = timelineMeta?.needsFrameRate ?? true; const exporting = status === "exporting"; - const dialogBusy = exporting || timelineBusy; + const dialogBusy = exporting || timelineBusy || saving; const hasWords = words.length > 0; // Fall back when the remembered tab isn't valid for this project. @@ -168,11 +188,9 @@ export default function ExportDialog() { const mediaFileName = `${baseName}.edited.${mediaExt}`; const clearMediaExport = useCallback(() => { - const prev = useEditorStore.getState().exportUrl; - if (prev) URL.revokeObjectURL(prev); - setExportUrl(null); + setExportResult(null); setProgress(0); - }, [setExportUrl]); + }, [setExportResult]); const selectTab = useCallback( (next: ExportTab) => { @@ -226,28 +244,47 @@ export default function ExportDialog() { setError(null); setProgress(0); + // Free the previous render (a temp file on desktop) before making another. + setExportResult(null); setStatus("exporting"); + // Closing the window mid-render swaps the project out from under us; a + // result (or failure) that lands afterwards belongs to nothing on screen. + const stillCurrent = () => useEditorStore.getState().videoFile === videoFile; try { + setRenderEngine(await activeMediaEngine()); const keeps = getKeepRanges(cuts, duration); - const blob = + const result = await exportMedia( + videoFile, + keeps, + editedDuration, + setProgress, activeTab === "audio" - ? await exportAudio(videoFile, keeps, editedDuration, setProgress, { - format: audioFormat, - }) - : await exportVideo(videoFile, keeps, editedDuration, setProgress, { + ? { kind: "audio", format: audioFormat } + : { + kind: "video", withAudio: hasAudioTrack, format: videoFormat, resolution, - }); - const prev = useEditorStore.getState().exportUrl; - if (prev) URL.revokeObjectURL(prev); - setExportUrl(URL.createObjectURL(blob)); + } + ); + setRenderEngine(result.engine); + if (!stillCurrent()) { + if (result.engine === "native") discardNativeExport(result.outputId); + return; + } + const next: ExportResult = + result.engine === "native" + ? { kind: "file", outputId: result.outputId, size: result.size } + : { kind: "blob", url: URL.createObjectURL(result.blob) }; + setExportResult(next); trackEvent("export_completed", { kind: activeTab, format: activeTab === "audio" ? audioFormat : videoFormat, + engine: result.engine, ...(activeTab === "audio" ? {} : { resolution }), }); } catch (err) { + if (!stillCurrent()) return; // The message we show is friendly and lossy — "Export failed while // rendering the video" says nothing about which of ffmpeg's failure modes // it was. Send the original so the export path is visible in Sentry at @@ -255,7 +292,7 @@ export default function ExportDialog() { reportError(err, `export-${activeTab}`); setError(err instanceof Error ? err.message : en["error.export"]); } finally { - setStatus("ready"); + if (stillCurrent()) setStatus("ready"); } }, [ videoFile, @@ -269,9 +306,32 @@ export default function ExportDialog() { videoFormat, resolution, setStatus, - setExportUrl, + setExportResult, ]); + /** Desktop: native Save dialog, then move the render to where the user chose. */ + const saveFileExport = useCallback(async () => { + const current = useEditorStore.getState().exportResult; + if (current?.kind !== "file") return; + setSaving(true); + setError(null); + try { + const savedName = await saveNativeExport( + current.outputId, + mediaFileName, + t("export.saveDialogTitle") + ); + if (savedName && useEditorStore.getState().exportResult === current) { + setExportResult({ ...current, savedName }); + } + } catch (err) { + reportError(err, "export-save"); + setError(err instanceof Error ? err.message : en["error.exportSave"]); + } finally { + setSaving(false); + } + }, [mediaFileName, setExportResult, t]); + const textSupportsTimestamps = DOC_FORMATS.has(textFormat); const textSupportsShortCues = SUBTITLE_FORMATS.has(textFormat); const textSupportsSpeakerLabels = textFormat !== "json"; @@ -641,7 +701,7 @@ export default function ExportDialog() {
- {t("export.rendering")} + {t(renderEngine === "native" ? "export.renderingNative" : "export.rendering")} {Math.round(progress * 100)}% @@ -654,13 +714,17 @@ export default function ExportDialog() { />

- {t("export.encodingHelp")} + {t( + renderEngine === "native" + ? "export.encodingHelpNative" + : "export.encodingHelp" + )}

- ) : exportUrl ? ( + ) : exportResult?.kind === "blob" ? ( + ) : exportResult?.kind === "file" ? ( +
+ {exportResult.savedName ? ( + <> +

+ {t("export.savedAs", { name: exportResult.savedName })} +

+ + + + ) : ( + + )} + +
) : (