Repository navigation
Expand file tree
/
Copy pathNewEditorShell.tsx
More file actions
1802 lines (1723 loc) · 72.1 KB
/
Copy pathNewEditorShell.tsx
File metadata and controls
1802 lines (1723 loc) · 72.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import {
AUDIO_ROW_EXPANSION_PX,
computeAudioRowCount,
} from "@/lib/ai-edition/document/audioTracks";
import { createId } from "@/lib/ai-edition/document/ids";
import {
documentAfterProbedDuration,
PLACEHOLDER_DURATION_SEC,
} from "@/lib/ai-edition/document/timeline";
import {
type InsertSide,
insertDocumentWord,
removeDocumentWords,
setDocumentWordText,
} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
import type {
AxcutAudioTrack,
AxcutClip,
AxcutDocument,
AxcutShaderEffectRange,
} from "@/lib/ai-edition/schema";
import { withPastedShaderEffect } from "@/lib/ai-edition/shaderEffects";
import { useMcpDocumentHost } from "@/lib/ai-edition/store/mcpDocumentHost";
import { saveWithDeadline, useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
useAutoTranscription,
useTimelineTranscriptGate,
useTranscriptionStore,
} from "@/lib/ai-edition/store/transcriptionStore";
import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { future as redoStack, past as undoStack } from "@/lib/ai-edition/store/undoStack";
import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { isGeneratedAssetId } from "@/lib/ai-edition/timeline/clip-parts";
import { mergeCloseCuts } from "@/lib/ai-edition/timeline/cut-breath";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import {
dropTrimPillsByIds,
trimAppliesToClip,
ventilateTimelineSpanToTrims,
} from "@/lib/ai-edition/timeline/trim-mapping";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import { matchesShortcut } from "@/lib/shortcuts";
import { nativeBridgeClient } from "@/native";
import type { AiEditionProjectSummary } from "@/native/contracts";
import { resolveVisibleClips } from "@/native/sceneDescription";
import { useNativePlaybackSync } from "@/native/useNativePlaybackSync";
import { ExportDialog } from "./ExportDialog";
import { importProjectFile } from "./importProjectFile";
import { insertionsEnabled } from "./insertionsEnabled";
import { ChatStripPanel } from "./LeftPanel";
import {
EditClipModal,
NewProjectModal,
OpenProjectModal,
type StartingPoint,
UnsavedChangesModal,
type UnsavedChoice,
} from "./Modals";
import { Preview } from "./Preview";
import { importPendingRecording, maybeSaveFreshRecordingAutoZooms } from "./recordingImport";
import { AddAudioLayerDialog } from "./v4/AddAudioLayerDialog";
import v4 from "./v4/EditorShellV4.module.css";
import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar";
import { type Facet, FloatingInspector } from "./v4/FloatingInspector";
import { MediaStage } from "./v4/MediaStage";
import { RecStage } from "./v4/RecStage";
import { V4Timeline } from "./v4/V4Timeline";
interface SeekTarget {
timeSec: number;
isSource?: boolean;
requestId: number;
}
/**
* Renders nothing. Exists purely so the per-frame `currentTimeSec` subscription
* that feeds the native transport lives in a LEAF instead of in the shell.
*
* `currentTimeSec` is rewritten on every animation frame during playback
* (VirtualPreview's rAF tick → onTimeChange → setCurrentTime). Reading it
* directly in NewEditorShell re-rendered the entire editor — timeline, clips,
* waveforms, inspector, preview — 60×/s, which is what made the playhead and
* the transcript cue point stutter: the whole tree had to commit before the
* playhead's own DOM node moved. Everything that genuinely needs the live
* playhead now subscribes to it where it is actually rendered (PlayheadOverlay,
* TransportBar, Preview, TranscriptPane, NativeCompositorOverlay, and this
* component); the shell itself reads it imperatively via getState() in the
* handlers that need it. The store write cadence is unchanged — `currentTimeSec`
* is still the source of truth, still updated every frame.
*/
// Stable empty list: a fresh `[]` each render would churn the preview's audio
// element set on every playhead tick.
const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
function NativePlaybackSync({
visibleClips,
clips,
}: {
visibleClips: AxcutClip[];
clips: AxcutClip[];
}) {
const playing = useProjectStore((s) => s.playing);
const currentTimeSec = useProjectStore((s) => s.currentTimeSec);
// visibleClips = trim-compressed native stream; `clips` = RAW layout currentTimeSec
// is measured against. resolveNativePosition needs both (see timelineMap).
useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips);
return null;
}
export const DEFAULT_TIMELINE_HEIGHT_PX = 392;
export const MIN_TIMELINE_HEIGHT_PX = 160;
export const MAX_TIMELINE_HEIGHT_PX = 560;
/** The Effects lane's height (`.tlLane`: 24px and a 5px margin), added while the project has an
* effect: the lane only shows then (`V4Timeline`). */
export const EFFECT_LANE_EXPANSION_PX = 29;
/**
* What one `loadedmetadata` event does, from the front of the write queue.
*
* Exported because it is only reachable from outside the component: the event
* arrives through Preview, PreviewCanvas, VirtualPreview and a real `<video>`
* decoding real media, which no test environment here provides. The decision
* itself lives in `documentAfterProbedDuration`, and is tested next to it.
*
* Every store read is at call time, not from a closure. By the time this runs the
* document may have moved on, and `originatingProjectId` is what says whether it
* moved to a different project.
*/
export async function runLoadedMetadataWrite(
durationSec: number,
assetId: string,
originatingProjectId: string | undefined,
deps: {
/** Defaults to the real auto-zoom pass; injected in tests. */
autoZoom?: (document: AxcutDocument) => Promise<unknown>;
saveTimeoutMs?: number;
} = {},
): Promise<void> {
// ponytail: WebM recordings from MediaRecorder report NaN/Infinity until the
// main-process EBML fix lands. Seed against a placeholder so the timeline never
// gets stuck empty; `documentAfterProbedDuration` is what refuses to write it over
// a length something already measured. Auto-zoom is not fooled by it either --
// `hasProbedDurationForPendingAsset` asks the CLIPS, not the asset, and a clip
// still sitting at the placeholder reads as waiting.
const finite = Number.isFinite(durationSec) && durationSec > 0;
const knownSec = finite ? durationSec : PLACEHOLDER_DURATION_SEC;
const state = useProjectStore.getState();
// The placeholder exists so the FIRST clip is not empty, nothing more. Once a
// timeline is there, a length nothing measured has nothing to fold in -- and
// re-applying it to a clip already sitting at it means a `history: false` write on
// every metadata event for the same unmeasured take.
const next =
finite || state.document?.timeline.clips.length === 0
? documentAfterProbedDuration(state.document, assetId, knownSec, originatingProjectId)
: null;
if (next) {
// `history: false`: this is the probed duration being folded into the document
// on load, not something the user did — an undo landing on it would empty their
// timeline. Auto-zoom below is a later, undoable suggestion.
//
// Awaited so the queue can serialise it, but bounded: `saveDocument` awaits the
// bridge with no deadline of its own and never rejects, so a main process that
// stops answering would hold this queue slot — and every edit behind it — for
// the life of the renderer. The abandoned write is safe to let go of.
await saveWithDeadline(state.saveDocument(next, { history: false }), deps.saveTimeoutMs);
}
// Re-checked rather than assumed: the await above is exactly when a project
// switch lands, and the check inside the decision spoke only for the document as
// it was before it. Handing the auto-zoom pass another project's document would
// not write zooms into it — `canApplyFreshRecordingAutoZooms` refuses a document
// that does not hold the pending recording — but the passes before that check DO
// clear the pending flag on what they are given, so the take that was actually
// imported would silently lose its auto-zoom. A closed project ends the step for
// the same reason: there is nothing left this event belongs to.
const settled = useProjectStore.getState().document;
if (!settled || settled.project.id !== originatingProjectId) return;
// A document with no assets has no take to suggest zooms for. The auto-zoom pass
// would refuse it anyway; not calling it keeps "nothing to do" meaning nothing.
if (settled.assets.length === 0) return;
await (deps.autoZoom ?? maybeSaveFreshRecordingAutoZooms)(settled);
}
export function NewEditorShell() {
const te = useScopedT("editor");
useMcpDocumentHost();
const document = useProjectStore((s) => s.document);
const projectId = useProjectStore((s) => s.projectId);
const dirty = useProjectStore((s) => s.dirty);
const createProject = useProjectStore((s) => s.createProject);
const setCurrentTime = useProjectStore((s) => s.setCurrentTime);
const setSourceDuration = useProjectStore((s) => s.setSourceDuration);
const loadProject = useProjectStore((s) => s.loadProject);
const saveDocument = useProjectStore((s) => s.saveDocument);
// Single source of truth for transport state (was local useState here AND, separately
// and unused, in VirtualPreview — two copies independently wired to the same <video>
// events could disagree, see the "stops at clip end" fix history). Anything that needs
// to know or drive playback reads/writes this store field now, not a component-local copy.
const playing = useProjectStore((s) => s.playing);
const setPlaying = useProjectStore((s) => s.setPlaying);
const [seekTarget, setSeekTarget] = useState<SeekTarget | null>(null);
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
// v4 shell: three modes (Media / Edit / Rec), a collapsible agent (chat)
// column, and a floating facet inspector over the stage.
const [mode, setMode] = useState<EditorMode>("edit");
const [chatOpen, setChatOpen] = useState(false);
const pendingChatPrompt = useChatPromptBus((s) => s.pending);
useEffect(() => {
if (pendingChatPrompt && !chatOpen) {
setChatOpen(true);
}
}, [pendingChatPrompt, chatOpen]);
const [chatWidthPx, setChatWidthPx] = useState(
() => Number(localStorage.getItem("os-editor-chat-width")) || 392,
);
const [timelineBaseHeightPx, setTimelineBaseHeightPx] = useState(() => {
const raw = localStorage.getItem("os-editor-timeline-height");
// One-time migration: if legacy 308px default exists without the migration marker,
// migrate it once to DEFAULT_TIMELINE_HEIGHT_PX so existing users see all lanes,
// but allow them to intentionally choose 308px in the future.
// Also migrate intermediate 344px default to DEFAULT_TIMELINE_HEIGHT_PX.
const migrated = localStorage.getItem("os-editor-timeline-height-migrated");
if (!migrated && (!raw || Number(raw) === 308 || Number(raw) === 344)) {
localStorage.setItem("os-editor-timeline-height-migrated", "true");
localStorage.setItem("os-editor-timeline-height", String(DEFAULT_TIMELINE_HEIGHT_PX));
return DEFAULT_TIMELINE_HEIGHT_PX;
}
if (raw === "344") {
localStorage.setItem("os-editor-timeline-height", String(DEFAULT_TIMELINE_HEIGHT_PX));
return DEFAULT_TIMELINE_HEIGHT_PX;
}
const val = raw ? Number(raw) : 0;
if (!val) {
return DEFAULT_TIMELINE_HEIGHT_PX;
}
return Math.min(MAX_TIMELINE_HEIGHT_PX, Math.max(MIN_TIMELINE_HEIGHT_PX, val));
});
// The timeline height automatically expands by AUDIO_ROW_EXPANSION_PX (+29px)
// whenever the project transitions between 1 and 2 stacked audio rows (e.g. voiceover + music).
const audioRowCount = useMemo(
() => computeAudioRowCount(document?.audioTracks ?? []),
[document?.audioTracks],
);
const hasEffects = (document?.shaderEffectRanges?.length ?? 0) > 0;
const extraLaneHeightPx =
Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX +
(hasEffects ? EFFECT_LANE_EXPANSION_PX : 0);
const timelineHeightPx = Math.min(
MAX_TIMELINE_HEIGHT_PX,
Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraLaneHeightPx),
);
const [inspectorOpen, setInspectorOpen] = useState(true);
const [facet, setFacet] = useState<Facet>("effects");
const [openProjectOpen, setOpenProjectOpen] = useState(false);
const [newProjectOpen, setNewProjectOpen] = useState(false);
// Crop + trim in/out both live in EditClipModal now (per-clip), reachable
// from the timeline (double-click / pencil icon) and the inspector's
// "Edit clip" rail button — a single shell-level instance instead of one
// mounted per trigger site.
const [editClipTarget, setEditClipTarget] = useState<AxcutClip | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [unsavedPrompt, setUnsavedPrompt] = useState<{
action: "close" | "new" | "open" | "record";
resolve: (choice: UnsavedChoice) => void;
} | null>(null);
const { shortcuts, isMac, openConfig: openShortcutsConfig } = useShortcuts();
// The actions half of the dialog context, not the section: this component only ever *opens*
// one, and subscribing it to the open state would re-render the whole editor — timeline,
// preview, transport — twice per dialog interaction. Whether a dialog is open is a question
// for `isModalOpen`, which answers for every modal rather than for this context's one.
const { openDialog } = useEditorDialogActions();
// Transcription is local and every transcript-driven feature (Smart cuts,
// captions, the transcript pane) needs one, so the editor produces them by
// itself instead of waiting for the user to find the button. This hook is
// the ONLY place the background pass is driven from — see transcriptionStore.
useAutoTranscription();
const requestTimelineTranscripts = useTranscriptionStore((s) => s.requestTimelineTranscripts);
// Resolved over the assets the TIMELINE plays, not over the primary asset: in
// a recording project the primary asset is the screen capture, which is
// routinely silent, and keying the transcript pane off it made the pane claim
// "no audio track" for a project whose actual footage was mid-transcription.
const transcriptGate = useTimelineTranscriptGate();
// Per asset, for the transcript pane: only the block whose transcript is
// actually being rewritten goes read-only. The gate answers "may a
// transcript-dependent ACTION run?", which is a different question.
const transcriptions = useAssetTranscriptions();
const busyAssetIds = useMemo(
() =>
Object.values(transcriptions)
.filter((v) => v.status === "running" || v.status === "queued")
.map((v) => v.assetId),
[transcriptions],
);
// For the pane-level busy label: timeline-scoped like the gate, so an
// off-timeline job cannot relabel controls the gate keeps enabled.
const timelineBusyView = useMemo(
() => firstTimelineBusyView(document, transcriptions),
[document, transcriptions],
);
const tl = useTimeline();
// An undo only puts the restored document back in the store and marks it dirty,
// so without this the reverted state never reached disk: close the window and the
// edit the user just undid came back. `history: false` is load-bearing — a
// recording save here would push the restored document straight back onto the
// stack and clear the redo the undo had just created.
const { runUndo, runRedo } = useUndoRedoShortcuts(() => {
const doc = useProjectStore.getState().document;
if (doc) void useProjectStore.getState().saveDocument(doc, { history: false });
});
const [copiedClipId, setCopiedClipId] = useState<string | null>(null);
const [projectSummaries, setProjectSummaries] = useState<AiEditionProjectSummary[]>([]);
const seekSeqRef = useRef(0);
const initRef = useRef(false);
// Dev-only: expose the project store so the browser preview harness can
// seed a populated document for design QA. Tree-shaken out of prod builds.
if (import.meta.env.DEV) {
(window as unknown as { __osProjectStore?: typeof useProjectStore }).__osProjectStore =
useProjectStore;
}
// ponytail: serialise timeline-edit saves so two rapid Backspaces
// don't race each other's save and overwrite one another in the
// store. The hook reads the doc inside the chain (after awaiting the
// previous save) — see its source for the race this fixes.
// Only `enqueue` now: the two trim handlers were the last callers of `apply`, and both
// read the document inside the chain so a cut cannot be overwritten by a word edit
// landing between the read and the save. The `add_trim_range` / `remove_trim_range` ops
// stay for the agent, which addresses clips rather than moments.
const { enqueue: enqueueTimelineWrite } = useSequentialTimelineOps({
fallbackDocument: document,
saveDocument,
});
const promptUnsaved = useCallback(
(action: "close" | "new" | "open" | "record"): Promise<UnsavedChoice> => {
if (!dirty) return Promise.resolve("discard");
return new Promise<UnsavedChoice>((resolve) => {
setUnsavedPrompt({ action, resolve });
});
},
[dirty],
);
const primaryAssetPath =
document?.assets.find((a) => a.id === document.project.primaryAssetId)?.originalPath ?? null;
void primaryAssetPath;
const clips: AxcutClip[] = document?.timeline.clips ?? [];
const visibleClips = useMemo(() => (document ? resolveVisibleClips(document) : []), [document]);
const hasProject = Boolean(document);
const hasAsset = projectId !== null && (document?.assets.length ?? 0) > 0;
const project = document?.project
? {
id: document.project.id,
title: document.project.title,
updatedAt: new Date().toISOString(),
}
: null;
// refresh project list when the Open Project modal is open
useEffect(() => {
if (!openProjectOpen) return;
void (async () => {
try {
const next = await nativeBridgeClient.aiEdition.listProjects();
setProjectSummaries(next);
} catch {
// ponytail: silent
}
})();
}, [openProjectOpen]);
// Auto-load project recording session on mount
useEffect(() => {
if (initRef.current) return;
initRef.current = true;
void (async () => {
if (!window.electronAPI) return;
try {
if (await importPendingRecording((warning) => toast.warning(warning))) {
toast.success("Recording added to a new project");
return;
}
} catch (err) {
toast.error("Could not auto-create project from recording", {
description: err instanceof Error ? err.message : String(err),
});
return;
}
// ponytail: no recording waiting — restore the user's most recent
// project. The browser-shim's listProjects returns the seeded
// `browser-shim-projects` entries, so e2e tests can land directly in a
// populated editor; for real Electron users this is the expected "open
// last project on launch" UX — and, now that the recording hand-off is
// consumed on import, it is also what reopening the editor after a
// recording lands on: the project that recording went into, settings and
// all, instead of a second project on the same file.
try {
const projects = await nativeBridgeClient.aiEdition.listProjects();
console.info("[editor] listProjects returned", projects);
if (projects.length > 0) {
console.info("[editor] auto-loading project", projects[0].id);
await loadProject(projects[0].id);
const state = useProjectStore.getState();
console.info(
"[editor] post-loadProject status=",
state.status,
"error=",
JSON.stringify(state.error),
"doc=",
state.document ? "loaded" : "null",
);
}
} catch (e) {
console.warn("[editor] auto-load failed", e);
}
})();
}, [loadProject]);
// Warn on close when dirty
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (useProjectStore.getState().dirty) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
// Electron close interception
useEffect(() => {
if (!window.electronAPI) return;
// 1. Sync dirty state to Electron main process
window.electronAPI.setHasUnsavedChanges(dirty);
}, [dirty]);
useEffect(() => {
if (!window.electronAPI) return;
// 2. Handle request-close-confirm from Electron
const unsubCloseConfirm = window.electronAPI.onRequestCloseConfirm(() => {
void (async () => {
const choice = await promptUnsaved("close");
if (choice === "discard") {
window.electronAPI.sendCloseConfirmResponse("discard");
} else if (choice === "save") {
window.electronAPI.sendCloseConfirmResponse("save");
} else {
window.electronAPI.sendCloseConfirmResponse("cancel");
}
})();
});
// 3. Handle request-save-before-close from Electron
const unsubSaveBeforeClose = window.electronAPI.onRequestSaveBeforeClose(async () => {
const doc = useProjectStore.getState().document;
// The store already toasted the reason; answering false is what keeps the
// window open on top of it.
if (doc) return await saveDocument(doc, { history: true });
return true;
});
return () => {
unsubCloseConfirm?.();
unsubSaveBeforeClose?.();
};
}, [promptUnsaved, saveDocument]);
const videoSources = useMemo(() => {
if (!document) return [];
// Every asset, insertions included — an insertion is a clip on an asset with a real
// path. A file the save has not written yet simply fails to load, and the player
// reports it the way it reports any unreadable source: the edit stands, the picture
// catches up on the next save.
return document.assets.map((asset) => ({
id: asset.id,
filePath: /^(https?|blob|data):/.test(asset.originalPath) ? undefined : asset.originalPath,
// Real Electron assets are filesystem paths and go through toFileUrl.
// In the browser preview an asset can already point at an http(s)/
// blob/data URL served by Vite; toFileUrl would mangle those into a
// broken file:// URL, so pass web URLs through untouched.
src: /^(https?|blob|data):/.test(asset.originalPath)
? asset.originalPath
: toFileUrl(asset.originalPath),
label: asset.label,
}));
}, [document]);
const handleLoadedMetadata = useCallback(
(durationSec: number, assetId: string) => {
setSourceDuration(
Number.isFinite(durationSec) && durationSec > 0 ? durationSec : PLACEHOLDER_DURATION_SEC,
);
// Read before queueing: this is the project the event belongs to.
const originatingProjectId = useProjectStore.getState().document?.project.id;
// On the shared write queue, and reading the document inside it. Folding a
// probed duration in is a read-modify-write of the whole document, which is
// what `useSequentialTimelineOps` exists for -- its header says anything that
// reads the doc and saves it back belongs there. Off the queue, `getState()`
// returns the PRE-edit document while a user's save is still in flight (the
// store is only written once the bridge answers), and the full snapshot built
// from it lands after theirs and takes their edit with it.
//
// The auto-zoom pass goes on the same queued task rather than after it, so a
// fresh take's suggestions are serialised against user edits too.
void enqueueTimelineWrite(() =>
runLoadedMetadataWrite(durationSec, assetId, originatingProjectId),
);
},
[setSourceDuration, enqueueTimelineWrite],
);
const handleSeek = useCallback(
(timeSec: number) => {
setCurrentTime(timeSec);
setSeekTarget({ timeSec, isSource: false, requestId: ++seekSeqRef.current });
},
[setCurrentTime],
);
const handleTimeChange = useCallback(
(timeSec: number) => {
setCurrentTime(timeSec);
},
[setCurrentTime],
);
// Same race as `useSequentialTimelineOps` — see that file's header. `insertClipAt` is a
// read-modify-write of the whole document, so two adds in flight at once both read the
// pre-insert doc and the second `saveDocument` clobbers the first, silently dropping a
// clip. Two adds is one double-click on **Add to timeline** (the button has no pending
// state) or two quick drags.
//
// It goes on that hook's queue rather than one of its own: a second queue serialises
// adds against adds and nothing else, so an add still clobbers a trim landing at the
// same moment. It can't route through `apply()` — inserting a clip is not an
// AxcutTimelineOperation, it carries its own background duration probe — hence
// `enqueue`, which is the same chain without that constraint.
//
// The append index is read INSIDE the chain for the same reason the doc is: off the
// closure, `clips.length` stays frozen at the last render, so the second add lands
// before the first instead of after it.
const handleDropAsset = useCallback(
(assetId: string) =>
enqueueTimelineWrite(() => {
const doc = useProjectStore.getState().document;
// An audio asset has no video, so it must never become a clip (issue
// #350) — it goes on the audio lane as a track. Adding it "to the
// timeline" reuses its existing track if it already has one (importing
// already placed one) so the same file can't stack up duplicate lanes.
if (doc?.assets.find((a) => a.id === assetId)?.kind === "audio") {
if (doc.audioTracks.some((t) => t.assetId === assetId)) return Promise.resolve();
return tl.addAudioTrack(assetId).then(() => undefined);
}
const at = doc?.timeline.clips.length ?? 0;
return tl.insertClipAt(assetId, at);
}).catch((error) => {
toast.error(te("mediaStage.couldNotAddAsset"), {
description: error instanceof Error ? error.message : String(error),
});
throw error;
}),
[tl, te, enqueueTimelineWrite],
);
// Ref so the 'ended' listener below always sees the latest clips without tearing
// down and re-registering the DOM listener on every document change. (The playhead
// it also needs is read straight off the store at call time — see NativePlaybackSync
// above for why nothing in this component subscribes to it.)
const clipsForEndedRef = useRef(clips);
clipsForEndedRef.current = clips;
// ponytail: the transport bar (play/pause, prev/next, loop, fullscreen)
// lives in the timeline header now, not under the preview canvas — it
// needs the video element, so this state/these handlers moved up here
// from Preview.tsx to be shared with Bottombar.
useEffect(() => {
const el = videoElement;
if (!el) return;
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
// BUG corrigé : ce listener écoutait le même événement DOM natif 'ended' que le
// onEnded interne de VirtualPreview.tsx, sans la moindre logique multi-clip — il
// mettait TOUJOURS `playing` à false, y compris quand VirtualPreview venait
// juste d'enchaîner sur le clip suivant (les deux listeners réagissent au même
// événement, indépendamment). Deux endroits qui décident chacun de leur côté si
// la lecture doit s'arrêter = exactement le genre de duplication qui casse selon
// le chemin UX emprunté. On applique ici le même critère "y a-t-il un clip
// suivant ?" que VirtualPreview — seul point de
// vérité pour "y a-t-il encore de la timeline à jouer".
const onEnded = () => {
const playhead = useProjectStore.getState().currentTimeSec;
const hasNextClip = clipsForEndedRef.current.some((c) => c.timelineStartSec > playhead + 0.1);
if (!hasNextClip) setPlaying(false);
};
el.addEventListener("play", onPlay);
el.addEventListener("pause", onPause);
el.addEventListener("ended", onEnded);
setPlaying(!el.paused);
return () => {
el.removeEventListener("play", onPlay);
el.removeEventListener("pause", onPause);
el.removeEventListener("ended", onEnded);
};
// setPlaying is a stable Zustand action reference (never recreated), so listing it
// here doesn't cause this effect to re-subscribe on every playhead tick.
}, [videoElement, setPlaying]);
const togglePlay = useCallback(() => {
if (!videoElement) return;
if (videoElement.paused) {
// Same catch as VirtualPreview's: `play()` rejects on the autoplay policy
// or when a new load interrupts it, and the store's `playing` flag is
// driven by the element's own play/pause listeners above — so a rejection
// leaves nothing to reconcile, it just must not escape unhandled.
void videoElement.play().catch(() => {
// swallow: rejection just means playback never started
});
} else {
videoElement.pause();
}
}, [videoElement]);
// "Transcribe now" from the transcript pane. The run itself belongs to the
// transcription store (it owns the queue, the toasts and the failure
// bookkeeping) — all the shell adds is bringing the transcript into view
// once it lands.
const handleTranscribe = useCallback(async () => {
const before = useProjectStore.getState().document?.transcripts.length ?? 0;
await requestTimelineTranscripts();
if ((useProjectStore.getState().document?.transcripts.length ?? 0) > before) {
setMode("edit");
setFacet("transcript");
setInspectorOpen(true);
}
}, [requestTimelineTranscripts]);
const handleBrowseProject = useCallback(async () => {
try {
const result = await window.electronAPI?.loadProjectFile();
if (!result?.success || !result.project) return;
const { projectId, legacy } = await importProjectFile(result.project, result.storedProjectId);
await loadProject(projectId);
toast.success(legacy ? "Legacy project migrated and loaded" : "Project opened");
} catch (err) {
toast.error("Could not load project", {
description: err instanceof Error ? err.message : String(err),
});
}
}, [loadProject]);
// ponytail: transcript-pane → timeline skip ranges. The right pane's
// contentEditable region converts user Backspace/Delete into a new
// trimRange (NOT a destructive word removal — the source text stays
// intact, the word is just hidden by the skip overlay). Mirrors
// axcut's `queueAddTrimRange` / `queueRemoveTrimRange` callbacks in
// apps/web/src/App.tsx. The serialised save + inside-the-chain doc
// read is owned by `useSequentialTimelineOps` above.
// transcript-pane → a cut, authored as a stretch of the RAW ruler (issue #560).
//
// The pane used to hand over the asset and clip the words belonged TO, which is how a
// cut made on the voiceover lane came to be anchored on an audio fragment: it removed
// nothing from playback or the export while the word turned red. A cut is a moment of
// the programme, so the clips that carry it are resolved HERE, from the clips actually
// under the span — `ventilateTimelineSpanToTrims`, the same primitive a zoom straddling
// a boundary uses, so one gesture can become several rows and stay one pill.
//
// On `enqueueTimelineWrite`, not `applyTimelineOp`'s convenience or `tl.setTrimEntries`:
// the latter reads `useProjectStore.getState().document` unqueued, so correcting a word
// and immediately cutting the next one would let the word edit overwrite the cut. That
// is exactly the failure this chain exists to prevent.
const handleTrimTimelineSpan = useCallback(
(startSec: number, endSec: number, reason: string) => {
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
const ranges = ventilateTimelineSpanToTrims(startSec, endSec, doc.timeline.clips);
if (ranges.length === 0) {
// No nearest-clip fallback. A span over a gap, or past the last clip, names
// no film — cutting the closest thing instead would remove something the
// user never pointed at.
toast.error(te("errors.trimNoFilm"));
return;
}
const rows = ranges.map((range) => {
// No one-frame flash between this cut and one already on the same clip.
const merged = mergeCloseCuts(
{ startSec: range.sourceStartSec, endSec: range.sourceEndSec },
doc.timeline.trimRanges.filter((t) =>
trimAppliesToClip(t, { id: range.clipId, assetId: range.assetId }),
),
doc.assets.find((a) => a.id === range.assetId)?.video?.fps,
);
return {
id: createId("trim"),
assetId: range.assetId,
clipId: range.clipId,
...merged,
reason,
origin: "user" as const,
};
});
await saveDocument(
{
...doc,
timeline: { ...doc.timeline, trimRanges: [...doc.timeline.trimRanges, ...rows] },
},
{ history: true },
);
});
},
[enqueueTimelineWrite, saveDocument, te],
);
// Every row of the pill at once: a cut ventilated across a clip boundary is several
// rows and one pill, and `dropTrimPillsByIds` resolves the rest of the group from any
// member. Dropping half would leave the word still cut with nothing on screen to say so.
const handleRemoveTrimRanges = useCallback(
(trimIds: string[]) => {
if (trimIds.length === 0) return;
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
const next = dropTrimPillsByIds(doc.timeline.trimRanges, doc.timeline.clips, trimIds);
if (next.length === doc.timeline.trimRanges.length) return;
await saveDocument(
{ ...doc, timeline: { ...doc.timeline, trimRanges: next } },
{ history: true },
);
});
},
[enqueueTimelineWrite, saveDocument],
);
// transcript-pane → the word's own text. Unlike Backspace (which writes a trimRange and
// cuts the media), this writes only `transcript.words[].text`: the captions follow, the
// film is untouched. Queued on the SAME chain as the trims so correcting a word and
// cutting the next one cannot overwrite each other's save.
const handleSetWordText = useCallback(
(assetId: string, wordId: string, text: string) => {
void enqueueTimelineWrite(async () => {
// Read inside the chain: the previous save has resolved by now, so the store
// holds the document this edit has to be applied to.
const doc = useProjectStore.getState().document;
if (!doc) return;
// Correcting a transcribed word is a shipped feature; retyping an INSERTED one
// resizes generated media and asks the save for a new file of it, which is the
// same thing the gate above refuses. A release build must not do either.
if (!insertionsEnabled() && isGeneratedAssetId(assetId)) return;
try {
await saveDocument(setDocumentWordText(doc, assetId, wordId, text), { history: true });
} catch (err) {
// The word or its transcript vanished under the edit (a regeneration landed
// mid-typing). Nothing to retry — say so rather than dropping it silently.
toast.error(te("errors.wordEditFailed"), {
description: err instanceof Error ? err.message : String(err),
});
}
});
},
[enqueueTimelineWrite, saveDocument, te],
);
// transcript-pane → a word nobody said. It takes the silence it is dropped into and no
// audio at all, so unlike a cut it changes nothing about the film; today it reaches the
// captions and stops there.
const handleInsertWord = useCallback(
(assetId: string, anchorWordId: string, side: InsertSide, text: string) => {
if (!insertionsEnabled()) return;
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
try {
await saveDocument(insertDocumentWord(doc, assetId, anchorWordId, side, text), {
history: true,
});
} catch (err) {
toast.error(te("errors.wordInsertFailed"), {
description: err instanceof Error ? err.message : String(err),
});
}
});
},
[enqueueTimelineWrite, saveDocument, te],
);
// Deleting inserted words. One save for the whole set, so a Backspace over several of
// them is one Ctrl+Z, and the document layer refuses anything that was actually spoken.
const handleRemoveWords = useCallback(
(assetId: string, wordIds: string[]) => {
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc || wordIds.length === 0) return;
try {
await saveDocument(removeDocumentWords(doc, assetId, wordIds), { history: true });
} catch (err) {
toast.error(te("errors.wordRemoveFailed"), {
description: err instanceof Error ? err.message : String(err),
});
}
});
},
[enqueueTimelineWrite, saveDocument, te],
);
const handleSelectProject = useCallback(
async (id: string) => {
try {
await loadProject(id);
} catch (err) {
toast.error("Could not open project", {
description: err instanceof Error ? err.message : String(err),
});
}
},
[loadProject],
);
// The dialog's starting point picks the tab the fresh project opens on:
// "import" → Media (browse/drop assets), "screen-recording" → Rec (capture).
const handleCreateProject = useCallback(
async (title: string, startingPoint: StartingPoint) => {
try {
await createProject(title);
setMode(startingPoint === "screen-recording" ? "rec" : "media");
} catch (err) {
toast.error("Could not create project", {
description: err instanceof Error ? err.message : String(err),
});
}
},
[createProject],
);
// The only way to get rid of a project short of deleting its file by hand.
// Media is left alone on purpose: a recording can back several projects, and
// the dialog says so before it asks.
const handleDeleteProject = useCallback(async (id: string) => {
try {
const result = await nativeBridgeClient.aiEdition.delete(id);
if (!result.success) throw new Error(result.error ?? "Failed to delete project");
setProjectSummaries((prev) => prev.filter((p) => p.id !== id));
// Deleting the project that is open leaves the editor pointing at a file
// that no longer exists — any save from there would recreate it.
if (useProjectStore.getState().projectId === id) {
useProjectStore.getState().clear();
}
toast.success("Project deleted");
} catch (err) {
toast.error("Could not delete project", {
description: err instanceof Error ? err.message : String(err),
});
}
}, []);
const handleSave = useCallback(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
if (await saveDocument(doc, { history: true })) toast.success("Project saved");
}, [saveDocument]);
// Native File menu (electron/main.ts) → v4 actions. The menu is shown via
// Menu.setApplicationMenu and dispatches these IPC events; the old editor
// listened to them, but the v4 shell replaced it, leaving the File items
// dead. Wire them to the same handlers the top-bar buttons use so the
// File/Edit/View menu bar works again (the View items still use Electron roles).
// The v4 editor has no separate "Save As" location, so it maps to Save.
//
// Edit > Undo/Redo are here too, and not roles: on macOS the menu's Cmd+Z key
// equivalent is matched by AppKit before the key event reaches the renderer, so
// this subscription is the ONLY thing that makes Ctrl+Z work there. See
// `electron/edit-menu.ts`.
useEffect(() => {
const api = window.electronAPI;
if (!api) return;
const unsubscribers = [
api.onMenuNewProject?.(() => setNewProjectOpen(true)),
api.onMenuLoadProject?.(() => setOpenProjectOpen(true)),
api.onMenuSaveProject?.(() => void handleSave()),
api.onMenuSaveProjectAs?.(() => void handleSave()),
api.onMenuUndo?.(runUndo),
api.onMenuRedo?.(runRedo),
];
return () => {
for (const unsub of unsubscribers) unsub?.();
};
}, [handleSave, runUndo, runRedo]);
const handleRenameProject = useCallback(
async (title: string) => {
const doc = useProjectStore.getState().document;
if (!doc) return;
if (title === doc.project.title) return;
await saveDocument({ ...doc, project: { ...doc.project, title } }, { history: true });
},
[saveDocument],
);
const handleConfirmUnsaved = useCallback(
(choice: UnsavedChoice) => {
if (!unsavedPrompt) return;
const { action, resolve } = unsavedPrompt;
setUnsavedPrompt(null);
// ponytail: resolve the action when the user picks save / discard.
// The "continue with action" path is handled below in handleNewRecording /
// the open-project branch. We resolve the promise once the work is done
// (or cancelled).
void (async () => {
if (choice === "cancel") {
resolve("cancel");
return;
}
if (choice === "save") {
const doc = useProjectStore.getState().document;
// A failed save cancels the action that prompted this dialog. The store has
// already said why -- which is what the bare `catch {}` here used to swallow,
// leaving the window refusing to close with nothing on screen explaining it.
if (doc && !(await saveDocument(doc, { history: true }))) {
resolve("cancel");
return;
}
}
if (action === "record") {
void window.electronAPI?.startNewRecording?.().catch((err) => {
console.warn("[editor] failed to start a new recording:", err);
});
}
resolve(choice);
})();
},
[saveDocument, unsavedPrompt],
);
const handleNewRecording = useCallback(async () => {
const choice = await promptUnsaved("record");
if (choice !== "cancel") {
void window.electronAPI?.startNewRecording?.().catch((err) => {
console.warn("[editor] failed to start a new recording:", err);
});
}
}, [promptUnsaved]);
const handleExport = useCallback(() => {
if (!hasAsset) {
toast.info("Add a video to the project before exporting.");
return;
}
setExportOpen(true);
}, [hasAsset]);
const handleOpenSettings = useCallback(() => {
openShortcutsConfig();
}, [openShortcutsConfig]);
// Both hand off to the main process rather than rendering anything here: the About box and
// the update dialogs are native message boxes owned by a window (see showMessageBox in
// electron/main.ts), which is what keeps them from opening behind the always-on-top HUD.
// Errors are swallowed on purpose — the main process is the one that reports them, and a
// rejection here would only surface as an unhandled promise in the renderer.
const handleShowAbout = useCallback(() => {
void window.electronAPI?.showAbout?.().catch(() => {
// Swallowed: see above.
});
}, []);
const handleCheckForUpdates = useCallback(() => {
void window.electronAPI?.checkForUpdates?.().catch(() => {
// Swallowed: see above.
});
}, []);
// Voiceover recording (the one audio gesture that is not a file import). The
// dialog owns the mic; the shell owns the transport and the placement.
const [voiceoverFlow, setVoiceoverFlow] = useState<{ maxDurationSec: number } | null>(null);
// The playhead as it was when RECORDING STARTED. Recording plays the video so
// the user can narrate what they see, which means the live playhead has moved
// on by the take's own length by the time the take ends — reading it then
// placed every voiceover one full take-length to the right of where it was
// spoken. Captured on the way in, used on the way out.
const voiceoverStartSecRef = useRef(0);
const openVoiceoverFlow = useCallback(() => {
const doc = useProjectStore.getState().document;
if (!doc) return;
const total = doc.timeline.clips.reduce((max, c) => Math.max(max, c.timelineEndSec), 0);
const playhead = useProjectStore.getState().currentTimeSec;