From cb3232fa4c102e86deb960ae5f9d668c6828b104 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 01:36:59 +0200 Subject: [PATCH 01/12] feat(timeline): cut a clip in two at the playhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split control in the timeline toolbar. Press it with the playhead parked inside a clip and that clip becomes two, meeting where the playhead was. THE PART THAT IS NOT A BUTTON. A split produces two clips of the same asset whose timecodes meet exactly and whose crop matches — which is, to the letter, what `joinable()` calls one clip. Every structural edit ends in `withClipsChanged`, and `joinContiguous` folds such a pair back together. So a split at the playhead was a guaranteed no-op in this model: annihilated by the same call that would persist it. Proved before building, not reasoned about — two halves of a 0-10s clip cut at 4s come back as one clip, 0-10s. That fold is deliberate and the module says so: hand-joined clips should not litter the timeline, and it rejected "a marker on every cut, which would have to survive every move that makes it wrong". This adds a narrower one: `splitFromPrevious`, set only where a person asked for a cut, which `joinable` refuses to fold across. It is optional, so every document written before it parses unchanged, and it can only ever keep clips apart, never join them — the safe direction for a flag that travels with a clip through moves it knows nothing about. THE MUTATOR. `splitClipAt` sits beside `moveClip`, `duplicateClip` and `setClipSourceRange`, for the reason they give: one home for the recipe rather than one per façade. The head keeps the original's id, so what was anchored to it stays anchored. Both extents are zeroed for `resequenceClips`, as `setClipSourceRange` does. Trims are divided, not copied. Copying them and trusting the clamp was the first attempt — `setClipSourceRange` documents `rederiveRegionMs` as "dropping what the trim removed" — and the test caught it: that refreshes a trim's DERIVED ms, while its authoritative window is `startSec`/`endSec` in source time, which nothing downstream reconsiders. Every trim stayed on both halves, so a cut in the tail went on cutting the head. Each is now intersected with each half's window: it lands on the side containing it, on both sides cut down when it straddles the split, on neither when empty there. Word refs are recomputed per half through `collectWordRefs`, which is how they were derived in the first place. It is a no-op — never a throw — on an unknown clip, outside the clip, or when either half would come out under MIN_SPLIT_HALF_SEC. A split control is pressed with the playhead wherever it happens to be, including on a boundary, where the honest answer is that there is nothing to cut. THE CONTROL. The playhead runs on timeline time and a clip is cut in media time, so the carrier is found by containment and the instant mapped through it — not via `resolveTimelineSpanToTrim`, which is the same projection but falls back to the nearest clip, right for a trim dragged onto the ruler and wrong here, where it would cut a clip the playhead is not on. It is not disabled when there is nothing to cut. Knowing that needs the live playhead, and this component deliberately does not subscribe to it — the playhead reads the store itself so playback does not re-render the timeline sixty times a second. So the button always fires and says when it did not cut, rather than looking broken. Slice, not Scissors: this file already spends Scissors on a trim pill and SplitSquareHorizontal on the trim tool, and a cut that divides a clip is not the cut that marks a span for removal. The write is declared in documentWriteAudit's table, which is what caught it going undeclared. Seven tests, checked against broken versions: dropping the marker, and making `joinable` ignore it, each fail six of them. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ai-edition/v4/V4Timeline.tsx | 25 ++++ src/i18n/locales/ar/timeline.json | 2 + src/i18n/locales/en/timeline.json | 2 + src/i18n/locales/es/timeline.json | 2 + src/i18n/locales/fr/timeline.json | 2 + src/i18n/locales/it/timeline.json | 2 + src/i18n/locales/ja-JP/timeline.json | 2 + src/i18n/locales/ko-KR/timeline.json | 2 + src/i18n/locales/pt-BR/timeline.json | 2 + src/i18n/locales/ru/timeline.json | 2 + src/i18n/locales/tr/timeline.json | 2 + src/i18n/locales/vi/timeline.json | 2 + src/i18n/locales/zh-CN/timeline.json | 2 + src/i18n/locales/zh-TW/timeline.json | 2 + src/lib/ai-edition/document/timeline.test.ts | 121 ++++++++++++++++++ src/lib/ai-edition/document/timeline.ts | 118 ++++++++++++++++- src/lib/ai-edition/schema/index.ts | 14 ++ .../store/documentWriteAudit.test.ts | 2 + src/lib/ai-edition/store/useTimeline.ts | 29 +++++ 19 files changed, 332 insertions(+), 3 deletions(-) diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 28aad552c..fea796983 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -9,6 +9,7 @@ import { Music, Pencil, Scissors, + Slice, Sparkles, SplitSquareHorizontal, Trash2, @@ -1925,6 +1926,30 @@ export function V4Timeline({ ) : null} ))} + {/* Slice, not Scissors: this file already spends Scissors on a trim + pill and SplitSquareHorizontal on the trim tool, and a cut that + divides a clip in two is not the cut that marks a span for + removal. One glyph, one operation. */} + + + + {tools.map((tool) => ( @@ -1926,30 +1948,6 @@ export function V4Timeline({ ) : null} ))} - {/* Slice, not Scissors: this file already spends Scissors on a trim - pill and SplitSquareHorizontal on the trim tool, and a cut that - divides a clip in two is not the cut that marks a span for - removal. One glyph, one operation. */} - - - {tools.map((tool) => ( diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json index 72ce2e54e..c5abf91d9 100644 --- a/src/i18n/locales/ar/timeline.json +++ b/src/i18n/locales/ar/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "اسحب لإعادة الترتيب · انقر نقرًا مزدوجًا لتعديل نقطتي البداية والنهاية", "editInOutPoints": "تعديل نقطتي البداية والنهاية", "splitClip": "تقسيم المقطع عند مؤشر التشغيل", - "splitClipNothingToCut": "ضع مؤشر التشغيل داخل مقطع، بعيدًا عن طرفيه.", + "splitClipNothingToSplit": "ضع مؤشر التشغيل داخل مقطع، بعيدًا عن طرفيه.", "deleteClip": "حذف المقطع", "dropToAdd": "أفلت للإضافة إلى المخطط الزمني", "importRecordingFirst": "استورد تسجيلاً أولاً", diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index 40e8dafe6..39aeeda6d 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "Drag to reorder · double-click to edit in/out points", "editInOutPoints": "Edit in/out points", "splitClip": "Split clip at playhead", - "splitClipNothingToCut": "Park the playhead inside a clip, away from its ends.", + "splitClipNothingToSplit": "Park the playhead inside a clip, away from its ends.", "deleteClip": "Delete clip", "dropToAdd": "Drop to add to timeline", "importRecordingFirst": "Import a recording first", diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 12926b59b..a397e6882 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "Arrastra para reordenar · doble clic para editar los puntos de entrada/salida", "editInOutPoints": "Editar puntos de entrada/salida", "splitClip": "Dividir el clip en el cabezal", - "splitClipNothingToCut": "Coloca el cabezal dentro de un clip, lejos de sus extremos.", + "splitClipNothingToSplit": "Coloca el cabezal dentro de un clip, lejos de sus extremos.", "deleteClip": "Eliminar clip", "dropToAdd": "Suelta para añadir a la línea de tiempo", "importRecordingFirst": "Importa una grabación primero", diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index 79d85419c..95024c3ce 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -73,8 +73,8 @@ "newAnnotation": "Annotation", "dragToReorderHint": "Glissez pour réorganiser · double-cliquez pour modifier les points d'entrée/sortie", "editInOutPoints": "Modifier les points d'entrée/sortie", - "splitClip": "Couper le clip à la tête de lecture", - "splitClipNothingToCut": "Placez la tête de lecture dans un clip, à distance de ses extrémités.", + "splitClip": "Diviser le clip à la tête de lecture", + "splitClipNothingToSplit": "Placez la tête de lecture dans un clip, à distance de ses extrémités.", "deleteClip": "Supprimer le clip", "dropToAdd": "Déposez pour ajouter à la timeline", "importRecordingFirst": "Importez d'abord un enregistrement", diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index fd7a6ead7..d57484686 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "Trascina per riordinare · doppio clic per modificare i punti di entrata/uscita", "editInOutPoints": "Modifica punti di entrata/uscita", "splitClip": "Dividi la clip alla testina", - "splitClipNothingToCut": "Posiziona la testina all'interno di una clip, lontano dalle estremità.", + "splitClipNothingToSplit": "Posiziona la testina all'interno di una clip, lontano dalle estremità.", "deleteClip": "Elimina clip", "dropToAdd": "Rilascia per aggiungere alla timeline", "importRecordingFirst": "Importa prima una registrazione", diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json index 4768e65ec..3c3a6169b 100644 --- a/src/i18n/locales/ja-JP/timeline.json +++ b/src/i18n/locales/ja-JP/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "ドラッグして並べ替え・ダブルクリックでイン/アウトポイントを編集", "editInOutPoints": "イン/アウトポイントを編集", "splitClip": "再生ヘッドの位置でクリップを分割", - "splitClipNothingToCut": "再生ヘッドを、クリップの端から離れた内側に置いてください。", + "splitClipNothingToSplit": "再生ヘッドを、クリップの端から離れた内側に置いてください。", "deleteClip": "クリップを削除", "dropToAdd": "ドロップしてタイムラインに追加", "importRecordingFirst": "先に録画をインポートしてください", diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json index 3c20a7066..2e595b951 100644 --- a/src/i18n/locales/ko-KR/timeline.json +++ b/src/i18n/locales/ko-KR/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "드래그하여 순서 변경 · 더블클릭하여 시작/종료 지점 편집", "editInOutPoints": "시작/종료 지점 편집", "splitClip": "재생 헤드에서 클립 나누기", - "splitClipNothingToCut": "재생 헤드를 클립 안쪽, 양 끝에서 떨어진 위치에 두세요.", + "splitClipNothingToSplit": "재생 헤드를 클립 안쪽, 양 끝에서 떨어진 위치에 두세요.", "deleteClip": "클립 삭제", "dropToAdd": "타임라인에 추가하려면 놓으세요", "importRecordingFirst": "먼저 녹화 파일을 가져오세요", diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index bab804caa..228b33e56 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "Arraste para reordenar · clique duas vezes para editar os pontos de entrada/saída", "editInOutPoints": "Editar pontos de entrada/saída", "splitClip": "Dividir o clipe no cursor de reprodução", - "splitClipNothingToCut": "Posicione o cursor dentro de um clipe, longe das extremidades.", + "splitClipNothingToSplit": "Posicione o cursor dentro de um clipe, longe das extremidades.", "deleteClip": "Excluir clipe", "dropToAdd": "Solte para adicionar à linha do tempo", "importRecordingFirst": "Importe uma gravação primeiro", diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index 5a37d1afb..d6d347ae5 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -73,8 +73,8 @@ "newAnnotation": "Аннотация", "dragToReorderHint": "Перетащите для изменения порядка · дважды щёлкните для редактирования точек входа/выхода", "editInOutPoints": "Редактировать точки входа/выхода", - "splitClip": "Разрезать клип по курсору", - "splitClipNothingToCut": "Поместите курсор внутри клипа, подальше от его краёв.", + "splitClip": "Разделить клип по курсору", + "splitClipNothingToSplit": "Поместите курсор внутри клипа, подальше от его краёв.", "deleteClip": "Удалить клип", "dropToAdd": "Отпустите, чтобы добавить на таймлайн", "importRecordingFirst": "Сначала импортируйте запись", diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index 03d564045..dc657da97 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "Yeniden sıralamak için sürükleyin · giriş/çıkış noktalarını düzenlemek için çift tıklayın", "editInOutPoints": "Giriş/çıkış noktalarını düzenle", "splitClip": "Klibi oynatma başlığında böl", - "splitClipNothingToCut": "Oynatma başlığını bir klibin içine, uçlarından uzağa yerleştirin.", + "splitClipNothingToSplit": "Oynatma başlığını bir klibin içine, uçlarından uzağa yerleştirin.", "deleteClip": "Klibi sil", "dropToAdd": "Zaman çizelgesine eklemek için bırakın", "importRecordingFirst": "Önce bir kayıt içe aktarın", diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json index c156581cd..43dc6c50c 100644 --- a/src/i18n/locales/vi/timeline.json +++ b/src/i18n/locales/vi/timeline.json @@ -73,8 +73,8 @@ "newAnnotation": "Chú thích", "dragToReorderHint": "Kéo để sắp xếp lại · nhấp đúp để chỉnh sửa điểm vào/ra", "editInOutPoints": "Chỉnh sửa điểm vào/ra", - "splitClip": "Cắt đôi clip tại đầu phát", - "splitClipNothingToCut": "Đặt đầu phát vào bên trong một clip, cách xa hai đầu.", + "splitClip": "Tách đôi clip tại đầu phát", + "splitClipNothingToSplit": "Đặt đầu phát vào bên trong một clip, cách xa hai đầu.", "deleteClip": "Xóa clip", "dropToAdd": "Thả để thêm vào dòng thời gian", "importRecordingFirst": "Hãy nhập một bản ghi trước", diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index acab55ac4..58e746354 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "拖动以重新排序 · 双击以编辑入点/出点", "editInOutPoints": "编辑入点/出点", "splitClip": "在播放头处分割片段", - "splitClipNothingToCut": "请将播放头置于片段内部,远离两端。", + "splitClipNothingToSplit": "请将播放头置于片段内部,远离两端。", "deleteClip": "删除片段", "dropToAdd": "拖放以添加到时间轴", "importRecordingFirst": "请先导入录制内容", diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index 552812bce..69419b9e9 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -74,7 +74,7 @@ "dragToReorderHint": "拖曳以重新排序 · 按兩下以編輯入點/出點", "editInOutPoints": "編輯入點/出點", "splitClip": "在播放頭處分割片段", - "splitClipNothingToCut": "請將播放頭置於片段內部,遠離兩端。", + "splitClipNothingToSplit": "請將播放頭置於片段內部,遠離兩端。", "deleteClip": "刪除片段", "dropToAdd": "拖放以新增至時間軸", "importRecordingFirst": "請先匯入錄製內容", diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts index f23463258..7c4497f19 100644 --- a/src/lib/ai-edition/document/timeline.ts +++ b/src/lib/ai-edition/document/timeline.ts @@ -1090,7 +1090,7 @@ const MIN_SPLIT_HALF_SEC = 0.05; * A no-op — never a throw — when the clip is unknown, when `sourceSec` falls outside the * clip, or when either half would come out shorter than MIN_SPLIT_HALF_SEC. A split control * is a thing people press with the playhead parked anywhere, including on a clip boundary, - * where the honest answer is that there is nothing to cut. + * where the honest answer is that there is nowhere to split. */ export function splitClipAt( document: AxcutDocument, diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 501b2e144..633a462f9 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -919,19 +919,19 @@ describe("useTimeline save failures", () => { expect(useProjectStore.getState().document?.zoomRanges).toHaveLength(0); }); - // Same shape of bug as the one above: the toolbar shows "nothing to cut" on a + // Same shape of bug as the one above: the toolbar says nothing was split on a // falsy answer, so reporting success on a write that never landed left a control // claiming a cut the document does not have. it("reports no cut when the split's write fails", async () => { useProjectStore.setState({ currentTimeSec: 4 }); const { result } = renderTimeline(); - let didCut: boolean | undefined; + let didSplit: boolean | undefined; await act(async () => { - didCut = await result.current.splitClipAtPlayhead(); + didSplit = await result.current.splitClipAtPlayhead(); }); - expect(didCut).toBe(false); + expect(didSplit).toBe(false); expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(1); }); }); @@ -995,12 +995,12 @@ describe("useTimeline.splitClipAtPlayhead", () => { it("cuts the clip under the playhead, at the frame of its own media the playhead is on", async () => { const { result } = renderTimeline(); - let didCut: boolean | undefined; + let didSplit: boolean | undefined; await act(async () => { - didCut = await result.current.splitClipAtPlayhead(); + didSplit = await result.current.splitClipAtPlayhead(); }); - expect(didCut).toBe(true); + expect(didSplit).toBe(true); const clips = useProjectStore.getState().document?.timeline.clips ?? []; expect(clips).toHaveLength(3); // The clip the playhead is NOT on is untouched, id and all: containment picks the @@ -1023,12 +1023,12 @@ describe("useTimeline.splitClipAtPlayhead", () => { useProjectStore.setState({ currentTimeSec: 25 }); const { result } = renderTimeline(); - let didCut: boolean | undefined; + let didSplit: boolean | undefined; await act(async () => { - didCut = await result.current.splitClipAtPlayhead(); + didSplit = await result.current.splitClipAtPlayhead(); }); - expect(didCut).toBe(false); + expect(didSplit).toBe(false); expect(bridgeMocks.save).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 2c26dcac8..1232ea950 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -1048,7 +1048,7 @@ export function useTimeline() { * nearest clip when nothing contains the point — right for a trim dragged onto the * ruler, wrong here, where it would cut a clip the playhead is not even on. * - * Returns whether it cut, so a caller can say nothing happened rather than leave a + * Returns whether it split, so a caller can say nothing happened rather than leave a * control that looks broken when the playhead sits on a boundary. */ const splitClipAtPlayhead = useCallback(async (): Promise => { const doc = useProjectStore.getState().document; From eea8b80dc44c9db19989b710c0f45a8a6e6889b0 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 11:32:18 +0200 Subject: [PATCH 06/12] refactor(timeline): let one place decide what a split fragment is too small for From CodeRabbit on #669, though not for the reason given. The division used REGION_WINDOW_EPSILON_SEC on each half, which is this code deciding "too small to keep" -- a rule that already exists, once, in `rederiveAnchoredRegion`. The finding said a region whose halves both fall under the threshold is lost. Measured on a 0.001s annotation straddling the cut: it is dropped with the epsilon and dropped with `> 0` alike, because the fragments the epsilon refused to make are exactly the ones the clamp drops a moment later. So nothing is preserved by this and nothing was being lost. What is real is the duplicated policy. `> 0` leaves this function dividing, and the one place that owns "too short to survive" goes on owning it. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/ai-edition/document/timeline.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts index 7c4497f19..d40f8ee78 100644 --- a/src/lib/ai-edition/document/timeline.ts +++ b/src/lib/ai-edition/document/timeline.ts @@ -1181,12 +1181,20 @@ export function splitClipAt( const parts: StoredRegion[] = []; // Only the cut itself is applied here; the outer edges are the existing // clamp's business, and it runs on both halves straight after. + // + // `> 0`, not the epsilon: with the epsilon this code was deciding "too small to + // keep", which is a rule that already exists, once, in `rederiveAnchoredRegion`. + // It changes nothing that anyone can see — measured on a 0.001s region straddling + // the cut, the fragments the epsilon refused to make are exactly the ones the + // clamp drops a moment later, so the region disappears either way. It is the + // duplication that is worth removing, not a loss: this divides, and the one + // place that owns "too short to survive" goes on owning it. const headEnd = Math.min(region.sourceEndSec, sourceSec); - if (headEnd - region.sourceStartSec > REGION_WINDOW_EPSILON_SEC) { + if (headEnd - region.sourceStartSec > 0) { parts.push({ ...region, ...grouped, sourceEndSec: headEnd }); } const tailStart = Math.max(region.sourceStartSec, sourceSec); - if (region.sourceEndSec - tailStart > REGION_WINDOW_EPSILON_SEC) { + if (region.sourceEndSec - tailStart > 0) { // A new id only on the tail side, exactly as above: the head kept the // original clip's id, so its share can keep the row's. parts.push({ From 955e45fc7183056f655ca118bf1d16aa4a3ccf56 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Wed, 16 Sep 2026 19:34:43 +0200 Subject: [PATCH 07/12] fix(i18n): give the Czech locale the split control's two strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Czech reached main after this branch was cut, so the merge brought in a locale that had never heard of `timeline.toolbar.splitClip` or `timeline.toolbar.splitClipNothingToSplit`, and the locale parity test went red. Both use "rozdělit" (divide). The trim in this locale is "Oříznutí" and the AI cuts are "střihy", so the split takes neither of their verbs, the same rule the other locales were brought under. "Přehrávací hlavice" follows the term the locale already uses for the playhead in its settings. Co-Authored-By: Claude Opus 5 (1M context) --- src/i18n/locales/cs/timeline.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/locales/cs/timeline.json b/src/i18n/locales/cs/timeline.json index 0c3457467..e849d5da3 100644 --- a/src/i18n/locales/cs/timeline.json +++ b/src/i18n/locales/cs/timeline.json @@ -73,6 +73,8 @@ "newAnnotation": "Anotace", "dragToReorderHint": "Přetáhněte pro změnu pořadí · dvojklik pro úpravu vstupních/výstupních bodů", "editInOutPoints": "Upravit vstupní/výstupní body", + "splitClip": "Rozdělit klip na pozici přehrávací hlavice", + "splitClipNothingToSplit": "Umístěte přehrávací hlavici dovnitř klipu, mimo jeho okraje.", "deleteClip": "Smazat klip", "dropToAdd": "Přetáhněte pro přidání na časovou osu", "importRecordingFirst": "Nejprve importujte nahrávku", From 081379c444ae9deaaec5b5371b5afce4bb5faef8 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Wed, 16 Sep 2026 19:35:57 +0200 Subject: [PATCH 08/12] fix(timeline): stop an inserted word handing a split's flag to the piece after it `insertGeneratedClip` cuts the clip the word lands in by spreading it, so when that clip was a split's tail both pieces came out carrying `splitFromPrevious`. The piece to the right of the word is the insertion's own cut, not one a person asked for, and the flag told `joinable` otherwise: removing the word left the tail in two pieces the join refused to heal. Split at 5, insert at 6, delete the word, and the timeline read [0-5] [5-6] [6-10] instead of [0-5] [5-10]. The right piece now drops the flag. The left piece keeps whatever the clip had, because it still starts where the clip did and that cut is still the user's. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/ai-edition/document/insertion.test.ts | 25 ++++++++++++++++++- src/lib/ai-edition/document/insertion.ts | 7 +++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/document/insertion.test.ts b/src/lib/ai-edition/document/insertion.test.ts index 23efb0dae..6efc686ef 100644 --- a/src/lib/ai-edition/document/insertion.test.ts +++ b/src/lib/ai-edition/document/insertion.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; import type { AxcutDocument } from "../schema"; import { insertGeneratedClip, removeGeneratedClips, retextGeneratedClip } from "./insertion"; -import { moveClip, removeClip, resolvePlaybackSegments } from "./timeline"; +import { moveClip, removeClip, resolvePlaybackSegments, splitClipAt } from "./timeline"; import { setDocumentWordText } from "./transcript"; const doc = (over: Partial = {}): AxcutDocument => @@ -173,6 +173,29 @@ describe("removeGeneratedClips", () => { expect(moved.timeline.clips[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 10 }); }); + it("rejoins the halves of a split's tail, and leaves the split itself standing", () => { + // The insertion cuts whatever clip the word sits in, and a split's tail carries + // `splitFromPrevious`. The piece to the right of the insertion is not a cut anyone asked + // for, so it must not inherit the flag: with it, removing the word left the tail in two + // pieces the join refused to heal. The tail itself keeps its flag, since that cut was + // asked for. + const split = splitClipAt(doc(), "c1", 5); + const inserted = insertGeneratedClip(split, "a1", "w2", "after", "hi"); + expect(inserted.timeline.clips.map((c) => [c.sourceStartSec, c.splitFromPrevious])).toEqual([ + [0, undefined], + [5, true], + [0, undefined], + [6, undefined], + ]); + const back = removeClip(inserted, "ext:synth_1"); + expect( + back.timeline.clips.map((c) => [c.sourceStartSec, c.sourceEndSec, c.splitFromPrevious]), + ).toEqual([ + [0, 5, undefined], + [5, 10, true], + ]); + }); + it("is a no-op for a word that has no clip", () => { const base = doc(); expect(removeGeneratedClips(base, ["synth_9"])).toBe(base); diff --git a/src/lib/ai-edition/document/insertion.ts b/src/lib/ai-edition/document/insertion.ts index 14de27197..b6de8f3e2 100644 --- a/src/lib/ai-edition/document/insertion.ts +++ b/src/lib/ai-edition/document/insertion.ts @@ -192,8 +192,13 @@ export function insertGeneratedClip( const cutsHere = atRuler > clip.timelineStartSec + EPS && atRuler < clip.timelineEndSec - EPS; if (!placed && cutsHere) { const cut = clip.sourceStartSec + (atRuler - clip.timelineStartSec); + // The right piece is this cut's own, not one a person asked for, so it must not + // inherit `splitFromPrevious` from a clip that was itself a split's tail: the flag + // would stop the join from healing the two pieces once the word is taken out again. + // The left piece keeps whatever the clip had, since it still starts where that did. + const { splitFromPrevious: _inherited, ...unflagged } = clip; const right = { - ...clip, + ...unflagged, id: createId("clip"), sourceStartSec: cut, timelineStartSec: atRuler, From 883112e4ab6b03f7210612e74ddf3055dc474567 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Wed, 16 Sep 2026 19:37:14 +0200 Subject: [PATCH 09/12] fix(timeline): let a rebuild that keeps a split's halves keep the split `replaceTimeline` preserves a clip whose source window a kept interval reproduces, but it rebuilds that clip field by field (id, origin, reason, word refs) and `splitFromPrevious` was not among them. The agent's `replace_timeline` and `drop_range` both land there, so a split came through them looking intact and fell apart on the next structural edit: move another clip and the fold in `withClipsChanged` joined the halves back into the clip they were cut from. The kept clip now carries the flag from the one it preserves. It is the same stretch of media, so the cut at its start is still the one the user made. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/ai-edition/document/timeline.test.ts | 42 ++++++++++++++++++++ src/lib/ai-edition/document/timeline.ts | 5 ++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts index 539a1a6a8..820d19f27 100644 --- a/src/lib/ai-edition/document/timeline.test.ts +++ b/src/lib/ai-edition/document/timeline.test.ts @@ -2075,4 +2075,46 @@ describe("splitClipAt", () => { expect(out.timeline.clips[0].wordRefs).toEqual(["w_head"]); expect(out.timeline.clips[1].wordRefs).toEqual(["w_tail"]); }); + + // `replaceTimeline` keeps a clip whose source window a kept interval reproduces, and it + // used to keep it field by field: id, origin, reason, word refs, and not the flag. The + // agent's `replace_timeline` and `drop_range` both land there, so a split went through + // them looking intact and came apart on the next structural edit, which folds the halves + // back into one clip. + it("survives an agent rebuild that keeps both halves, and the edit after it", () => { + const whole = makeDoc({ + timeline: { + clips: [ + makeClip({ id: "clip_1", sourceStartSec: 0, sourceEndSec: 60, timelineEndSec: 60 }), + ], + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + }); + const once = splitClipAt(whole, "clip_1", 20); + const split = splitClipAt(once, once.timeline.clips[1].id, 40); + const rebuilt = replaceTimeline( + split, + [ + { startSec: 0, endSec: 20 }, + { startSec: 20, endSec: 40 }, + { startSec: 40, endSec: 60 }, + ], + "agent rebuild", + "agent", + ); + expect(rebuilt.timeline.clips.map((c) => c.splitFromPrevious)).toEqual([undefined, true, true]); + // The edit that used to undo it: moving the last piece to the front makes the other + // two neighbours again, which is exactly what the fold looks for. + const lastId = rebuilt.timeline.clips[2].id; + const moved = moveClip(rebuilt, lastId, 0); + expect(moved.timeline.clips.map((c) => [c.sourceStartSec, c.sourceEndSec])).toEqual([ + [40, 60], + [0, 20], + [20, 40], + ]); + }); }); diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts index d40f8ee78..6af89e13a 100644 --- a/src/lib/ai-edition/document/timeline.ts +++ b/src/lib/ai-edition/document/timeline.ts @@ -830,7 +830,7 @@ export function planTimelineReplacement( } export interface ReplaceTimelineOptions { - /** Reuse the id / origin / reason / wordRefs of a clip whose source window a + /** Reuse the id / origin / reason / wordRefs / split flag of a clip whose source window a * kept interval reproduces exactly. Default true — a rebuild that happens to * keep a stretch of media keeps the clip that WAS that stretch of media. */ preserveIds?: boolean; @@ -880,6 +880,9 @@ export function replaceTimeline( collectWordRefs(document.transcript, slot.interval.startSec, slot.interval.endSec), origin: existing?.origin ?? origin, reason: existing?.reason ?? reason, + // A kept clip is the same stretch of media, so a cut the user put at its start is + // still there. Dropped, the next structural edit folds it into its neighbour. + ...(existing?.splitFromPrevious ? { splitFromPrevious: true } : {}), }; }); From db19c5c26ee731c52833828233653bc4b1ff5b03 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Wed, 16 Sep 2026 19:37:30 +0200 Subject: [PATCH 10/12] fix(timeline): seat the split's doc comment and applyClipEdit's each above its own function `splitClipAtPlayhead` had been dropped between `applyClipEdit`'s long comment and `applyClipEdit` itself, so that comment (the dialog's one-save Apply, crop per clip, reading the store for the sequential queue) read as if it described the split, and the function it did describe opened with no explanation at all. The split block moves up above that comment. Nothing else changes. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/ai-edition/store/useTimeline.ts | 50 ++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index f767c94e9..dbb25bcb9 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -1026,31 +1026,6 @@ export function useTimeline() { setSelectedAudioTrackId(null); }, [setSelectedAudioTrackId]); - // The Edit Clip dialog's Apply, as ONE document and ONE save. - // - // Source range and crop are two edits made in a single user action, and they used to - // be two independent saves fired back to back. Both built their next document from - // the SAME pre-Apply one — the crop write never saw the source-range change — so - // whichever IPC write landed last silently dropped the other edit, with no error and - // no toast (#355). Composing them means the crop is applied to the *resequenced* - // clips, which is also the only order that can be right. - // - // Axcut-consistent clip trim: only the source range is user-editable (the dialog's - // draggable track). Changing it changes the clip's effective duration, so every clip - // is resequenced back-to-back afterward — same invariant as - // insertClipAt/moveClip/removeClip — instead of leaving downstream clips at their old - // timeline positions (which would overlap). That whole recipe (resequence width + - // clamp/rederive pills) lives in the one pure `setClipSourceRange`, shared with the op - // dispatcher and the LLM tool. - // - // Crop is a per-clip framing, not a document-wide setting — two clips (even from the - // same asset) can reasonably want different crops. `undefined` means the dialog's crop - // section was never touched (leave the stored value alone); `null` clears it back to - // "no crop" (full frame) rather than storing the identity region explicitly. - // - // The document is read from the store, not off the render closure, so this composes - // with `useSequentialTimelineOps`: queued behind another timeline write, it still sees - // what that write committed. Same reason as `setTrimEntries` / `insertClipAt`. /** Cut the clip under the playhead in two, there. * * The playhead runs on TIMELINE time and a clip is cut in its own MEDIA time, so the @@ -1080,6 +1055,31 @@ export function useTimeline() { return await saveDocument(next, { history: true }); }, [saveDocument]); + // The Edit Clip dialog's Apply, as ONE document and ONE save. + // + // Source range and crop are two edits made in a single user action, and they used to + // be two independent saves fired back to back. Both built their next document from + // the SAME pre-Apply one — the crop write never saw the source-range change — so + // whichever IPC write landed last silently dropped the other edit, with no error and + // no toast (#355). Composing them means the crop is applied to the *resequenced* + // clips, which is also the only order that can be right. + // + // Axcut-consistent clip trim: only the source range is user-editable (the dialog's + // draggable track). Changing it changes the clip's effective duration, so every clip + // is resequenced back-to-back afterward — same invariant as + // insertClipAt/moveClip/removeClip — instead of leaving downstream clips at their old + // timeline positions (which would overlap). That whole recipe (resequence width + + // clamp/rederive pills) lives in the one pure `setClipSourceRange`, shared with the op + // dispatcher and the LLM tool. + // + // Crop is a per-clip framing, not a document-wide setting — two clips (even from the + // same asset) can reasonably want different crops. `undefined` means the dialog's crop + // section was never touched (leave the stored value alone); `null` clears it back to + // "no crop" (full frame) rather than storing the identity region explicitly. + // + // The document is read from the store, not off the render closure, so this composes + // with `useSequentialTimelineOps`: queued behind another timeline write, it still sees + // what that write committed. Same reason as `setTrimEntries` / `insertClipAt`. const applyClipEdit = useCallback( async ( clipId: string, From fdd14233f6ae883653b8c0db22e34078a86de2b3 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Wed, 16 Sep 2026 19:39:30 +0200 Subject: [PATCH 11/12] fix(timeline): put the split on the shell's write queue with every other document edit The split control called `splitClipAtPlayhead` straight from the timeline, and that saves the whole document. Every other read-modify-write in the shell (word deletions, transcript trims, adding a clip, the Edit Clip dialog's Apply, the probed-duration fold) waits its turn on `useSequentialTimelineOps`, because a write that reads the store while another save is still in flight builds on the pre-save document and takes that edit with it when it lands. A split pressed while a transcript cut was saving could do exactly that. The shell now hands the timeline `onSplitClipAtPlayhead`, which enqueues the split, the same way it already hands it `onDropAsset`. No restructuring was needed: the split already reads the document and the playhead from the store when it runs rather than when it is called, which is the one thing a queued task has to do. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ai-edition/NewEditorShell.tsx | 10 ++++++++++ .../ai-edition/v4/V4Timeline.geometry.test.tsx | 2 ++ src/components/ai-edition/v4/V4Timeline.tsx | 8 +++++++- .../ai-edition/v4/V4Timeline.waveform.test.tsx | 1 + src/lib/ai-edition/store/useTimeline.ts | 6 +++++- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index fabfc3acb..0ea65b129 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -516,6 +516,15 @@ export function NewEditorShell() { [setSourceDuration, enqueueTimelineWrite], ); + // On the shared write queue, like every other read-modify-write of the document here. + // `splitClipAtPlayhead` reads the document and the playhead from the store when it runs, + // not when it is called, so queued behind a save in flight it cuts what that save + // committed instead of writing a pre-save snapshot over it. + const handleSplitClipAtPlayhead = useCallback( + () => enqueueTimelineWrite(() => tl.splitClipAtPlayhead()), + [tl, enqueueTimelineWrite], + ); + const handleSeek = useCallback( (timeSec: number) => { setCurrentTime(timeSec); @@ -1708,6 +1717,7 @@ export function NewEditorShell() { onNextClip={handleNextClip} onAddVoiceover={openVoiceoverFlow} onEditClip={setEditClipTarget} + onSplitClipAtPlayhead={handleSplitClipAtPlayhead} /> ) : null} diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 01269a5c5..2a4f5d41a 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -134,6 +134,7 @@ function renderTimeline( onNextClip={vi.fn()} onEditClip={vi.fn()} onAddVoiceover={vi.fn()} + onSplitClipAtPlayhead={vi.fn(async () => false)} /> ); @@ -611,6 +612,7 @@ describe("V4Timeline audio lane drag", () => { onNextClip={vi.fn()} onEditClip={vi.fn()} onAddVoiceover={props.onAddVoiceover ?? vi.fn()} + onSplitClipAtPlayhead={vi.fn(async () => false)} /> , ); diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 61768fe4a..db60c7b6f 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -572,6 +572,7 @@ export function V4Timeline({ onNextClip, onEditClip, onAddVoiceover, + onSplitClipAtPlayhead, }: { tl: TimelineApi; setCurrentTime: (sec: number) => void; @@ -588,6 +589,11 @@ export function V4Timeline({ /** Opens the voiceover recorder. Shell-level like the clip editor: the * dialog owns the microphone and the shell owns the transport. */ onAddVoiceover: () => void; + /** Cuts the clip under the playhead in two and resolves whether it did. The shell's, + * not `tl.splitClipAtPlayhead` called from here: a split is a read-modify-write of the + * whole document, so it has to wait its turn on the shell's write queue behind a save + * already in flight, or one of the two edits is lost. */ + onSplitClipAtPlayhead: () => Promise; }) { const t = useScopedT("timeline"); // The live bindings, not the defaults: these keys are remappable, and a menu @@ -1856,7 +1862,7 @@ export function V4Timeline({ // store itself so playback does not re-render the timeline 60 // times a second). So it always fires, and says when it did not // split rather than looking broken. - void tl.splitClipAtPlayhead().then((didSplit) => { + void onSplitClipAtPlayhead().then((didSplit) => { if (!didSplit) toast.info(t("toolbar.splitClipNothingToSplit")); }); }} diff --git a/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx b/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx index b62a303e7..e76fd1cf2 100644 --- a/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx @@ -139,6 +139,7 @@ function renderBars(atGainDb: number): string[] { onNextClip={vi.fn()} onEditClip={vi.fn()} onAddVoiceover={vi.fn()} + onSplitClipAtPlayhead={vi.fn(async () => false)} /> , ); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index dbb25bcb9..87412b976 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -1035,7 +1035,11 @@ export function useTimeline() { * ruler, wrong here, where it would cut a clip the playhead is not even on. * * Returns whether it split, so a caller can say nothing happened rather than leave a - * control that looks broken when the playhead sits on a boundary. */ + * control that looks broken when the playhead sits on a boundary. + * + * The document and the playhead are read from the store when this runs, not off the + * render closure, so the shell can queue it on `useSequentialTimelineOps` behind a save + * in flight and have it cut what that save committed. */ const splitClipAtPlayhead = useCallback(async (): Promise => { const doc = useProjectStore.getState().document; if (!doc) return false; From 25e0e362d25b91de36901e613683156d1ed29883 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Wed, 16 Sep 2026 19:40:48 +0200 Subject: [PATCH 12/12] test(timeline): pin the split through a move round trip, and a trim across the playhead Two gaps the review pointed at, neither a bug today. The flag was pinned through one later structural edit, never through the edit most likely to show it gone: move a half away and bring it back. Either way round, the halves end up side by side again with timecodes that meet, and only the flag stops the fold from joining them. And the playhead projection and the trim division were each pinned alone. The new case puts a trim at 32s-37s of the file on the clip that sits at 10s-20s on the timeline and plays 30s-40s, with the playhead at 14s. Only the mapped cut (34s) lands inside that clip at all, and the trim has to come out as 32-34 on the head and 34-37 on the tail, with the film still skipping the same five seconds. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/ai-edition/document/timeline.test.ts | 52 +++++++++++++++++++ src/lib/ai-edition/store/useTimeline.test.ts | 54 ++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts index 820d19f27..264cdd6c1 100644 --- a/src/lib/ai-edition/document/timeline.test.ts +++ b/src/lib/ai-edition/document/timeline.test.ts @@ -2076,6 +2076,58 @@ describe("splitClipAt", () => { expect(out.timeline.clips[1].wordRefs).toEqual(["w_tail"]); }); + // The flag travels with the clip, and a move re-runs the fold over the whole list. So a + // round trip is the edit that would show a flag lost on the way: whichever half goes + // away and comes back, the two land next to each other again with timecodes that meet, + // and only the flag is left to say the cut was asked for. + it("holds through moving either half away and back again", () => { + const doc = makeDoc({ + timeline: { + clips: [ + makeClip({ id: "clip_1", sourceStartSec: 0, sourceEndSec: 10, timelineEndSec: 10 }), + makeClip({ + id: "other", + sourceStartSec: 30, + sourceEndSec: 40, + timelineStartSec: 10, + timelineEndSec: 20, + }), + ], + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + }); + const split = splitClipAt(doc, "clip_1", 4); + const tailId = split.timeline.clips[1].id; + const windows = (d: AxcutDocument) => + d.timeline.clips.map((c) => [c.id, c.sourceStartSec, c.sourceEndSec]); + + const tailAway = moveClip(split, tailId, 2); + expect(windows(tailAway)).toEqual([ + ["clip_1", 0, 4], + ["other", 30, 40], + [tailId, 4, 10], + ]); + const tailBack = moveClip(tailAway, tailId, 1); + expect(windows(tailBack)).toEqual([ + ["clip_1", 0, 4], + [tailId, 4, 10], + ["other", 30, 40], + ]); + expect(tailBack.timeline.clips[1].splitFromPrevious).toBe(true); + + const headAway = moveClip(split, "clip_1", 2); + const headBack = moveClip(headAway, "clip_1", 0); + expect(windows(headBack)).toEqual([ + ["clip_1", 0, 4], + [tailId, 4, 10], + ["other", 30, 40], + ]); + }); + // `replaceTimeline` keeps a clip whose source window a kept interval reproduces, and it // used to keep it field by field: id, origin, reason, word refs, and not the flag. The // agent's `replace_timeline` and `drop_range` both land there, so a split went through diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index f2430e233..8616bf391 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; +import { resolvePlaybackSegments } from "../document/timeline"; import type { AxcutDocument } from "../schema"; import { axcutSchemaVersion } from "../schema"; import { useProjectStore } from "./projectStore"; @@ -1140,6 +1141,59 @@ describe("useTimeline.splitClipAtPlayhead", () => { ]); }); + // The projection and the trim division meet here, and each has only been pinned on its + // own. The trim is written in the clip's MEDIA time (32s to 37s of the file), the + // playhead in TIMELINE time (14s), and the two only agree on where the cut falls once the + // playhead has been mapped through the clip. Handing `splitClipAt` 14 would be a no-op + // here; handing it anything but 34 would divide the trim at the wrong frame. + it("divides a trim straddling the playhead at the frame the playhead maps to", async () => { + useProjectStore.setState({ + document: { + ...twoWindowDoc, + timeline: { + ...twoWindowDoc.timeline, + trimRanges: [ + { + id: "trim_b", + clipId: "clip_b", + assetId: "asset_1", + startSec: 32, + endSec: 37, + origin: "user", + reason: "", + }, + ], + }, + }, + }); + const { result } = renderTimeline(); + + let didSplit: boolean | undefined; + await act(async () => { + didSplit = await result.current.splitClipAtPlayhead(); + }); + + expect(didSplit).toBe(true); + const timeline = useProjectStore.getState().document?.timeline; + const [, head, tail] = timeline?.clips ?? []; + expect(head).toMatchObject({ id: "clip_b", sourceStartSec: 30, sourceEndSec: 34 }); + expect(tail).toMatchObject({ sourceStartSec: 34, sourceEndSec: 40 }); + const trimsOf = (clipId: string) => + (timeline?.trimRanges ?? []) + .filter((t) => t.clipId === clipId) + .map((t) => [t.startSec, t.endSec]); + expect(trimsOf(head.id)).toEqual([[32, 34]]); + expect(trimsOf(tail.id)).toEqual([[34, 37]]); + // And the film still skips the same five seconds of the file, now in two rows. + const played = resolvePlaybackSegments(timeline?.clips ?? [], timeline?.trimRanges ?? []) + .filter((s) => s.sourceStartSec >= 30) + .map((s) => [s.sourceStartSec, s.sourceEndSec]); + expect(played).toEqual([ + [30, 32], + [37, 40], + ]); + }); + // Parked past the last clip: there is nothing under the playhead to cut, which is // the honest answer rather than cutting the nearest clip instead. it("says nothing was cut when the playhead is off every clip", async () => {