Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
25 changes: 25 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<os>-<arch>/` (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/<tag>` 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
Expand Down
23 changes: 18 additions & 5 deletions components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
168 changes: 139 additions & 29 deletions components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<TranscriptFormat>(["txt", "md", "docx", "pdf"]);
/** Caption formats that support the short-cues toggle. */
Expand Down Expand Up @@ -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<ExportTab>("video");
Expand All @@ -121,6 +138,9 @@ export default function ExportDialog() {
const [timelineBusy, setTimelineBusy] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
/** Engine of the current / last render — native renders say so in the copy. */
const [renderEngine, setRenderEngine] = useState<MediaEngine | null>(null);
const [saving, setSaving] = useState(false);

const cuts = useCutRanges();
const editedDuration = useMemo(
Expand All @@ -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.
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -226,36 +244,55 @@ 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
// all; until now only the crashes that escaped this catch were.
reportError(err, `export-${activeTab}`);
setError(err instanceof Error ? err.message : en["error.export"]);
} finally {
setStatus("ready");
if (stillCurrent()) setStatus("ready");
}
}, [
videoFile,
Expand All @@ -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";
Expand Down Expand Up @@ -641,7 +701,7 @@ export default function ExportDialog() {
<div>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{t("export.rendering")}
{t(renderEngine === "native" ? "export.renderingNative" : "export.rendering")}
</span>
<span className="tabular-nums text-zinc-400 dark:text-zinc-500">
{Math.round(progress * 100)}%
Expand All @@ -654,13 +714,17 @@ export default function ExportDialog() {
/>
</div>
<p className="mt-3 text-xs text-zinc-400 dark:text-zinc-500">
{t("export.encodingHelp")}
{t(
renderEngine === "native"
? "export.encodingHelpNative"
: "export.encodingHelp"
)}
</p>
</div>
) : exportUrl ? (
) : exportResult?.kind === "blob" ? (
<div className="flex flex-col gap-2">
<a
href={exportUrl}
href={exportResult.url}
download={mediaFileName}
className="flex h-10 items-center justify-center gap-2 rounded-xl bg-neutral-600 px-4 text-sm font-medium text-white transition hover:bg-neutral-500 dark:bg-neutral-500 dark:hover:bg-neutral-400"
>
Expand All @@ -674,6 +738,52 @@ export default function ExportDialog() {
{t("export.reexport")}
</button>
</div>
) : exportResult?.kind === "file" ? (
<div className="flex flex-col gap-2">
{exportResult.savedName ? (
<>
<p className="truncate text-center text-xs text-zinc-500 dark:text-zinc-400">
{t("export.savedAs", { name: exportResult.savedName })}
</p>
<button
onClick={() => void revealNativeExport(exportResult.outputId)}
className="flex h-10 items-center justify-center gap-2 rounded-xl bg-neutral-600 px-4 text-sm font-medium text-white transition hover:bg-neutral-500 dark:bg-neutral-500 dark:hover:bg-neutral-400"
>
<FolderOpen size={15} className="shrink-0" />
<span className="truncate">{t(revealLabelKey())}</span>
</button>
<button
onClick={saveFileExport}
disabled={saving}
className="h-10 rounded-xl text-sm font-medium text-zinc-500 transition hover:bg-zinc-50 disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
{saving
? t("export.saving")
: t("export.saveFile", { name: mediaFileName })}
</button>
</>
) : (
<button
onClick={saveFileExport}
disabled={saving}
className="flex h-10 items-center justify-center gap-2 rounded-xl bg-neutral-600 px-4 text-sm font-medium text-white transition hover:bg-neutral-500 disabled:opacity-60 dark:bg-neutral-500 dark:hover:bg-neutral-400"
>
<Download size={15} className="shrink-0" />
<span className="truncate">
{saving
? t("export.saving")
: t("export.saveFile", { name: mediaFileName })}
</span>
</button>
)}
<button
onClick={startMediaExport}
disabled={saving}
className="h-10 rounded-xl text-sm font-medium text-zinc-500 transition hover:bg-zinc-50 disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
{t("export.reexport")}
</button>
</div>
) : (
<button
onClick={startMediaExport}
Expand Down
Loading
Loading