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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
- name: UI localization tests
run: npm run test:i18n

- name: Timeline export tests
run: npm run test:timeline

- name: Build Next.js
run: npm run build

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ builds auto-update from GitHub Releases. Prefer the browser? Use the
- 🔒 **Private by design** — no auth, no uploads; all media processing happens on-device
- 📝 **Word-level editing** — select words, press ⌫, the cut follows the text
- 📥 **Import your own transcript** — skip Whisper and edit with an SRT, VTT, or JSON caption file
- 📤 **Export hub** — video (MP4/WebM, 720p–4K), audio (M4A/MP3/WAV), transcript (TXT/MD), or subtitles (SRT/VTT/JSON)
- 📤 **Export hub** — video (MP4/WebM, 720p–4K), audio (M4A/MP3/WAV), transcript (TXT/MD), subtitles (SRT/VTT/JSON), or NLE timeline (Resolve/Premiere/FCP/AAF)
- 🧹 **Filler removal** — one-click cut of "um", "uh", and similar fillers
- 🔇 **Silence removal** — one-click cut of pauses and dead air (≥0.3s)
- 🗣️ **Speaker diarization** — the transcript is grouped by speaker
Expand All @@ -44,6 +44,7 @@ builds auto-update from GitHub Releases. Prefer the browser? Use the
timestamps; double-click to reset
- ⚡ **Live preview** — playback skips your cuts in real time
- 📦 **In-browser / desktop export** — frame-accurate re-encode with ffmpeg.wasm
- 🎞️ **NLE timeline export** — DaVinci Resolve / Premiere XML, Final Cut FCPXML, Pro Tools/Logic AAF
- 🎧 **Audio files** — edit podcasts, voice notes, and interviews the same way as video
- 🖥️ **Desktop app** — macOS, Windows, and Linux via Electron (signed + notarized on Mac)

Expand Down
Binary file added assets/aaf/scaffold.aaf
Binary file not shown.
11 changes: 11 additions & 0 deletions assets/aaf/scaffold.meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"maxClips": 64,
"editRate": 30,
"markerUrl": "file:///RESCRIPT_MEDIA_PLACEHOLDER",
"markerName": "RESCRIPT_MEDIA_PLACEHOLDER",
"sourceMobId": "urn:smpte:umid:060a2b34.01010105.01010f20.13000000.040078e3.beb94430.b7ae5183.e5fe733a",
"masterMobId": "urn:smpte:umid:060a2b34.01010105.01010f20.13000000.c28cf923.35fa45b3.ae4a1560.34ae9733",
"compMobId": "urn:smpte:umid:060a2b34.01010105.01010f20.13000000.5829115b.bdc94197.ad19d12f.d41e33d4",
"masterPictureSlot": 1,
"masterSoundSlot": 2
}
173 changes: 165 additions & 8 deletions components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useMemo, useState } from "react";
import {
Captions,
Clapperboard,
Download,
FileText,
Film,
Expand All @@ -24,12 +25,20 @@ import {
type SubtitleFormat,
type TranscriptDocFormat,
} from "@/lib/serializeTranscript";
import {
downloadTimelineExport,
TIMELINE_FORMATS,
TIMELINE_FRAME_RATES,
type TimelineExportFormat,
type TimelineFrameRate,
} from "@/lib/serializeTimeline";
import { AAF_MAX_CLIPS } from "@/lib/aaf/patchAaf";
import { useCutRanges } from "@/hooks/useCutRanges";
import { useI18n } from "./I18nProvider";
import { localizeRuntimeMessage } from "@/lib/i18n";
import { en } from "@/lib/i18n/messages/en";

type ExportTab = "video" | "audio" | "transcript" | "subtitles";
type ExportTab = "video" | "audio" | "transcript" | "subtitles" | "timeline";

const VIDEO_FORMATS: { value: VideoExportFormat; label: string }[] = [
{ value: "mp4", label: "MP4" },
Expand Down Expand Up @@ -83,6 +92,11 @@ export default function ExportDialog() {
const [transcriptFormat, setTranscriptFormat] =
useState<TranscriptDocFormat>("txt");
const [subtitleFormat, setSubtitleFormat] = useState<SubtitleFormat>("srt");
const [timelineFormat, setTimelineFormat] =
useState<TimelineExportFormat>("resolve");
const [timelineFrameRate, setTimelineFrameRate] =
useState<TimelineFrameRate>("30");
const [timelineBusy, setTimelineBusy] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);

Expand All @@ -91,7 +105,14 @@ export default function ExportDialog() {
() => getEditedDuration(cuts, duration),
[cuts, duration]
);
const keepRangeCount = useMemo(
() => getKeepRanges(cuts, duration).length,
[cuts, duration]
);
const aafOverCap =
timelineFormat === "aaf" && keepRangeCount > AAF_MAX_CLIPS;
const exporting = status === "exporting";
const dialogBusy = exporting || timelineBusy;
const hasWords = words.length > 0;

// Fall back when the remembered tab isn't valid for this project.
Expand All @@ -100,11 +121,13 @@ export default function ExportDialog() {
? "audio"
: tab === "audio" && !hasAudioTrack
? isAudioProject
? "transcript"
? "timeline"
: "video"
: (tab === "transcript" || tab === "subtitles") && !hasWords
? isAudioProject
? "audio"
? hasAudioTrack
? "audio"
: "timeline"
: "video"
: tab;

Expand Down Expand Up @@ -248,6 +271,49 @@ export default function ExportDialog() {
]
);

const exportTimeline = useCallback(async () => {
if (!videoFile) return;
setTimelineBusy(true);
setError(null);
try {
const keeps = getKeepRanges(cuts, duration);
const videoEl = useEditorStore.getState().videoEl;
const width =
videoEl && "videoWidth" in videoEl
? (videoEl as HTMLVideoElement).videoWidth || 1920
: 1920;
const height =
videoEl && "videoHeight" in videoEl
? (videoEl as HTMLVideoElement).videoHeight || 1080
: 1080;
await downloadTimelineExport(timelineFormat, {
keepRanges: keeps,
duration,
mediaFileName: videoFile.name,
projectName: baseName,
frameRate: timelineFrameRate,
withVideo: !isAudioProject,
withAudio: hasAudioTrack,
width,
height,
});
trackEvent("export_completed", { kind: "timeline", format: timelineFormat });
} catch (err) {
setError(err instanceof Error ? err.message : en["error.timelineExport"]);
} finally {
setTimelineBusy(false);
}
}, [
videoFile,
cuts,
duration,
timelineFormat,
timelineFrameRate,
baseName,
isAudioProject,
hasAudioTrack,
]);

if (!open) return null;

const tabs: {
Expand Down Expand Up @@ -287,14 +353,20 @@ export default function ExportDialog() {
disabled: !hasWords,
title: !hasWords ? t("export.noWordsFirst") : undefined,
},
{
id: "timeline",
label: t("export.timeline"),
icon: Clapperboard,
title: t("export.timelineTitle"),
},
];

// app-no-drag: the backdrop covers the draggable top bar, so it needs to take
// clicks (dismiss) rather than letting them move the window.
return (
<div
className="app-no-drag fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4 backdrop-blur-sm dark:bg-black/60"
onClick={() => !exporting && setOpen(false)}
onClick={() => !dialogBusy && setOpen(false)}
>
<div
className="w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl dark:bg-zinc-900 dark:shadow-black/50"
Expand All @@ -306,15 +378,15 @@ export default function ExportDialog() {
</h2>
<button
onClick={() => setOpen(false)}
disabled={exporting}
disabled={dialogBusy}
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-30 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
>
<X size={16} />
</button>
</div>

<div
className="mb-5 grid grid-cols-4 gap-0.5 rounded-xl bg-zinc-100 p-0.5 dark:bg-zinc-800"
className="mb-5 grid grid-cols-5 gap-0.5 rounded-xl bg-zinc-100 p-0.5 dark:bg-zinc-800"
role="tablist"
aria-label={t("export.type")}
>
Expand All @@ -326,7 +398,7 @@ export default function ExportDialog() {
type="button"
role="tab"
aria-selected={selected}
disabled={disabled || exporting}
disabled={disabled || dialogBusy}
title={title}
onClick={() => selectTab(id)}
className={`flex h-9 items-center justify-center gap-1.5 rounded-[0.625rem] text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-40 ${
Expand All @@ -342,7 +414,9 @@ export default function ExportDialog() {
})}
</div>

{(activeTab === "video" || activeTab === "audio") && (
{(activeTab === "video" ||
activeTab === "audio" ||
activeTab === "timeline") && (
<div className="mb-5 grid grid-cols-3 gap-2 text-center">
<Stat label={t("export.statOriginal")} value={formatTime(duration)} />
<Stat label={t("export.statCuts")} value={String(cuts.length)} />
Expand Down Expand Up @@ -417,6 +491,72 @@ export default function ExportDialog() {
</div>
)}

{activeTab === "timeline" && (
<div className="mb-5 space-y-4">
<OptionGroup
label={t("export.nle")}
value={timelineFormat}
options={TIMELINE_FORMATS.map(({ value, label }) => ({
value,
label,
}))}
disabled={timelineBusy}
onChange={setTimelineFormat}
/>
<div>
<p className="mb-2 text-[11px] font-medium tracking-wide text-zinc-400 dark:text-zinc-500">
{t("export.frameRate")}
</p>
<div
className="grid grid-cols-4 gap-0.5 rounded-lg bg-zinc-100 p-0.5 dark:bg-zinc-800"
role="radiogroup"
aria-label={t("export.frameRate")}
>
{TIMELINE_FRAME_RATES.map((opt) => {
const selected = timelineFrameRate === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
disabled={timelineBusy}
onClick={() => setTimelineFrameRate(opt.value)}
className={`flex h-8 items-center justify-center rounded-md px-1 text-[11px] font-medium tabular-nums transition disabled:opacity-40 ${
selected
? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
}`}
>
{opt.label}
</button>
);
})}
</div>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{t("export.timelineHelp")}{" "}
{t(
timelineFormat === "aaf"
? "export.timelineHelpAaf"
: timelineFormat === "fcpx"
? "export.timelineHelpFcpx"
: timelineFormat === "premiere"
? "export.timelineHelpPremiere"
: "export.timelineHelpResolve"
)}
</p>
{aafOverCap && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
{t("export.aafOverCap", {
count: keepRangeCount,
max: AAF_MAX_CLIPS,
})}
</p>
)}
</div>
)}

{error && (
<p className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/30 dark:bg-red-950/30 dark:text-red-900">
{localizeRuntimeMessage(error, t)}
Expand Down Expand Up @@ -497,6 +637,23 @@ export default function ExportDialog() {
{t("export.downloadFormat", { format: subtitleFormat })}
</button>
)}

{activeTab === "timeline" && (
<button
onClick={exportTimeline}
disabled={timelineBusy || !videoFile || aafOverCap}
className="flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-zinc-900 text-sm font-medium text-white transition hover:bg-zinc-700 disabled:opacity-40 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<Download size={15} />
{timelineBusy
? t("export.preparing")
: t("export.downloadFormat", {
format:
TIMELINE_FORMATS.find((f) => f.value === timelineFormat)
?.ext ?? "xml",
})}
</button>
)}
</div>
</div>
);
Expand Down
Loading
Loading