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
8 changes: 4 additions & 4 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ Everything else is derived:
5. ✅ Editing core: word selection → cut/restore, undo/redo, playback that
skips cuts, show/hide deleted words.
6. ✅ Export: keep-range concat render to MP4 with progress, download.
7. ✅ Flexible timeline editing: word-boundary drag, Split at playhead
(scene boundaries), clip trim handles, manual cuts merged into export.

## Future work

- Correct/retype words (text overrides for captions), speaker renaming.
- Filler-word detection ("um", "uh") with one-click removal.
- Larger Whisper variants + language selection UI; local model import for
air-gapped first runs.
- Smarter export: stream-copy for keyframe-aligned segments, WebCodecs-based
rendering for speed.
- Multi-clip projects, captions burn-in, audio-only export.
- Persist projects to IndexedDB / OPFS so refreshes keep edits.
- Multi-clip projects (reorder scenes), captions burn-in.
- Gap clips / insert silence between words.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ the media. Export the final cut — without your file ever leaving your device.
- 📥 **Import your own transcript** — skip Whisper and edit with an SRT, VTT, or JSON caption file
- 🧹 **Filler removal** — one-click cut of "um", "uh", and similar fillers
- 🗣️ **Speaker diarization** — the transcript is grouped by speaker
- 🎬 **Timeline** — waveform, word labels, cut regions, playhead, zoom
- 🎬 **Timeline** — waveform, wordbar with draggable timing handles, Split,
cut regions, playhead; scroll to zoom, side-scroll to pan
- ✂️ **Split & trim** — blade clips at the playhead; drag clip edges to refine
cuts beyond word boundaries
- 🎯 **Word timing** — zoom in and drag a word's edges when ASR alignment is off
- 🔴 **Cut edges** — drag either edge of a cut to trim independently of Whisper
timestamps; double-click to reset
- ⚡ **Live preview** — playback skips your cuts in real time
- 📦 **In-browser export** — frame-accurate MP4 (video) or M4A (audio) with ffmpeg.wasm
- 🎧 **Audio files** — edit podcasts, voice notes, and interviews the same way as video
Expand Down
21 changes: 21 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,24 @@ body {
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}

/* Timeline: split confirmation flash */
@keyframes tl-split-flash {
0% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.55);
}
100% {
box-shadow: 0 0 0 10px rgba(251, 191, 36, 0);
}
}
.tl-split-flash {
animation: tl-split-flash 0.42s ease-out;
}

.tl-trim-handle:hover > div {
transform: scaleX(1.4);
}
.tl-word-handle:hover > span {
opacity: 1 !important;
width: 2px;
}
12 changes: 11 additions & 1 deletion components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default function Editor() {
})();
}, [videoFile, skipTranscription, transcribe]);

// Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo.
// Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
Expand All @@ -61,6 +61,16 @@ export default function Editor() {
e.preventDefault();
if (e.shiftKey) s.redo();
else s.undo();
} else if (
e.key.toLowerCase() === "s" &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
s.status === "ready" &&
!s.exportOpen
) {
e.preventDefault();
s.splitAtPlayhead();
}
};
document.addEventListener("keydown", handler);
Expand Down
6 changes: 5 additions & 1 deletion components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function ExportDialog() {
const videoFile = useEditorStore((s) => s.videoFile);
const mediaKind = useEditorStore((s) => s.mediaKind);
const words = useEditorStore((s) => s.words);
const manualCuts = useEditorStore((s) => s.manualCuts);
const duration = useEditorStore((s) => s.duration);
const status = useEditorStore((s) => s.status);
const setStatus = useEditorStore((s) => s.setStatus);
Expand All @@ -21,7 +22,10 @@ export default function ExportDialog() {
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);

const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]);
const cuts = useMemo(
() => getCutRanges(words, duration, manualCuts),
[words, duration, manualCuts]
);
const editedDuration = useMemo(() => getEditedDuration(cuts, duration), [cuts, duration]);
const exporting = status === "exporting";
const isAudio = mediaKind === "audio";
Expand Down
62 changes: 31 additions & 31 deletions components/ImportTranscriptOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,37 +108,8 @@ export default function ImportTranscriptOption() {

return (
<>
<ModelOption
id="import"
label="Import transcript"
meta="SRT / VTT / JSON"
icon={FileText}
autoTrigger={false}
onSelect={(ctx) => {
menuRef.current = ctx;
const current = useEditorStore.getState().model;
if (isWhisperModel(current)) {
previousModelRef.current = current;
}
// Do not set model to "import" until a file is chosen. Close the menu
// before the OS dialog so cancel cannot leave it pinned open.
pickGenRef.current += 1;
setPicking(true);
setError(null);
ctx.closeMenu();
requestAnimationFrame(() => fileRef.current?.click());
}}
>
<ImportTrigger
label={triggerLabel}
busy={reading}
error={Boolean(error)}
enabled={triggerEnabled}
/>
<ImportStatus reading={reading} error={error} picking={picking} />
</ModelOption>
{/* Sibling of the option button — never nest <input> inside <button>. */}
<input
{/* Sibling of the option button — never nest <input> inside <button>. */}
<input
ref={fileRef}
type="file"
accept={TRANSCRIPT_ACCEPT}
Expand Down Expand Up @@ -190,6 +161,35 @@ export default function ImportTranscriptOption() {
})();
}}
/>
<ModelOption
id="import"
label="Import transcript"
meta="SRT / VTT / JSON"
icon={FileText}
autoTrigger={false}
onSelect={(ctx) => {
menuRef.current = ctx;
const current = useEditorStore.getState().model;
if (isWhisperModel(current)) {
previousModelRef.current = current;
}
// Do not set model to "import" until a file is chosen. Close the menu
// before the OS dialog so cancel cannot leave it pinned open.
pickGenRef.current += 1;
setPicking(true);
setError(null);
ctx.closeMenu();
requestAnimationFrame(() => fileRef.current?.click());
}}
>
<ImportTrigger
label={triggerLabel}
busy={reading}
error={Boolean(error)}
enabled={triggerEnabled}
/>
<ImportStatus reading={reading} error={error} picking={picking} />
</ModelOption>
</>
);
}
Expand Down
10 changes: 7 additions & 3 deletions components/MediaPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default function MediaPreview() {
const videoFile = useEditorStore((s) => s.videoFile);
const mediaKind = useEditorStore((s) => s.mediaKind);
const words = useEditorStore((s) => s.words);
const manualCuts = useEditorStore((s) => s.manualCuts);
const duration = useEditorStore((s) => s.duration);
const playing = useEditorStore((s) => s.playing);
const currentTime = useEditorStore((s) => s.currentTime);
Expand All @@ -26,7 +27,10 @@ export default function MediaPreview() {

const mediaRef = useRef<HTMLMediaElement | null>(null);
const isAudio = mediaKind === "audio";
const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]);
const cuts = useMemo(
() => getCutRanges(words, duration, manualCuts),
[words, duration, manualCuts]
);
const cutsRef = useRef(cuts);
useEffect(() => {
cutsRef.current = cuts;
Expand Down Expand Up @@ -103,7 +107,7 @@ export default function MediaPreview() {
<button
type="button"
onClick={togglePlay}
className="flex w-full max-w-md cursor-pointer flex-col items-center gap-3 rounded-2xl border border-zinc-200 bg-white px-8 py-14 text-center shadow-sm transition hover:border-zinc-300"
className="flex max-w-md cursor-pointer flex-col items-center gap-3 px-8 py-14 text-center transition"
>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-zinc-900 text-white">
<AudioLines size={24} />
Expand Down Expand Up @@ -133,7 +137,7 @@ export default function MediaPreview() {
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="max-h-full max-w-full cursor-pointer rounded-xl bg-black shadow-lg shadow-zinc-900/10"
className="max-h-full max-w-full cursor-pointer rounded-sm bg-black shadow-lg shadow-zinc-900/10"
/>
)}
</div>
Expand Down
Loading