From 9f9d6698312de91261fed15ac1dc8bb9d0dc6d3c Mon Sep 17 00:00:00 2001
From: Benjamin Freeman
Date: Mon, 24 Aug 2026 22:56:04 +0200
Subject: [PATCH 01/84] feat(editor): add audio-track data model for external
audio import
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 of issue #350 (import voiceover / BGM / SFX). Adds the document
model for timeline audio tracks without any UI, IPC, or export wiring yet.
- Widen assetSchema.kind to enum(["video","audio"]) so an imported audio
file (no video stream) has its own kind. Additive — every existing doc
holds "video", which still validates, so no schemaVersion bump.
- Add audioTrackSchema: a timeline-global track addressed in OUTPUT
(post-trim/post-speed) timeline seconds, the same domain the compositor's
concatenated programme PCM lives in. That invariant is what will keep the
live preview and the export in sync in later phases.
- Add document.audioTracks[] (defaulted, so pre-#350 docs load unchanged),
the AxcutAudioTrack type, and a createAudioTrack factory.
- Tests cover defaults, trim/position validation, factory round-trip, the
kind widening, and that a document omitting audioTracks defaults to [].
- Fixture fallout: 15 test files + browserShim build full AxcutDocument
literals and now carry audioTracks: [] alongside their zoomRanges: [].
Co-Authored-By: Claude Opus 4.8
---
.../ai-edition/EditorEmptyState.test.tsx | 1 +
.../ExportDialog.showInFolder.test.tsx | 1 +
.../ai-edition/ExportDialog.test.ts | 1 +
.../ai-edition/WebcamOverlay.test.tsx | 1 +
.../ai-edition/document/outputFormat.test.ts | 1 +
src/lib/ai-edition/document/timeline.test.ts | 1 +
.../ai-edition/document/transcribe.test.ts | 1 +
src/lib/ai-edition/schema/index.test.ts | 102 ++++++++++++++++--
src/lib/ai-edition/schema/index.ts | 65 ++++++++++-
.../ai-edition/store/editorSettings.test.ts | 1 +
src/lib/ai-edition/store/projectStore.test.ts | 1 +
.../ai-edition/store/undo.modalGuard.test.tsx | 1 +
src/lib/ai-edition/store/useCaptions.test.ts | 1 +
.../store/useEditorSettings.test.ts | 1 +
src/lib/ai-edition/store/useTimeline.test.ts | 1 +
.../ai-edition/transcription/status.test.ts | 1 +
src/native/browserShim.ts | 1 +
src/native/sceneDescription.test.ts | 1 +
18 files changed, 175 insertions(+), 8 deletions(-)
diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx
index acb513e4b..be7f130b7 100644
--- a/src/components/ai-edition/EditorEmptyState.test.tsx
+++ b/src/components/ai-edition/EditorEmptyState.test.tsx
@@ -53,6 +53,7 @@ const sampleDoc = vi.hoisted(
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
}),
);
diff --git a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
index 835d9c8fd..db4207c4e 100644
--- a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
+++ b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
@@ -75,6 +75,7 @@ const DOC: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts
index 3aa8d85b5..ed2f1ab1a 100644
--- a/src/components/ai-edition/ExportDialog.test.ts
+++ b/src/components/ai-edition/ExportDialog.test.ts
@@ -57,6 +57,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx
index bdd36a558..9c4b2762d 100644
--- a/src/components/ai-edition/WebcamOverlay.test.tsx
+++ b/src/components/ai-edition/WebcamOverlay.test.tsx
@@ -74,6 +74,7 @@ function makeDocument(): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts
index 4ceac442f..c7e75ab40 100644
--- a/src/lib/ai-edition/document/outputFormat.test.ts
+++ b/src/lib/ai-edition/document/outputFormat.test.ts
@@ -67,6 +67,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index a561dd2f9..bb5172178 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -57,6 +57,7 @@ function makeDoc(overrides: Partial = {}): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
...overrides,
};
diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts
index 72dc8d00b..9e8cbe4bd 100644
--- a/src/lib/ai-edition/document/transcribe.test.ts
+++ b/src/lib/ai-edition/document/transcribe.test.ts
@@ -49,6 +49,7 @@ function makeDoc(): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts
index bbba94fd8..2c6b25be4 100644
--- a/src/lib/ai-edition/schema/index.test.ts
+++ b/src/lib/ai-edition/schema/index.test.ts
@@ -3,8 +3,10 @@ import { migrateRawDocumentToCurrent } from "../document/migrate";
import {
annotationRegionSchema,
assetSchema,
+ audioTrackSchema,
axcutSchemaVersion,
clipSchema,
+ createAudioTrack,
createEmptyDocument,
documentSchema,
ensureDocument,
@@ -40,6 +42,7 @@ describe("axcut-schema v7", () => {
expect(doc.timeline.captionRanges).toEqual([]);
expect(doc.annotations).toEqual([]);
expect(doc.zoomRanges).toEqual([]);
+ expect(doc.audioTracks).toEqual([]);
expect(doc.transcripts).toEqual([]);
expect(doc.legacyEditor).toBeNull();
});
@@ -73,14 +76,22 @@ describe("axcut-schema v7", () => {
).toThrow();
});
- it("assetSchema requires kind = 'video'", () => {
+ it("assetSchema accepts kind 'video' and 'audio', defaulting to 'video'", () => {
+ // Widened from a literal when external-audio import landed (issue #350).
+ const video = assetSchema.parse({ id: "a1", label: "x", originalPath: "/x.mp4" });
+ expect(video.kind).toBe("video");
+ const audio = assetSchema.parse({
+ id: "a2",
+ kind: "audio",
+ label: "bgm",
+ originalPath: "/bgm.mp3",
+ });
+ expect(audio.kind).toBe("audio");
+ });
+
+ it("assetSchema rejects an unknown kind", () => {
expect(() =>
- assetSchema.parse({
- id: "asset_1",
- kind: "audio",
- label: "x",
- originalPath: "/x.mp4",
- }),
+ assetSchema.parse({ id: "a1", kind: "image", label: "x", originalPath: "/x.png" }),
).toThrow();
});
@@ -962,3 +973,80 @@ describe("v6 -> v7 trim clip-anchor migration", () => {
]);
});
});
+
+describe("audio tracks (issue #350)", () => {
+ it("applies defaults for gain, mute, trim, position, and label", () => {
+ const track = audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 42,
+ });
+ expect(track.timelineStartSec).toBe(0);
+ expect(track.trimStartSec).toBe(0);
+ expect(track.trimEndSec).toBeUndefined();
+ expect(track.gainDb).toBe(0);
+ expect(track.mute).toBe(false);
+ expect(track.label).toBe("");
+ });
+
+ it("rejects a trim window whose end precedes its start", () => {
+ expect(() =>
+ audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 10,
+ trimStartSec: 5,
+ trimEndSec: 2,
+ }),
+ ).toThrow();
+ });
+
+ it("rejects a negative timelineStartSec", () => {
+ expect(() =>
+ audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 10,
+ timelineStartSec: -1,
+ }),
+ ).toThrow();
+ });
+
+ it("createAudioTrack builds a schema-valid track with a prefixed id", () => {
+ const track = createAudioTrack({
+ assetId: "asset_1",
+ durationSec: 12.5,
+ timelineStartSec: 3,
+ label: "voiceover.mp3",
+ });
+ expect(track.id).toMatch(/^audio_/);
+ expect(track.assetId).toBe("asset_1");
+ expect(track.durationSec).toBe(12.5);
+ expect(track.timelineStartSec).toBe(3);
+ expect(track.label).toBe("voiceover.mp3");
+ // The factory output must itself round-trip through the schema.
+ expect(() => audioTrackSchema.parse(track)).not.toThrow();
+ });
+
+ it("defaults audioTracks to [] when a stored document omits the key", () => {
+ // A document written before issue #350 has no `audioTracks`; the defaulted
+ // array must fill in so older files load unchanged (no schemaVersion bump).
+ const { audioTracks: _drop, ...withoutAudio } = createEmptyDocument({
+ projectId: "p",
+ title: "t",
+ });
+ expect("audioTracks" in withoutAudio).toBe(false);
+ const parsed = documentSchema.parse(withoutAudio);
+ expect(parsed.audioTracks).toEqual([]);
+ });
+
+ it("round-trips a document carrying an audio track", () => {
+ const track = createAudioTrack({ assetId: "asset_1", durationSec: 8 });
+ const doc = {
+ ...createEmptyDocument({ projectId: "p", title: "t" }),
+ audioTracks: [track],
+ };
+ const parsed = documentSchema.parse(doc);
+ expect(parsed.audioTracks).toEqual([track]);
+ });
+});
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index dd6936fde..a6efe746b 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -150,7 +150,12 @@ export const assetTranscriptionFailureSchema = z.object({
export const assetSchema = z.object({
id: z.string().min(1),
- kind: z.literal("video"),
+ // Widened from a `"video"` literal when external-audio import landed (issue
+ // #350). An imported voiceover / BGM / SFX file carries no video stream, so it
+ // needs its own kind; every document written before this only ever held
+ // `"video"`, which still validates, so the widening is additive (no
+ // schemaVersion bump — same rule as `transcriptionFailure` below).
+ kind: z.enum(["video", "audio"]).default("video"),
label: z.string().min(1),
originalPath: z.string().min(1),
proxyPath: z.string().optional(),
@@ -471,6 +476,38 @@ export const zoomRegionSchema = endGteStart(
"startMs",
);
+// External audio import (issue #350) — voiceover / BGM / SFX layered over the
+// assembled programme. Unlike zoom/speed/annotation/trim, an audio track is NOT
+// clip-anchored: it floats over the whole timeline, so it is addressed in OUTPUT
+// (post-trim, post-speed) timeline seconds — the same domain the compositor's
+// concatenated programme PCM lives in (see `SceneAudio` in
+// src/native/sceneDescription.ts and `audio.rs`). That single invariant is what
+// keeps the live preview and the export in sync without a per-track sync offset.
+//
+// `assetId` points at an asset with `kind: "audio"`. `timelineStartSec` places
+// the track's head on the programme; `trimStartSec`/`trimEndSec` window the
+// source file (both in source seconds); `gainDb` + `mute` set its level.
+export const audioTrackSchema = z
+ .object({
+ id: z.string().min(1),
+ assetId: z.string().min(1),
+ timelineStartSec: z.number().nonnegative().default(0),
+ // Full source duration of the underlying file, cached here so the timeline
+ // can lay out the pill before the asset is re-probed on load.
+ durationSec: z.number().nonnegative().default(0),
+ trimStartSec: z.number().nonnegative().default(0),
+ // Absent means "play to the end of the file". Explicit when the user trims
+ // the tail so the pill and the export agree on where the track stops.
+ trimEndSec: z.number().nonnegative().optional(),
+ gainDb: z.number().default(0),
+ mute: z.boolean().default(false),
+ label: z.string().default(""),
+ })
+ .refine((data) => data.trimEndSec === undefined || data.trimEndSec >= data.trimStartSec, {
+ message: "trimEndSec must be greater than or equal to trimStartSec",
+ path: ["trimEndSec"],
+ });
+
// Legacy OpenScreen appearance / export settings that the v3 schema doesn't
// normalize into the timeline / assets model. They are applied at export time
// by the existing pipeline (see technical-documentation/architecture/document-model.md).
@@ -503,6 +540,9 @@ const documentSchemaShape = z.object({
}),
annotations: z.array(annotationRegionSchema).default([]),
zoomRanges: z.array(zoomRegionSchema).default([]),
+ // Imported audio tracks (issue #350). Defaulted so every document written
+ // before this loads unchanged; an older build simply strips the key on save.
+ audioTracks: z.array(audioTrackSchema).default([]),
legacyEditor: legacyEditorSchema.nullable().default(null),
});
@@ -944,6 +984,7 @@ export type AxcutTimelineOperation = z.infer;
export type AxcutAnnotationRegion = z.infer;
export type AxcutZoomRegion = z.infer;
export type AxcutCameraTrack = z.infer;
+export type AxcutAudioTrack = z.infer;
export type AxcutLegacyEditor = z.infer;
export type AxcutDocument = z.infer;
export type AxcutDocumentInput = z.input;
@@ -979,6 +1020,7 @@ export function createEmptyDocument(
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
});
}
@@ -986,3 +1028,24 @@ export function createEmptyDocument(
export function ensureDocument(value: unknown): AxcutDocument {
return documentSchema.parse(value);
}
+
+/**
+ * Build a timeline audio track for an imported audio asset (issue #350). The
+ * head is placed at `timelineStartSec` (output-timeline seconds) and the track
+ * spans the whole source file until the user trims it. Parsed through the schema
+ * so every default (gain, mute, trim) is applied in one place.
+ */
+export function createAudioTrack(input: {
+ assetId: string;
+ durationSec: number;
+ timelineStartSec?: number;
+ label?: string;
+}): AxcutAudioTrack {
+ return audioTrackSchema.parse({
+ id: createId("audio"),
+ assetId: input.assetId,
+ durationSec: input.durationSec,
+ timelineStartSec: input.timelineStartSec ?? 0,
+ label: input.label ?? "",
+ });
+}
diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts
index 07982f47a..0848d3404 100644
--- a/src/lib/ai-edition/store/editorSettings.test.ts
+++ b/src/lib/ai-edition/store/editorSettings.test.ts
@@ -29,6 +29,7 @@ const baseDoc: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
transcripts: [],
transcript: null,
legacyEditor: null,
diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts
index a46a6b535..84074aad2 100644
--- a/src/lib/ai-edition/store/projectStore.test.ts
+++ b/src/lib/ai-edition/store/projectStore.test.ts
@@ -62,6 +62,7 @@ const sampleDoc = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/store/undo.modalGuard.test.tsx b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
index f54b12389..46e083b0b 100644
--- a/src/lib/ai-edition/store/undo.modalGuard.test.tsx
+++ b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
@@ -35,6 +35,7 @@ function doc(title: string): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/store/useCaptions.test.ts b/src/lib/ai-edition/store/useCaptions.test.ts
index b4063d68c..0cd641328 100644
--- a/src/lib/ai-edition/store/useCaptions.test.ts
+++ b/src/lib/ai-edition/store/useCaptions.test.ts
@@ -71,6 +71,7 @@ const docA: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/store/useEditorSettings.test.ts b/src/lib/ai-edition/store/useEditorSettings.test.ts
index bd47cb452..4122c05bf 100644
--- a/src/lib/ai-edition/store/useEditorSettings.test.ts
+++ b/src/lib/ai-edition/store/useEditorSettings.test.ts
@@ -74,6 +74,7 @@ const docA: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts
index 78854e817..ef28cd571 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -110,6 +110,7 @@ const sampleDoc: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts
index 00261712e..82b282703 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -243,6 +243,7 @@ describe("transcriptRelevantAssetIds", () => {
transcripts: [],
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts
index 8ef5989a8..c00f53d51 100644
--- a/src/native/browserShim.ts
+++ b/src/native/browserShim.ts
@@ -386,6 +386,7 @@ function createShimBridgeClient() {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
documentsByProject[doc.project.id] = doc;
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index 570ae71ee..2c02137e0 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -91,6 +91,7 @@ function makeDoc(
},
annotations: overrides.annotations ?? [],
zoomRanges: overrides.zoomRanges ?? [],
+ audioTracks: overrides.audioTracks ?? [],
legacyEditor: overrides.legacyEditor ?? null,
};
}
From 95e80d673ec7d54ca53908a00e35b713381334ca Mon Sep 17 00:00:00 2001
From: Benjamin Freeman
Date: Mon, 24 Aug 2026 23:27:03 +0200
Subject: [PATCH 02/84] feat(editor): import external audio files as audio-kind
assets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 2 of issue #350. Wires up picking an external audio file (voiceover
/ BGM / SFX) and adding it to a project as a kind:"audio" asset. No
timeline placement, preview, or export yet.
- IPC: open-audio-file-picker mirrors the video picker but approves against
a dedicated audio extension set (mp3/wav/m4a/aac/flac/ogg/opus). Factor the
path approver into a shared approveReadableMediaPath so the audio and video
approvers differ only by their extension gate — an audio picker must not
approve a video path or vice versa.
- document-service.addAsset takes a kind; an audio import validates against
audio extensions and never claims the empty primaryAssetId slot, so a BGM
file dropped into a fresh project can't become its primary (video) asset.
Threaded kind through the bridge chain (contracts, client, nativeBridge,
aiEditionService) and the browser shim.
- projectStore.addAudioAsset imports the file, skips the camera-sidecar
lookup addAsset does, and probes the real duration up front (new
probeAudioDuration, the
) : null}
-
+ {/* No transcribe button here. This pane is reached from the transcript
+ tab, whose empty state carries the one gate — and two buttons for
+ one background pass is what made people believe captions were
+ transcribed separately from the transcript (issue #560). What is
+ worth saying here is whether a run is already going. */}
+ {isTranscribing ? (
+
+
+ {t("captions.transcribing")}
+
+ ) : null}
) : (
{title}
-
+
+ {actions}
@@ -1259,6 +1359,7 @@ const TranscriptWord = memo(function TranscriptWord({
onRestore,
onAddTrimRange,
onSetWordText,
+ onRemoveWords,
}: {
cw: ClipWord;
isCue: boolean;
@@ -1269,6 +1370,7 @@ const TranscriptWord = memo(function TranscriptWord({
onRestore: (run: TrimRun) => void;
onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void;
onSetWordText: (assetId: string, wordId: string, text: string) => void;
+ onRemoveWords: (assetId: string, wordIds: string[]) => void;
}) {
const ts = useScopedT("settings");
const [hover, setHover] = useState(false);
@@ -1296,6 +1398,12 @@ const TranscriptWord = memo(function TranscriptWord({
onSetWordText(target.assetId, cw.word.id, next);
}, [draft, cw.word.text, cw.word.id, onSetWordText, target.assetId]);
+ const inserted = isInsertedWord(cw.word);
+
+ const removeInserted = useCallback(() => {
+ onRemoveWords(target.assetId, [cw.word.id]);
+ }, [onRemoveWords, target.assetId, cw.word.id]);
+
const revert = useCallback(() => {
if (original === undefined) return;
// Writing the original back through the same path is what clears the provenance
@@ -1414,7 +1522,6 @@ const TranscriptWord = memo(function TranscriptWord({
setDraft(null);
}
}}
- onBeforeInput={(event) => event.stopPropagation()}
onPaste={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
style={{
@@ -1436,6 +1543,58 @@ const TranscriptWord = memo(function TranscriptWord({
);
}
+ // A word nobody said. Amber rather than the accent: this one is not a fix to what was
+ // heard, it is text with no sound underneath — the caveat is the point. Double-click
+ // rewrites it like any other word; the cross deletes it, because there is no audio for a
+ // trim to remove.
+ if (inserted) {
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ onDoubleClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ startEditing();
+ }}
+ >
+
+ {cw.word.text}
+
+ {hover ? (
+
+
+
+ ) : null}{" "}
+
+ );
+ }
+
// A word the user emptied. It still owns a span of the media, so it keeps a place in
// the stream: rendered as its own (empty) text it would be a bare space — invisible,
// impossible to click, and therefore impossible to undo.
@@ -1568,6 +1727,27 @@ const TranscriptWord = memo(function TranscriptWord({
* bin on a cut word — same size, same place, the accent rather than the danger colour,
* since reverting a correction restores something instead of removing it. */
function RevertWordButton({ label, onRevert }: { label: string; onRevert: () => void }) {
+ return (
+
+
+
+ );
+}
+
+/** The one hover control shape the word stream uses, in whichever colour says what it does.
+ * `contentEditable={false}` keeps it out of the enclosing editable block, and the click is
+ * stopped so it never reaches the seek handler underneath. */
+function WordChipButton({
+ label,
+ tone,
+ onPress,
+ children,
+}: {
+ label: string;
+ tone: string;
+ onPress: () => void;
+ children: ReactNode;
+}) {
return (
aria-label={label}
onClick={(e) => {
e.stopPropagation();
- onRevert();
+ onPress();
}}
style={{
display: "inline-flex",
@@ -1588,17 +1768,84 @@ function RevertWordButton({ label, onRevert }: { label: string; onRevert: () =>
padding: 0,
border: 0,
borderRadius: 4,
- background: "var(--accent)",
+ background: tone,
color: "white",
cursor: "pointer",
verticalAlign: "middle",
}}
>
-
+ {children}
);
}
+/**
+ * The field a typed character opens between two words. It is not a word yet — nothing is
+ * written until it commits — so it carries no `data-word-id` and no place in `words`.
+ *
+ * Every event it raises is stopped at the field, for the same reason the word editor stops
+ * its own: the block around it reads Backspace as a cut and a click as a seek.
+ */
+function InsertionField({
+ value,
+ label,
+ onChange,
+ onCommit,
+ onCancel,
+ abandonedRef,
+}: {
+ value: string;
+ label: string;
+ onChange: (value: string) => void;
+ onCommit: () => void;
+ onCancel: () => void;
+ abandonedRef: { current: boolean };
+}) {
+ return (
+ onChange(event.target.value)}
+ onBlur={() => {
+ if (abandonedRef.current) {
+ abandonedRef.current = false;
+ return;
+ }
+ onCommit();
+ }}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ onCommit();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ onCancel();
+ }
+ }}
+ onBeforeInput={(event) => event.stopPropagation()}
+ onPaste={(event) => event.stopPropagation()}
+ onPointerUp={(event) => event.stopPropagation()}
+ style={{
+ display: "inline",
+ width: `${Math.max(value.length, 3) + 2}ch`,
+ margin: "0 3px 2px 0",
+ padding: "0 5px",
+ border: "1px solid var(--warn)",
+ borderRadius: 999,
+ background: "var(--warn-soft)",
+ color: "var(--fg)",
+ font: "inherit",
+ outline: "none",
+ }}
+ />
+ );
+}
+
// ─── Caret / selection helpers ────────────────────────────────────
// Ponytail port of axcut's findCollapsedDeletionWordId. The non-collapsed
// path uses findWordId directly (a range selection's endpoints already
@@ -1693,6 +1940,79 @@ function findCollapsedDeletionWordId(
return pool.find((wordNode) => isKept(wordNode.dataset.wordId ?? null))?.dataset.wordId ?? null;
}
+/**
+ * Where a typed character goes: beside the word the caret was resting on, never inside it.
+ *
+ * A caret in the middle of a word anchors AFTER that word rather than splitting it in two —
+ * a split would need two words where the transcript has one, and neither half would own the
+ * audio any more. At the very start of the block there is nothing to sit after, so the
+ * anchor is the first word and the new one lands before it.
+ */
+function findInsertionAnchor(
+ editor: HTMLElement,
+ node: Node | null,
+ offset: number,
+): { clipWordId: string; side: InsertSide } | null {
+ const wordNodes = Array.from(editor.querySelectorAll("[data-word-id]"));
+ if (wordNodes.length === 0 || !node) return null;
+
+ const direct = closestWordElement(node);
+ if (direct?.dataset.wordId) {
+ const atStart = node.nodeType === Node.TEXT_NODE && offset <= 0;
+ return { clipWordId: direct.dataset.wordId, side: atStart ? "before" : "after" };
+ }
+
+ // The caret is between the block's own children, and `offset` is a child index — the
+ // same shape `findCollapsedDeletionWordId` reads when it resolves a cut. Walk back for
+ // the word to sit after; if there is none, the caret is at the head of the stream and
+ // the new word goes before the first word ahead of it.
+ const childNodes = Array.from(node.childNodes);
+ for (const candidate of childNodes.slice(0, clampRangeOffset(node, offset)).reverse()) {
+ const wordId = findWordId(candidate) ?? findDescendantWordId(candidate);
+ if (wordId) return { clipWordId: wordId, side: "after" };
+ }
+ for (const candidate of childNodes.slice(clampRangeOffset(node, offset))) {
+ const wordId = findWordId(candidate) ?? findDescendantWordId(candidate);
+ if (wordId) return { clipWordId: wordId, side: "before" };
+ }
+ const first = wordNodes[0];
+ return first?.dataset.wordId ? { clipWordId: first.dataset.wordId, side: "before" } : null;
+}
+
+/**
+ * Pull the DOM's answer back onto a word the TRANSCRIPT has.
+ *
+ * `[silence]` pills carry a `data-word-id` like everything else in the stream, but they are
+ * pseudo-words `withSilenceGaps` invents per clip — there is nothing in `transcript.words`
+ * for a new word to be inserted next to. So the anchor walks off a silence to the nearest
+ * real word in the direction the caret was already facing, and only crosses to the other
+ * side when that direction runs out of stream.
+ */
+function resolveInsertionAnchor(
+ words: ClipWord[],
+ clipWordId: string,
+ side: InsertSide,
+): { clipWordId: string; side: InsertSide } | null {
+ const from = words.findIndex((w) => w.id === clipWordId);
+ if (from < 0) return null;
+ const real = (index: number) =>
+ index >= 0 && index < words.length && !isSilenceWord(words[index].word);
+ if (side === "after") {
+ for (let i = from; i >= 0; i--) if (real(i)) return { clipWordId: words[i].id, side: "after" };
+ for (let i = 0; i < words.length; i++) {
+ if (real(i)) return { clipWordId: words[i].id, side: "before" };
+ }
+ return null;
+ }
+ for (let i = from; i < words.length; i++) {
+ if (real(i)) return { clipWordId: words[i].id, side: "before" };
+ }
+ for (let i = words.length - 1; i >= 0; i--) {
+ if (real(i)) return { clipWordId: words[i].id, side: "after" };
+ }
+ return null;
+}
+
function findDescendantWordId(node: Node): string | null {
if (node instanceof HTMLElement && node.dataset.wordId) {
return node.dataset.wordId;
diff --git a/src/components/ai-edition/TranscriptPane.gating.test.tsx b/src/components/ai-edition/TranscriptPane.gating.test.tsx
index 34d97dfd3..bb62d61eb 100644
--- a/src/components/ai-edition/TranscriptPane.gating.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.gating.test.tsx
@@ -57,6 +57,8 @@ function renderPane(
onAddTrimRange={vi.fn()}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={overrides.isTranscribing ?? false}
diff --git a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
index 520e4b81b..97ba78739 100644
--- a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
@@ -85,6 +85,8 @@ function renderPane(
onAddTrimRange={onAddTrimRange}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
@@ -229,6 +231,8 @@ describe("keyboard cut with the caret between words", () => {
}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
diff --git a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
index c3ad72571..37a03b25a 100644
--- a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
@@ -79,6 +79,8 @@ function renderPane(onSeek: (sec: number) => void = vi.fn()) {
onAddTrimRange={vi.fn()}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
diff --git a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
index 2514fb23f..3c8fd0b97 100644
--- a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
@@ -65,6 +65,8 @@ function renderPane(words?: AxcutWord[], busyAssetIds: string[] = []) {
onAddTrimRange={onAddTrimRange}
onRemoveTrimRange={vi.fn()}
onSetWordText={onSetWordText}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
diff --git a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
new file mode 100644
index 000000000..7af0cc9db
--- /dev/null
+++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
@@ -0,0 +1,242 @@
+// @vitest-environment jsdom
+// Typing a word into the transcript that nobody said.
+//
+// This is the third gesture on the one word stream, and the one that had to get past a
+// guard: the block used to swallow every keystroke outright, because free text has no
+// `transcript.words` entry to land on. It still never lands in the block — what a typed
+// character opens is a field beside the word the caret was on, and only its commit makes a
+// word. These tests hold that: the DOM never gets ahead of `words`, and Backspace inside
+// the field types instead of cutting the clip out from under it.
+
+import "@testing-library/jest-dom";
+import { cleanup, fireEvent, render } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { I18nProvider } from "@/contexts/I18nContext";
+import type { AxcutAsset, AxcutClip, AxcutTranscript, AxcutWord } from "@/lib/ai-edition/schema";
+import { TranscriptPane } from "./RightPanes";
+
+vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } }));
+vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
+
+const ASSET: AxcutAsset = {
+ id: "asset_1",
+ kind: "video",
+ label: "recording.mp4",
+ originalPath: "/rec.mp4",
+ durationSec: 3,
+ cameraTrack: null,
+};
+
+const CLIP: AxcutClip = {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 3,
+ timelineStartSec: 0,
+ timelineEndSec: 3,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+};
+
+// Contiguous, so no `[silence]` pill sits between them to shift the caret indices.
+const WORDS: AxcutWord[] = [
+ { id: "w1", segmentId: "s", startSec: 0, endSec: 1, text: "Bonjour" },
+ { id: "w2", segmentId: "s", startSec: 1, endSec: 2, text: "à" },
+ { id: "w3", segmentId: "s", startSec: 2, endSec: 3, text: "tous" },
+];
+
+function renderPane(words: AxcutWord[] = WORDS, busyAssetIds: string[] = []) {
+ const onInsertWord = vi.fn();
+ const onRemoveWords = vi.fn();
+ const onAddTrimRange = vi.fn();
+ const transcript: AxcutTranscript = {
+ assetId: "asset_1",
+ language: "fr",
+ segments: [],
+ words,
+ };
+ const view = render(
+
+
+ ,
+ );
+ const editor = view.container.querySelector('[role="textbox"]');
+ if (!editor) throw new Error("transcript editor not rendered");
+ const field = () => view.container.querySelector("input[data-word-inserter]");
+ const wordEl = (id: string) => {
+ const el = view.container.querySelector(`[data-word-id="clip_1:${id}"]`);
+ if (!el) throw new Error(`word ${id} not rendered`);
+ return el;
+ };
+ return { ...view, editor, field, wordEl, onInsertWord, onRemoveWords, onAddTrimRange };
+}
+
+/** Park the caret between words at editor level, the way `restoreCaretBeforeWord` does. */
+function caretBeforeWordAt(editor: HTMLElement, index: number) {
+ const range = document.createRange();
+ range.setStart(editor, index);
+ range.collapse(true);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+}
+
+/**
+ * A real native `beforeinput`, because that is what the block listens to.
+ *
+ * Not `fireEvent.beforeInput`: React 18 builds its `onBeforeInput` from the legacy
+ * `textInput` event, whose `TextEvent` has no `inputType` — which is exactly why the guard
+ * moved off React and onto the DOM. Driving the synthetic one here would test a path the
+ * browser never takes.
+ */
+function type(editor: HTMLElement, data: string) {
+ // Through `fireEvent` so the state the listener sets is flushed, but with an event
+ // built by hand — `fireEvent.beforeInput` does not exist here, and the point is to
+ // dispatch the real thing.
+ fireEvent(
+ editor,
+ new InputEvent("beforeinput", {
+ data,
+ inputType: "insertText",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ window.getSelection()?.removeAllRanges();
+});
+
+describe("typing between two words", () => {
+ it("opens a field there instead of dropping the keystroke", () => {
+ const view = renderPane();
+ expect(view.field()).toBeNull();
+ caretBeforeWordAt(view.editor, 2); // between "à" and "tous"
+ type(view.editor, "v");
+ expect(view.field()).toHaveValue("v");
+ });
+
+ it("never writes the typed text into the block itself", () => {
+ // The whole reason inserts were blocked: a run of text with no word id behind it
+ // desynchronises the DOM from `words`.
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ expect(view.editor.textContent).not.toContain("v ");
+ expect(view.onInsertWord).not.toHaveBeenCalled();
+ });
+
+ it("commits on Enter, against the word the caret was after", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.change(field, { target: { value: "vraiment" } });
+ fireEvent.keyDown(field, { key: "Enter" });
+ expect(view.onInsertWord).toHaveBeenCalledWith("asset_1", "w2", "after", "vraiment");
+ });
+
+ it("anchors before the first word when the caret is at the very start", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 0);
+ type(view.editor, "E");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.keyDown(field, { key: "Enter" });
+ expect(view.onInsertWord).toHaveBeenCalledWith("asset_1", "w1", "before", "E");
+ });
+
+ it("abandons on Escape without writing anything", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.keyDown(field, { key: "Escape" });
+ fireEvent.blur(field);
+ expect(view.onInsertWord).not.toHaveBeenCalled();
+ expect(view.field()).toBeNull();
+ });
+
+ it("commits on blur", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 1);
+ type(view.editor, "x");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.change(field, { target: { value: "donc" } });
+ fireEvent.blur(field);
+ expect(view.onInsertWord).toHaveBeenCalledWith("asset_1", "w1", "after", "donc");
+ });
+
+ it("does not cut the media when Backspace is pressed inside the field", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.keyDown(field, { key: "Backspace" });
+ expect(view.onAddTrimRange).not.toHaveBeenCalled();
+ });
+
+ it("stays shut while this clip's transcript is being regenerated", () => {
+ const view = renderPane(WORDS, ["asset_1"]);
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ expect(view.field()).toBeNull();
+ });
+});
+
+describe("a word that was inserted", () => {
+ const INSERTED: AxcutWord[] = [
+ WORDS[0],
+ { id: "synth_1", segmentId: "s", startSec: 1, endSec: 1, text: "vraiment", source: "synth" },
+ WORDS[1],
+ WORDS[2],
+ ];
+
+ it("reads as its own thing, not as a transcribed word", () => {
+ const view = renderPane(INSERTED);
+ const el = view.wordEl("synth_1");
+ expect(el).toHaveAttribute("data-inserted", "true");
+ expect(el.textContent).toContain("vraiment");
+ });
+
+ // There is no audio for a trim to remove, so the gesture that makes a spoken word go
+ // away cannot be the one that makes this go away.
+ it("is deleted outright by its own control", () => {
+ const view = renderPane(INSERTED);
+ fireEvent.mouseEnter(view.wordEl("synth_1"));
+ const remove = view.wordEl("synth_1").querySelector("button");
+ if (!remove) throw new Error("no delete control");
+ fireEvent.click(remove);
+ expect(view.onRemoveWords).toHaveBeenCalledWith("asset_1", ["synth_1"]);
+ });
+
+ it("is deleted, not trimmed, when Backspace lands on it alone", () => {
+ const view = renderPane(INSERTED);
+ caretBeforeWordAt(view.editor, 2); // right after the insert
+ fireEvent.keyDown(view.editor, { key: "Backspace" });
+ expect(view.onRemoveWords).toHaveBeenCalledWith("asset_1", ["synth_1"]);
+ expect(view.onAddTrimRange).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json
index 93f340aaa..afafe6f4b 100644
--- a/src/i18n/locales/ar/editor.json
+++ b/src/i18n/locales/ar/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
"failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}",
"previewCompositorUnavailable": "المعاينة غير متوفرة على هذا الجهاز",
- "wordEditFailed": "تعذّر تغيير هذه الكلمة"
+ "wordEditFailed": "تعذّر تغيير هذه الكلمة",
+ "wordInsertFailed": "تعذّرت إضافة هذه الكلمة",
+ "wordRemoveFailed": "تعذّر حذف هذه الكلمة"
},
"export": {
"canceled": "تم إلغاء التصدير",
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index b211f0a7d..21b37731d 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "النص الحالي",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها: تتبعها الترجمات ولا يتغيّر الفيديو. مرّر المؤشر فوق كلمة معلَّمة لاستعادتها.",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
"noClips": "لا توجد مقاطع بعد",
"noTranscript": "لا يوجد نص بعد",
"whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
@@ -281,6 +281,9 @@
"correctedWord": "مصحّحة — كان النص \"{{original}}\"",
"revertWord": "استعادة \"{{original}}\"",
"blankedWord": "مُفرَّغة",
+ "insertAria": "كلمة جديدة",
+ "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
+ "removeInserted": "حذف \"{{word}}\"",
"noAudio": "لا يحتوي هذا الملف على مسار صوتي"
},
"captions": {
diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json
index bb167861a..6128ad898 100644
--- a/src/i18n/locales/en/editor.json
+++ b/src/i18n/locales/en/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Failed to save exported video",
"failedToRevealInFolder": "Error revealing in folder: {{error}}",
"previewCompositorUnavailable": "Preview unavailable on this machine",
- "wordEditFailed": "Could not change that word"
+ "wordEditFailed": "Could not change that word",
+ "wordInsertFailed": "Could not add that word",
+ "wordRemoveFailed": "Could not delete that word"
},
"export": {
"canceled": "Export canceled",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 06ef40395..0f79df528 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -270,7 +270,7 @@
},
"transcript": {
"title": "Current transcription",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text — the captions follow, the film does not move. Hover a marked word to restore it.",
+ "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Type between two words to add one, in amber: it reaches the captions and leaves the film alone. Hover a marked word to undo it.",
"noClips": "No clips yet",
"noTranscript": "No transcript yet",
"whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
@@ -287,6 +287,9 @@
"correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
"revertWord": "Restore \"{{original}}\"",
"blankedWord": "blanked",
+ "insertAria": "New word",
+ "insertedWord": "Added by you — no audio behind it",
+ "removeInserted": "Delete \"{{word}}\"",
"noAudio": "This media has no audio track"
},
"captions": {
diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json
index 891d3d16d..410802353 100644
--- a/src/i18n/locales/es/editor.json
+++ b/src/i18n/locales/es/editor.json
@@ -13,7 +13,9 @@
"failedToSaveExportedVideo": "Error al guardar el video exportado",
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}",
"previewCompositorUnavailable": "Vista previa no disponible en este equipo",
- "wordEditFailed": "No se pudo cambiar esa palabra"
+ "wordEditFailed": "No se pudo cambiar esa palabra",
+ "wordInsertFailed": "No se pudo añadir esa palabra",
+ "wordRemoveFailed": "No se pudo eliminar esa palabra"
},
"export": {
"canceled": "Exportación cancelada",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index c47036224..ba4f052d1 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "Transcripción actual",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto: los subtítulos la siguen, el vídeo no cambia. Pasa el cursor sobre una palabra marcada para restaurarla.",
+ "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo. Pasa el cursor sobre una palabra marcada para deshacer.",
"noClips": "Aún no hay clips",
"noTranscript": "Aún no hay transcripción",
"whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
@@ -281,6 +281,9 @@
"correctedWord": "Corregida: la transcripción decía «{{original}}»",
"revertWord": "Restaurar «{{original}}»",
"blankedWord": "vaciada",
+ "insertAria": "Palabra nueva",
+ "insertedWord": "Añadida por ti: no hay audio detrás",
+ "removeInserted": "Eliminar «{{word}}»",
"noAudio": "Este medio no tiene pista de audio"
},
"captions": {
diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json
index f7a04a264..a2e134fe3 100644
--- a/src/i18n/locales/fr/editor.json
+++ b/src/i18n/locales/fr/editor.json
@@ -19,7 +19,9 @@
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}",
"previewCompositorUnavailable": "Aperçu indisponible sur cette machine",
- "wordEditFailed": "Impossible de modifier ce mot"
+ "wordEditFailed": "Impossible de modifier ce mot",
+ "wordInsertFailed": "Impossible d'ajouter ce mot",
+ "wordRemoveFailed": "Impossible de supprimer ce mot"
},
"export": {
"canceled": "Export annulé",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index ddfb2bbdf..05f6bff42 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "Transcription actuelle",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte : les sous-titres suivent, le film ne bouge pas. Survolez un mot marqué pour le rétablir.",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film. Survolez un mot marqué pour annuler.",
"noClips": "Aucun clip pour l'instant",
"noTranscript": "Aucune transcription pour l'instant",
"whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
@@ -281,6 +281,9 @@
"correctedWord": "Corrigé — la transcription disait « {{original}} »",
"revertWord": "Rétablir « {{original}} »",
"blankedWord": "vidé",
+ "insertAria": "Nouveau mot",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
+ "removeInserted": "Supprimer « {{word}} »",
"noAudio": "Ce média n'a pas de piste audio"
},
"captions": {
diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json
index 1734c8c82..8e6498dee 100644
--- a/src/i18n/locales/it/editor.json
+++ b/src/i18n/locales/it/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Impossibile salvare il video esportato",
"failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}",
"previewCompositorUnavailable": "Anteprima non disponibile su questo computer",
- "wordEditFailed": "Impossibile modificare questa parola"
+ "wordEditFailed": "Impossibile modificare questa parola",
+ "wordInsertFailed": "Impossibile aggiungere questa parola",
+ "wordRemoveFailed": "Impossibile eliminare questa parola"
},
"export": {
"canceled": "Esportazione annullata",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 565cf1864..1777770b4 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "Trascrizione corrente",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo: i sottotitoli la seguono, il video non cambia. Passa sopra una parola contrassegnata per ripristinarla.",
+ "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video. Passa sopra una parola contrassegnata per annullare.",
"noClips": "Ancora nessun clip",
"noTranscript": "Ancora nessuna trascrizione",
"whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
@@ -281,6 +281,9 @@
"correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
"revertWord": "Ripristina «{{original}}»",
"blankedWord": "svuotata",
+ "insertAria": "Nuova parola",
+ "insertedWord": "Aggiunta da te — nessun audio dietro",
+ "removeInserted": "Elimina «{{word}}»",
"noAudio": "Questo contenuto non ha una traccia audio"
},
"captions": {
diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json
index 2686aaff8..2238b2ef4 100644
--- a/src/i18n/locales/ja-JP/editor.json
+++ b/src/i18n/locales/ja-JP/editor.json
@@ -21,7 +21,9 @@
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
"exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})",
"previewCompositorUnavailable": "このマシンではプレビューを表示できません",
- "wordEditFailed": "この単語を変更できませんでした"
+ "wordEditFailed": "この単語を変更できませんでした",
+ "wordInsertFailed": "この単語を追加できませんでした",
+ "wordRemoveFailed": "この単語を削除できませんでした"
},
"export": {
"canceled": "エクスポートがキャンセルされました",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 96807f17f..201bc054c 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "現在の文字起こし",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕は追従し、映像は変わりません。印の付いた単語にカーソルを合わせると元に戻せます。",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
"noClips": "クリップがまだありません",
"noTranscript": "文字起こしがまだありません",
"whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
@@ -281,6 +281,9 @@
"correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
"revertWord": "「{{original}}」に戻す",
"blankedWord": "空欄",
+ "insertAria": "新しい単語",
+ "insertedWord": "あなたが追加した単語 — 音声はありません",
+ "removeInserted": "「{{word}}」を削除",
"noAudio": "このメディアには音声トラックがありません"
},
"captions": {
diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json
index 16400545c..5acb26773 100644
--- a/src/i18n/locales/ko-KR/editor.json
+++ b/src/i18n/locales/ko-KR/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
"failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}",
"previewCompositorUnavailable": "이 컴퓨터에서는 미리보기를 사용할 수 없습니다",
- "wordEditFailed": "이 단어를 변경할 수 없습니다"
+ "wordEditFailed": "이 단어를 변경할 수 없습니다",
+ "wordInsertFailed": "이 단어를 추가할 수 없습니다",
+ "wordRemoveFailed": "이 단어를 삭제할 수 없습니다"
},
"export": {
"canceled": "내보내기가 취소되었습니다",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 9410e4dbe..f598c862f 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "현재 전사",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막은 따라가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 두 단어 사이에 입력하면 호박색 단어가 추가됩니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
"noClips": "아직 클립이 없습니다",
"noTranscript": "아직 전사가 없습니다",
"whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
@@ -281,6 +281,9 @@
"correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
"revertWord": "\"{{original}}\"(으)로 되돌리기",
"blankedWord": "비움",
+ "insertAria": "새 단어",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
+ "removeInserted": "\"{{word}}\" 삭제",
"noAudio": "이 미디어에는 오디오 트랙이 없습니다"
},
"captions": {
diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json
index 9b0054861..1a4b9347c 100644
--- a/src/i18n/locales/pt-BR/editor.json
+++ b/src/i18n/locales/pt-BR/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Falha ao salvar vídeo exportado",
"failedToRevealInFolder": "Erro ao mostrar na pasta: {{error}}",
"previewCompositorUnavailable": "Pré-visualização indisponível neste computador",
- "wordEditFailed": "Não foi possível alterar essa palavra"
+ "wordEditFailed": "Não foi possível alterar essa palavra",
+ "wordInsertFailed": "Não foi possível adicionar essa palavra",
+ "wordRemoveFailed": "Não foi possível excluir essa palavra"
},
"export": {
"canceled": "Exportação cancelada",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 5b7291b64..40f9a8e99 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "Transcrição atual",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto: as legendas acompanham, o vídeo não muda. Passe o mouse sobre uma palavra marcada para restaurá-la.",
+ "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo. Passe o mouse sobre uma palavra marcada para desfazer.",
"noClips": "Nenhum clipe ainda",
"noTranscript": "Nenhuma transcrição ainda",
"whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
@@ -281,6 +281,9 @@
"correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
"revertWord": "Restaurar \"{{original}}\"",
"blankedWord": "apagada",
+ "insertAria": "Nova palavra",
+ "insertedWord": "Adicionada por você — sem áudio por trás",
+ "removeInserted": "Excluir \"{{word}}\"",
"noAudio": "Esta mídia não tem faixa de áudio"
},
"captions": {
diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json
index c10afdf23..8b6aba527 100644
--- a/src/i18n/locales/ru/editor.json
+++ b/src/i18n/locales/ru/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Не удалось сохранить экспортированное видео",
"failedToRevealInFolder": "Ошибка при показе в папке: {{error}}",
"previewCompositorUnavailable": "Предпросмотр недоступен на этом компьютере",
- "wordEditFailed": "Не удалось изменить это слово"
+ "wordEditFailed": "Не удалось изменить это слово",
+ "wordInsertFailed": "Не удалось добавить слово",
+ "wordRemoveFailed": "Не удалось удалить слово"
},
"export": {
"canceled": "Экспорт отменён",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index 73da559dd..f39138626 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "Текущая расшифровка",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст: субтитры следуют за ним, видео не меняется. Наведите курсор на отмеченное слово, чтобы вернуть его.",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео. Наведите курсор на отмеченное слово, чтобы отменить.",
"noClips": "Клипов пока нет",
"noTranscript": "Расшифровки пока нет",
"whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
@@ -281,6 +281,9 @@
"correctedWord": "Исправлено — в расшифровке было «{{original}}»",
"revertWord": "Вернуть «{{original}}»",
"blankedWord": "очищено",
+ "insertAria": "Новое слово",
+ "insertedWord": "Добавлено вами — за ним нет звука",
+ "removeInserted": "Удалить «{{word}}»",
"noAudio": "В этом медиафайле нет аудиодорожки"
},
"captions": {
diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json
index b39f81a2c..a91e03fe0 100644
--- a/src/i18n/locales/tr/editor.json
+++ b/src/i18n/locales/tr/editor.json
@@ -13,7 +13,9 @@
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}",
"previewCompositorUnavailable": "Bu makinede önizleme kullanılamıyor",
- "wordEditFailed": "Bu kelime değiştirilemedi"
+ "wordEditFailed": "Bu kelime değiştirilemedi",
+ "wordInsertFailed": "Bu kelime eklenemedi",
+ "wordRemoveFailed": "Bu kelime silinemedi"
},
"export": {
"canceled": "Dışa aktarım iptal edildi",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 68156849a..031cdb7d0 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "Geçerli döküm",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz: altyazılar buna uyar, video değişmez. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
"noClips": "Henüz klip yok",
"noTranscript": "Henüz döküm yok",
"whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
@@ -281,6 +281,9 @@
"correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
"revertWord": "\"{{original}}\" haline getir",
"blankedWord": "boşaltıldı",
+ "insertAria": "Yeni kelime",
+ "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
+ "removeInserted": "\"{{word}}\" kelimesini sil",
"noAudio": "Bu medyada ses parçası yok"
},
"captions": {
diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json
index 56b4f6a10..9d22463f3 100644
--- a/src/i18n/locales/vi/editor.json
+++ b/src/i18n/locales/vi/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Không thể lưu video đã xuất",
"failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}",
"previewCompositorUnavailable": "Không thể xem trước trên máy này",
- "wordEditFailed": "Không thể thay đổi từ này"
+ "wordEditFailed": "Không thể thay đổi từ này",
+ "wordInsertFailed": "Không thể thêm từ này",
+ "wordRemoveFailed": "Không thể xoá từ này"
},
"export": {
"canceled": "Đã hủy xuất",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index a440a5cd4..0b39eb91a 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "Bản chép lời hiện tại",
- "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản: phụ đề đi theo, video không đổi. Di chuột lên từ được đánh dấu để khôi phục.",
+ "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video. Di chuột lên từ được đánh dấu để hoàn tác.",
"noClips": "Chưa có clip nào",
"noTranscript": "Chưa có bản chép lời",
"whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
@@ -281,6 +281,9 @@
"correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
"revertWord": "Khôi phục \"{{original}}\"",
"blankedWord": "đã xoá",
+ "insertAria": "Từ mới",
+ "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
+ "removeInserted": "Xoá \"{{word}}\"",
"noAudio": "Media này không có bản âm thanh"
},
"captions": {
diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json
index 1d471b3c5..0f1c4f7bc 100644
--- a/src/i18n/locales/zh-CN/editor.json
+++ b/src/i18n/locales/zh-CN/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "保存导出的视频失败",
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}",
"previewCompositorUnavailable": "此设备无法使用预览",
- "wordEditFailed": "无法修改该词"
+ "wordEditFailed": "无法修改该词",
+ "wordInsertFailed": "无法添加该词",
+ "wordRemoveFailed": "无法删除该词"
},
"export": {
"canceled": "导出已取消",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 386df3c87..4acef69fc 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -264,7 +264,7 @@
},
"transcript": {
"title": "当前转录",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字:字幕随之更新,画面不变。将鼠标悬停在带标记的词上可还原。",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。将鼠标悬停在带标记的词上可撤销。",
"noClips": "暂无片段",
"noTranscript": "暂无转录",
"whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
@@ -281,6 +281,9 @@
"correctedWord": "已更正 — 转录原文为“{{original}}”",
"revertWord": "还原为“{{original}}”",
"blankedWord": "已清空",
+ "insertAria": "新词",
+ "insertedWord": "你添加的词 — 背后没有声音",
+ "removeInserted": "删除“{{word}}”",
"noAudio": "此媒体没有音频轨道"
},
"captions": {
diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json
index b772e47db..029b6ff12 100644
--- a/src/i18n/locales/zh-TW/editor.json
+++ b/src/i18n/locales/zh-TW/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
"failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}",
"previewCompositorUnavailable": "此裝置無法使用預覽",
- "wordEditFailed": "無法修改這個字"
+ "wordEditFailed": "無法修改這個字",
+ "wordInsertFailed": "無法加入這個字詞",
+ "wordRemoveFailed": "無法刪除這個字詞"
},
"export": {
"canceled": "匯出已取消",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 2f5c29ac6..12894e080 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -265,7 +265,7 @@
},
"transcript": {
"title": "目前的逐字稿",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字:字幕隨之更新,影片不變。將滑鼠移到有標記的字上即可還原。",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。將滑鼠移到有標記的字上即可復原。",
"noClips": "尚無片段",
"noTranscript": "尚無逐字稿",
"whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
@@ -282,6 +282,9 @@
"correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
"revertWord": "還原為「{{original}}」",
"blankedWord": "已清空",
+ "insertAria": "新字詞",
+ "insertedWord": "你加入的字詞 — 背後沒有聲音",
+ "removeInserted": "刪除「{{word}}」",
"noAudio": "此媒體沒有音訊軌道"
},
"captions": {
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 1bc1f5451..9e8ace067 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import { type AxcutTranscript, createEmptyDocument } from "../schema";
-import { carryOverWordEdits, setDocumentWordText, setWordText, withTranscript } from "./transcript";
+import {
+ carryOverWordEdits,
+ insertDocumentWord,
+ insertWord,
+ removeDocumentWords,
+ removeWord,
+ setDocumentWordText,
+ setWordText,
+ withTranscript,
+} from "./transcript";
function fixture(language = "en"): AxcutTranscript {
return {
@@ -483,3 +492,178 @@ describe("carryOverWordEdits", () => {
expect(carryOverWordEdits(null, next).transcript).toBe(next);
});
});
+
+// ─── Inserting a word nobody said ────────────────────────────────
+// The word carries no audio, so what it may occupy is the silence around it and nothing
+// else. These pin that boundary: never over a spoken word, never a duration invented out
+// of nothing when there is no pause to take.
+
+describe("insertWord", () => {
+ // "I"(1–2) "use"(2–3) "OpenScreen"(3–4), then a gap, then segment 2 at 5.
+ it("takes the silence after the word it follows, up to what its text needs", () => {
+ const result = insertWord(fixture(), "word_3", "after", "everywhere");
+ const inserted = result.words.find((w) => w.source === "synth");
+ expect(inserted?.startSec).toBe(4);
+ // 10 characters at 15/s = 0.67s, and the next word is a full second away.
+ expect(inserted?.endSec).toBeCloseTo(4 + 10 / 15, 5);
+ });
+
+ it("never runs over the word that comes next", () => {
+ // "use" ends at 3 and "OpenScreen" starts there: a long word gets no room at all.
+ const inserted = insertWord(fixture(), "word_2", "after", "a very long addition").words.find(
+ (w) => w.source === "synth",
+ );
+ expect(inserted).toMatchObject({ startSec: 3, endSec: 3 });
+ });
+
+ it("borrows backwards when it goes before the first word", () => {
+ const inserted = insertWord(fixture(), "word_1", "before", "Well").words.find(
+ (w) => w.source === "synth",
+ );
+ // "word_1" starts at 1, and nothing precedes it — the floor is the media's own start.
+ expect(inserted?.endSec).toBe(1);
+ expect(inserted?.startSec).toBeCloseTo(1 - 0.4, 5);
+ });
+
+ it("marks it synthesized, with an id no transcription run can reuse", () => {
+ const inserted = insertWord(fixture(), "word_3", "after", "indeed").words.find(
+ (w) => w.source === "synth",
+ );
+ expect(inserted).toMatchObject({ text: "indeed", source: "synth", segmentId: "segment_1" });
+ expect(inserted?.id).toMatch(/^synth_\d+$/);
+ expect(inserted).not.toHaveProperty("originalText");
+ });
+
+ it("numbers past the inserts already there", () => {
+ const once = insertWord(fixture(), "word_3", "after", "one");
+ const twice = insertWord(once, "word_3", "after", "two");
+ const ids = twice.words.filter((w) => w.source === "synth").map((w) => w.id);
+ expect(new Set(ids).size).toBe(2);
+ expect(ids).toContain("synth_2");
+ });
+
+ it("lands in the segment's reading order, and rebuilds its text", () => {
+ const transcript = fixture();
+ const result = insertWord(transcript, "word_2", "after", "really");
+ const segment = result.segments.find((seg) => seg.id === "segment_1");
+ expect(segment?.wordIds).toEqual(["word_1", "word_2", "synth_1", "word_3"]);
+ expect(segment?.text).toBe("I use really OpenScreen");
+ // The segment the insert did not land in is carried over untouched, not rebuilt.
+ expect(result.segments[1]).toBe(transcript.segments[1]);
+ });
+
+ it("sits beside its anchor in the words array, which is what orders a zero-length insert", () => {
+ const result = insertWord(fixture(), "word_2", "after", "really");
+ const ids = result.words.map((w) => w.id);
+ expect(ids.indexOf("synth_1")).toBe(ids.indexOf("word_2") + 1);
+ });
+
+ it("refuses empty text and unknown anchors", () => {
+ expect(() => insertWord(fixture(), "word_2", "after", " ")).toThrow(/empty/);
+ expect(() => insertWord(fixture(), "nope", "after", "x")).toThrow(/missing/);
+ });
+
+ it("keeps the input transcript untouched", () => {
+ const transcript = fixture();
+ const before = JSON.stringify(transcript);
+ insertWord(transcript, "word_2", "after", "really");
+ expect(JSON.stringify(transcript)).toBe(before);
+ });
+});
+
+describe("removeWord", () => {
+ const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
+
+ it("takes the word out of the array, the segment, and its text", () => {
+ const result = removeWord(withInsert(), "synth_1");
+ expect(result.words.some((w) => w.id === "synth_1")).toBe(false);
+ const segment = result.segments.find((seg) => seg.id === "segment_1");
+ expect(segment?.wordIds).toEqual(["word_1", "word_2", "word_3"]);
+ expect(segment?.text).toBe("I use OpenScreen");
+ });
+
+ // Deleting a transcribed word would leave the film saying something the transcript
+ // denies. The operation for making a spoken word go away is a trim.
+ it("refuses a word that was actually spoken", () => {
+ expect(() => removeWord(fixture(), "word_2")).toThrow(/Refusing to remove transcribed word/);
+ });
+
+ it("refuses a word that is not there", () => {
+ expect(() => removeWord(fixture(), "nope")).toThrow(/missing/);
+ });
+});
+
+describe("insertDocumentWord / removeDocumentWords", () => {
+ it("writes both the per-asset transcript and the legacy mirror", () => {
+ const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
+ expect(result.transcript?.words.some((w) => w.id === "synth_1")).toBe(true);
+ expect(result.transcript).toBe(result.transcripts.find((t) => t.assetId === "asset_1"));
+ });
+
+ // One save for the whole set: a Backspace over three inserted words must be one Ctrl+Z.
+ it("removes several inserted words in a single document", () => {
+ let doc = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "one");
+ doc = insertDocumentWord(doc, "asset_1", "word_3", "after", "two");
+ const result = removeDocumentWords(doc, "asset_1", ["synth_1", "synth_2"]);
+ expect(result.transcripts[0].words.some((w) => w.source === "synth")).toBe(false);
+ });
+
+ it("rejects an asset with no transcript", () => {
+ expect(() => insertDocumentWord(makeDoc(), "nope", "word_2", "after", "x")).toThrow(
+ /no transcript/,
+ );
+ });
+});
+
+describe("carryOverWordEdits with inserted words", () => {
+ const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
+
+ it("puts an insert back after whatever the new run now ends last before it", () => {
+ // The insert sits at 3s. The new transcript says "I"(1–2) "used"(2–3) "it"(3.5–4).
+ const next = retranscribed([
+ ["n1", "I", 1, 2],
+ ["n2", "used", 2, 3],
+ ["n3", "it", 3.5, 4],
+ ]);
+ const result = carryOverWordEdits(withInsert(), next);
+ expect(result).toMatchObject({ carried: 1, dropped: 0 });
+ const ids = result.transcript.words.map((w) => w.id);
+ expect(ids.indexOf("synth_1")).toBe(ids.indexOf("n2") + 1);
+ expect(result.transcript.words.find((w) => w.id === "synth_1")).toMatchObject({
+ text: "really",
+ source: "synth",
+ });
+ });
+
+ it("puts it at the head when the new run has nothing before it", () => {
+ const carried = carryOverWordEdits(
+ insertWord(fixture(), "word_1", "before", "Well"),
+ retranscribed([["n1", "I", 1, 2]]),
+ );
+ expect(carried.carried).toBe(1);
+ expect(carried.transcript.words[0].text).toBe("Well");
+ });
+
+ it("counts an insert it could not place, rather than losing it quietly", () => {
+ const empty: AxcutTranscript = { assetId: "asset_1", language: "en", segments: [], words: [] };
+ expect(carryOverWordEdits(withInsert(), empty)).toMatchObject({ carried: 0, dropped: 1 });
+ });
+
+ it("carries corrections and inserts together", () => {
+ const both = insertWord(
+ setWordText(fixture(), "word_3", "OpenScreenApp"),
+ "word_2",
+ "after",
+ "really",
+ );
+ const next = retranscribed([
+ ["n1", "I", 1, 2],
+ ["n2", "use", 2, 3],
+ ["n3", "OpenScreen", 3, 4],
+ ]);
+ const result = carryOverWordEdits(both, next);
+ expect(result).toMatchObject({ carried: 2, dropped: 0 });
+ expect(result.transcript.words.find((w) => w.id === "n3")?.text).toBe("OpenScreenApp");
+ expect(result.transcript.words.some((w) => w.text === "really")).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 6728575b1..dec291474 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -147,12 +147,200 @@ export function setDocumentWordText(
return withTranscript(document, setWordText(transcript, wordId, text));
}
+/** Where a new word goes relative to the word the caret was resting on. */
+export type InsertSide = "before" | "after";
+
+/**
+ * How long an inserted word needs to be readable on screen. Subtitle practice is roughly
+ * fifteen characters a second, with a floor so a one-letter word is not a single frame.
+ * It is only ever a REQUEST — `insertWord` gives the word whatever silence is actually
+ * free, and no more.
+ */
+function readingSeconds(text: string): number {
+ return Math.max(0.4, text.trim().length / 15);
+}
+
+/** `synth_N`, numbered past every id already in the transcript.
+ *
+ * The prefix buys uniqueness, not meaning: a transcription run regenerates `word_N` from
+ * 1, so a synthesized word holding one of those ids would be overwritten by the next run.
+ * What the word IS lives in `source`, which is what every reader checks. */
+function nextSynthWordId(transcript: AxcutTranscript): string {
+ let highest = 0;
+ for (const word of transcript.words) {
+ const match = /^synth_(\d+)$/.exec(word.id);
+ if (match) highest = Math.max(highest, Number(match[1]));
+ }
+ return `synth_${highest + 1}`;
+}
+
+/**
+ * Insert a word that no one said.
+ *
+ * It carries no audio, so it takes the SILENCE it is dropped into and nothing else: from
+ * the word it follows up to what its text needs to be read, and never past the word that
+ * comes next. Dropped between two words that run straight into each other it has no
+ * duration at all and simply rides their caption line — which is where it reads correctly
+ * anyway, since there is no pause on screen to fill.
+ *
+ * That is the whole of what an inserted word can do today: it reaches the captions and
+ * stops there. When a voice can be synthesized for it, `source: "synth"` is what marks the
+ * words that need speaking, and the span computed here is the slot that audio has to fit.
+ */
+export function insertWord(
+ transcript: AxcutTranscript,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutTranscript {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) {
+ throw new Error("Cannot insert an empty word");
+ }
+ const anchorIndex = transcript.words.findIndex((word) => word.id === anchorWordId);
+ if (anchorIndex < 0) {
+ throw new Error(`Cannot insert next to missing transcript word "${anchorWordId}"`);
+ }
+ const anchor = transcript.words[anchorIndex];
+ const segment = transcript.segments.find((seg) => seg.id === anchor.segmentId);
+ if (!segment) {
+ throw new Error(
+ `Transcript word "${anchorWordId}" references missing segment "${anchor.segmentId}"`,
+ );
+ }
+ const anchorSlot = segment.wordIds.indexOf(anchorWordId);
+ if (anchorSlot < 0) {
+ throw new Error(`Segment "${segment.id}" does not reference anchor word "${anchorWordId}"`);
+ }
+
+ const wanted = readingSeconds(trimmed);
+ let startSec: number;
+ let endSec: number;
+ if (side === "after") {
+ startSec = anchor.endSec;
+ // The next word IN TIME, which is not necessarily the next one in the array — the
+ // array is insertion order, and only time decides what the new word may overlap.
+ const nextStart = transcript.words
+ .filter((word) => word.startSec >= startSec && word.id !== anchorWordId)
+ .reduce(
+ (soonest, word) => (soonest === null ? word.startSec : Math.min(soonest, word.startSec)),
+ null,
+ );
+ endSec = nextStart === null ? startSec + wanted : Math.min(startSec + wanted, nextStart);
+ } else {
+ endSec = anchor.startSec;
+ const previousEnd = transcript.words
+ .filter((word) => word.endSec <= endSec && word.id !== anchorWordId)
+ .reduce(
+ (latest, word) => (latest === null ? word.endSec : Math.max(latest, word.endSec)),
+ null,
+ );
+ const floor = previousEnd === null ? 0 : previousEnd;
+ startSec = Math.max(floor, endSec - wanted);
+ }
+
+ const inserted: AxcutWord = {
+ id: nextSynthWordId(transcript),
+ segmentId: segment.id,
+ startSec,
+ endSec: Math.max(startSec, endSec),
+ text: trimmed,
+ source: "synth",
+ };
+
+ // Position in `words` matters as well as the timings: a zero-length insert shares its
+ // start with the word it sits against, and the reading order of that tie is the array
+ // order (see `withSilenceGaps`).
+ const at = side === "after" ? anchorIndex + 1 : anchorIndex;
+ const words = [...transcript.words.slice(0, at), inserted, ...transcript.words.slice(at)];
+ const slot = side === "after" ? anchorSlot + 1 : anchorSlot;
+ const wordIds = [...segment.wordIds.slice(0, slot), inserted.id, ...segment.wordIds.slice(slot)];
+ const byId = new Map(words.map((word) => [word.id, word]));
+ const segmentText = joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? ""));
+
+ return {
+ ...transcript,
+ words,
+ segments: transcript.segments.map((seg) =>
+ seg.id === segment.id ? { ...seg, wordIds, text: segmentText } : seg,
+ ),
+ };
+}
+
+/**
+ * Delete an inserted word.
+ *
+ * Only a synthesized one: a transcribed word is the label on a piece of audio, and the
+ * operation for making that go away is a trim, which removes the sound with it. Deleting
+ * the label alone would leave the film saying a word the transcript denies.
+ */
+export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTranscript {
+ const target = transcript.words.find((word) => word.id === wordId);
+ if (!target) {
+ throw new Error(`Cannot remove missing transcript word "${wordId}"`);
+ }
+ if (target.source !== "synth") {
+ throw new Error(
+ `Refusing to remove transcribed word "${wordId}": cut it with a trim, or blank its text`,
+ );
+ }
+ const words = transcript.words.filter((word) => word.id !== wordId);
+ const byId = new Map(words.map((word) => [word.id, word]));
+ return {
+ ...transcript,
+ words,
+ segments: transcript.segments.map((segment) => {
+ if (!segment.wordIds.includes(wordId)) return segment;
+ const wordIds = segment.wordIds.filter((id) => id !== wordId);
+ return {
+ ...segment,
+ wordIds,
+ text: joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? "")),
+ };
+ }),
+ };
+}
+
+/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
+ * reason {@link setDocumentWordText} does. */
+export function insertDocumentWord(
+ document: AxcutDocument,
+ assetId: string,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ if (!transcript) {
+ throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
+ }
+ return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+}
+
+/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
+ * over several inserted words has to be ONE write, or undoing it takes as many presses as
+ * there were words. */
+export function removeDocumentWords(
+ document: AxcutDocument,
+ assetId: string,
+ wordIds: readonly string[],
+): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ if (!transcript) {
+ throw new Error(`Cannot remove a word from asset "${assetId}": it has no transcript`);
+ }
+ return withTranscript(
+ document,
+ wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
+ );
+}
+
/** What {@link carryOverWordEdits} managed to save from the previous transcript. */
export interface WordEditCarryOver {
transcript: AxcutTranscript;
- /** Corrections re-applied to the new transcript. */
+ /** Corrections and insertions re-applied to the new transcript. */
carried: number;
- /** Corrections the new transcript left no place for. These are lost. */
+ /** Edits the new transcript left no place for. These are lost. */
dropped: number;
}
@@ -176,7 +364,12 @@ export function carryOverWordEdits(
const edits = (previous?.words ?? []).filter(
(word) => word.source === "user" && word.originalText !== undefined,
);
- if (edits.length === 0) return { transcript: next, carried: 0, dropped: 0 };
+ const inserts = (previous?.words ?? [])
+ .filter((word) => word.source === "synth")
+ .sort((a, b) => a.startSec - b.startSec);
+ if (edits.length === 0 && inserts.length === 0) {
+ return { transcript: next, carried: 0, dropped: 0 };
+ }
// Candidates are read from `next` throughout, never from the transcript being
// built up: a word already rewritten by an earlier correction no longer carries
@@ -198,5 +391,24 @@ export function carryOverWordEdits(
transcript = setWordText(transcript, match.id, edit.text);
carried += 1;
}
- return { transcript, carried, dropped: edits.length - carried };
+
+ // An inserted word has no original text to recognise, so time is what places it: the
+ // audio did not change between runs, only how it was heard. Each one goes back after
+ // whatever the new transcript now ends last before it — including a word re-inserted a
+ // moment ago, which is what keeps two inserts at the same spot in their old order.
+ for (const insert of inserts) {
+ const before = transcript.words
+ .filter((word) => word.endSec <= insert.startSec)
+ .reduce(
+ (latest, word) => (latest === null || word.endSec >= latest.endSec ? word : latest),
+ null,
+ );
+ const head = transcript.words[0];
+ const target = before ?? head ?? null;
+ if (!target) continue;
+ transcript = insertWord(transcript, target.id, before ? "after" : "before", insert.text);
+ carried += 1;
+ }
+
+ return { transcript, carried, dropped: edits.length + inserts.length - carried };
}
diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts
index 1d3e928f8..204b92ea9 100644
--- a/src/lib/ai-edition/store/documentWriteAudit.test.ts
+++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts
@@ -131,6 +131,10 @@ const DECLARED: WritePath[] = [
w("src/components/ai-edition/NewEditorShell.tsx", "handleRenameProject", "save", "gesture"),
// Ctrl+S / File > Save.
w("src/components/ai-edition/NewEditorShell.tsx", "handleSave", "save", "gesture"),
+ // A word typed into the transcript pane, and the deletion of one. Both are the user's
+ // own edits to the transcript; neither touches the timeline.
+ w("src/components/ai-edition/NewEditorShell.tsx", "handleInsertWord", "save", "gesture"),
+ w("src/components/ai-edition/NewEditorShell.tsx", "handleRemoveWords", "save", "gesture"),
// A word rewritten in the transcript pane. A correction, not a cut: it writes
// `transcript.words[].text` and leaves the timeline alone.
w("src/components/ai-edition/NewEditorShell.tsx", "handleSetWordText", "save", "gesture"),
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 1a35e9e61..0cd068056 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -25,6 +25,13 @@ export function isSilenceWord(word: AxcutWord): boolean {
return word.id.startsWith("silence_");
}
+/** True for a word the user typed in, which no one said and nothing in the media carries.
+ * Keyed on `source`, never on the id: the id shape is only there to stop a transcription
+ * run from reusing it. */
+export function isInsertedWord(word: AxcutWord): boolean {
+ return word.source === "synth";
+}
+
/**
* Insert a synthetic `[silence]` pseudo-word into every gap of at least
* `SILENCE_THRESHOLD_SEC` between consecutive words (and at the clip's
@@ -38,7 +45,14 @@ function withSilenceGaps(
clipStartSec: number,
clipEndSec: number | undefined,
): AxcutWord[] {
- const sorted = [...words].sort((a, b) => a.startSec - b.startSec);
+ // Sorted by time, ties broken by the order the transcript stores them in. The tie is
+ // not hypothetical: a word inserted between two contiguous words has no duration and
+ // therefore shares its start with the one it sits against, and only the array says
+ // which of the two the reader sees first.
+ const order = new Map(words.map((word, index) => [word.id, index]));
+ const sorted = [...words].sort(
+ (a, b) => a.startSec - b.startSec || (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0),
+ );
const result: AxcutWord[] = [];
let cursor = clipStartSec;
let n = 0;
@@ -114,7 +128,15 @@ export interface ClipSection {
}
function wordsInRange(transcript: AxcutTranscript, startSec: number, endSec: number): AxcutWord[] {
- return transcript.words.filter((w) => w.endSec > startSec && w.startSec < endSec);
+ return transcript.words.filter((w) =>
+ // An inserted word dropped between two words that run into each other has NO
+ // duration, and an overlap test excludes a point at either edge of the range —
+ // which silently lost every word inserted at the very start of a clip. A word with
+ // no span is in the clip when its moment is.
+ w.endSec > w.startSec
+ ? w.endSec > startSec && w.startSec < endSec
+ : w.startSec >= startSec && w.startSec < endSec,
+ );
}
/** Find the trim range covering this word's center (returns the deepest match). */
From 188f3650ef773bace1573cd2a7837b06698f45d6 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 22:19:19 +0200
Subject: [PATCH 57/84] feat(editor): an added word buys itself time, and the
film holds its frame
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adding a word only borrowed whatever silence happened to be free, so a word
dropped between two words that run into each other got no time at all. It now
creates the time it needs: the clip splits at the word's edge and a held-frame
clip carries the deficit, so the timeline grows and everything downstream
shifts. Screen and webcam freeze together — both are derived from the one asset
source clock the freeze stops advancing — and the decoder is paused for the
duration rather than free-running past the held frame into what comes after.
That created span is the slot a synthesized voice will speak in.
The gesture is gated to dev builds until there is a voice to put in it. A silent
freeze frame is not a feature, and captions are not always on, so a release
build offers no way to add a word at all — the pane does not even advertise it.
Drop the gate in `openInsertion` when TTS lands.
Three things the split broke, found by running it rather than by reading it:
The captions went DARK over the pause. A line straddling the split was
ventilated once per half, each half carrying the whole line, so the caption
played, blinked out for exactly the pause the word exists for, then played again
from the top. A line covering the held moment now covers the pause too, and
spans that meet on the ruler coalesce — measured before and after: two cues of
"Bonjour on va parler de vraiment Kubernetes" became one, 0→3.9s.
The word rendered TWICE in the pane. The freeze claims it and the half that
starts at the same moment matched it as well. The freeze owns it: it is the
section the playhead is inside while the pause plays.
And one word turned one recording into three headed blocks, each announcing the
same filename over a sliver of timecode ("Clip 2 · 0:02.5—0:02.5"). Sections
that continue the one before — same media, meeting on both clocks — now flow
inline under a single header spanning the whole run. Two clips over one media
still get a header each, which is the case the header exists for.
The pane also says what its gestures are now: double-click corrects, Backspace
cuts, and (in dev) typing between two words adds one. They were invisible until
tried.
Tests cover the freeze end to end: the document split, playback keeping the
created time, the source clock held still through it, the pane showing the word
once, the header run, and the caption playing once straight through.
---
src/components/ai-edition/RightPanes.tsx | 253 +++++++++++-------
.../TranscriptPane.sharedMedia.test.tsx | 82 ++++++
.../TranscriptPane.wordEdit.test.tsx | 9 +
.../TranscriptPane.wordInsert.test.tsx | 17 ++
src/i18n/locales/ar/settings.json | 5 +-
src/i18n/locales/en/settings.json | 5 +-
src/i18n/locales/es/settings.json | 5 +-
src/i18n/locales/fr/settings.json | 5 +-
src/i18n/locales/it/settings.json | 5 +-
src/i18n/locales/ja-JP/settings.json | 5 +-
src/i18n/locales/ko-KR/settings.json | 5 +-
src/i18n/locales/pt-BR/settings.json | 5 +-
src/i18n/locales/ru/settings.json | 5 +-
src/i18n/locales/tr/settings.json | 5 +-
src/i18n/locales/vi/settings.json | 5 +-
src/i18n/locales/zh-CN/settings.json | 5 +-
src/i18n/locales/zh-TW/settings.json | 5 +-
src/lib/ai-edition/captions/captions.test.ts | 115 ++++++++
src/lib/ai-edition/captions/cues.ts | 30 ++-
src/lib/ai-edition/document/timeline.ts | 72 ++++-
.../ai-edition/document/transcript.test.ts | 59 ++++
src/lib/ai-edition/document/transcript.ts | 34 ++-
src/lib/ai-edition/schema/index.ts | 8 +
.../timeline/aggregated-transcript.test.ts | 91 +++++++
.../timeline/aggregated-transcript.ts | 45 +++-
.../ai-edition/timeline/timelineMap.test.ts | 61 +++++
src/lib/ai-edition/timeline/timelineMap.ts | 22 +-
src/native/useNativePlaybackSync.ts | 28 +-
28 files changed, 878 insertions(+), 113 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index d2bed7c31..e1f944986 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -782,13 +782,17 @@ export function TranscriptPane({
// engine, nothing attempted) leaves the button worth pressing.
const silentMedia = blocked?.reason === "no-audio";
+ // The insert gesture is dev-only until TTS (see openInsertion), so the copy follows
+ // the same gate: release builds must not advertise a dead gesture.
+ const helpText =
+ ts("transcript.help") + (import.meta.env.DEV ? ` ${ts("transcript.helpInsert")}` : "");
+ const editingHint = ts(
+ import.meta.env.DEV ? "transcript.editingHintDev" : "transcript.editingHint",
+ );
+
if (clips.length === 0 || !hasAnyTranscript) {
return (
- }
- helpText={ts("transcript.help")}
- >
+ } helpText={helpText}>
-
-
{ts("transcript.title")}
-
-
- {sections.map((section, idx) => (
-
- ))}
-
-
+ } helpText={helpText}>
+ {/* The gestures are invisible until tried: nothing on a plain word stream says
+ * that double-click corrects and Backspace cuts. One muted line names them; the
+ * ? popover above carries the long version (amber inserts, hover-bin restore). */}
+
+ {editingHint}
+
+ {sections.map((section, idx) => (
+
+ ))}
+
);
}
+/**
+ * Whether this section merely continues the previous one: same media, and the previous
+ * clip ends exactly where this one starts, on the source clock and on the ruler alike.
+ *
+ * Inserting a word SPLITS the clip it lands in — [before · freeze · after] — so one word
+ * turned one recording into three headed blocks, each announcing the same filename and a
+ * sliver of timecode. They are one continuous read and now render as one: the header
+ * appears on the first section of the run, the rest flow straight on from it. Two clips
+ * over the same media that are NOT contiguous still get a header each, which is the case
+ * the header exists for.
+ */
+function continuesPreviousSection(
+ previous: ClipSection | undefined,
+ section: ClipSection,
+): boolean {
+ if (!previous || previous.clip.assetId !== section.clip.assetId) return false;
+ const EPSILON_SEC = 0.001;
+ const sourceMeets =
+ Math.abs((previous.clip.sourceEndSec ?? Number.NaN) - section.clip.sourceStartSec) <
+ EPSILON_SEC;
+ const rulerMeets =
+ Math.abs(previous.clip.timelineEndSec - section.clip.timelineStartSec) < EPSILON_SEC;
+ return sourceMeets && rulerMeets;
+}
+
+/** The source range the whole run covers, for the one header that fronts it. */
+function runLabelFor(sections: ClipSection[], index: number): { start: number; end: number } {
+ let last = index;
+ while (
+ last + 1 < sections.length &&
+ continuesPreviousSection(sections[last], sections[last + 1])
+ ) {
+ last += 1;
+ }
+ return {
+ start: sections[index].clip.sourceStartSec,
+ end: sections[last].clip.sourceEndSec ?? sections[last].clip.sourceStartSec,
+ };
+}
+
// One contentEditable block per clip — header (vignette + filename +
// range) and a flowing word stream. The stream contains every transcript
// word inside the clip's source range, color-coded by whether the word
@@ -872,6 +926,8 @@ export function TranscriptPane({
const TranscriptClipBlock = memo(function TranscriptClipBlock({
index,
section,
+ continuation,
+ runLabel,
busy,
cueWordId,
onSeek,
@@ -883,6 +939,10 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
}: {
index: number;
section: ClipSection;
+ /** This section reads straight on from the one above — no header, no gap. */
+ continuation: boolean;
+ /** Source range of the whole contiguous run this section fronts. */
+ runLabel: { start: number; end: number };
busy: boolean;
cueWordId: string | null;
onSeek: (sec: number) => void;
@@ -901,10 +961,9 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
[clip.assetId, clip.id],
);
const filename = asset?.label ?? clip.assetId;
- const sourceRangeLabel =
- clip.sourceEndSec !== undefined
- ? `${formatMs(clip.sourceStartSec * 1000)}—${formatMs(clip.sourceEndSec * 1000)}`
- : `${formatMs(clip.sourceStartSec * 1000)}—`;
+ // The run's range, not this clip's: a split clip's own sliver would read as a
+ // 0:02.5—0:02.5 recording.
+ const sourceRangeLabel = `${formatMs(runLabel.start * 1000)}—${formatMs(runLabel.end * 1000)}`;
const editorRef = useRef(null);
const pendingCaretWordIdRef = useRef(null);
@@ -1059,6 +1118,11 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
const openInsertion = useCallback(
(seed: string) => {
+ // ponytail: word insertion ships dev-only until a voice can be synthesized for
+ // the word — without one it only borrows free silence, and once it creates
+ // timeline time (the pause gesture) it is a silent freeze frame. Drop this gate
+ // when TTS lands.
+ if (!import.meta.env.DEV) return;
if (busy || !seed.trim()) return;
const editor = editorRef.current;
const selection = globalThis.getSelection();
@@ -1176,79 +1240,85 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
return (
-
+ {continuation ? null : (
0 ? 16 : 0,
+ marginBottom: 6,
}}
>
- {index + 1}
-
-
-
- {filename}
-
-
- {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
-
-
- {/* A block whose transcript is being regenerated is read-only — say it,
- rather than letting the word stream look live and drop the edits. */}
- {busy ? (
-
- {ts("transcript.transcribing")}
+ {index + 1}
- ) : null}
-
+
+
+ {filename}
+
+
+ {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
+
+
+ {/* A block whose transcript is being regenerated is read-only — say it,
+ rather than letting the word stream look live and drop the edits. */}
+ {busy ? (
+
+
+ {ts("transcript.transcribing")}
+
+ ) : null}
+
+ )}
{words.length === 0 ? (
{
).toEqual(["clip_2:w2"]);
});
});
+
+// ─── Headers on a clip an inserted word split ────────────────────
+// Inserting a word splits the clip it lands in — [before · freeze · after] — so one word
+// turned one recording into three blocks, each announcing the same filename and a sliver
+// of timecode ("Clip 2 · 0:02.5—0:02.5"). They are one continuous read: one header, and
+// the words flow straight on. The two-copies case above must keep its two headers, which
+// is what tells the split apart from a media genuinely placed twice.
+
+const SPLIT_CLIPS: AxcutClip[] = [
+ {
+ id: "clip_1_fzA",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "clip_1_fz",
+ assetId: "asset_1",
+ sourceStartSec: 6,
+ sourceEndSec: 6,
+ timelineStartSec: 6,
+ timelineEndSec: 6.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "Inserted word — held frame",
+ frozenSec: 0.5,
+ },
+ {
+ id: "clip_1_fzB",
+ assetId: "asset_1",
+ sourceStartSec: 6,
+ sourceEndSec: 12,
+ timelineStartSec: 6.5,
+ timelineEndSec: 12.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+function renderClips(clips: AxcutClip[]) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("clip headers", () => {
+ it("fronts a split clip with one header covering the whole run", () => {
+ const view = renderClips(SPLIT_CLIPS);
+ const headers = view.container.querySelectorAll("[data-clip-header]");
+ expect(headers).toHaveLength(1);
+ // The run's range, not the first piece's — and not the freeze's 0:06.0—0:06.0.
+ expect(headers[0].textContent).toContain("0:00.0—0:12.0");
+ });
+
+ it("still gives two headers to one media placed twice", () => {
+ const view = renderClips(CLIPS);
+ expect(view.container.querySelectorAll("[data-clip-header]")).toHaveLength(2);
+ });
+});
diff --git a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
index 3c8fd0b97..74d2c021f 100644
--- a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
@@ -84,6 +84,15 @@ function renderPane(words?: AxcutWord[], busyAssetIds: string[] = []) {
afterEach(cleanup);
+describe("telling the user the gestures exist", () => {
+ it("shows the editing hint line and the ? help when a transcript is on screen", () => {
+ // The gestures are invisible until tried — the pane must name them itself.
+ const view = renderPane();
+ expect(view.getByText(/Double-click a word to correct it/)).toBeInTheDocument();
+ expect(view.getByRole("button", { name: "Help" })).toBeInTheDocument();
+ });
+});
+
describe("correcting a word", () => {
it("opens an editing field on the word a double-click lands on", () => {
const view = renderPane();
diff --git a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
index 7af0cc9db..caed55d61 100644
--- a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
@@ -134,6 +134,23 @@ describe("typing between two words", () => {
expect(view.field()).toHaveValue("v");
});
+ it("stays inert outside dev builds — the gesture waits for TTS", () => {
+ // An inserted word with no voice only borrows free silence, so the gesture ships
+ // dev-only (see openInsertion). Release builds must drop the keystroke silently,
+ // the same way they did before the feature existed.
+ vi.stubEnv("DEV", false);
+ try {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ expect(view.field()).toBeNull();
+ expect(view.onInsertWord).not.toHaveBeenCalled();
+ expect(view.editor.textContent).not.toContain("v ");
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
it("never writes the typed text into the block itself", () => {
// The whole reason inserts were blocked: a run of text with no word id behind it
// desynchronises the DOM from `words`.
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index 21b37731d..5520c0b50 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "النص الحالي",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
+ "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "helpInsert": "اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو.",
"noClips": "لا توجد مقاطع بعد",
"noTranscript": "لا يوجد نص بعد",
"whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 0f79df528..110edcdf4 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -270,7 +270,10 @@
},
"transcript": {
"title": "Current transcription",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Type between two words to add one, in amber: it reaches the captions and leaves the film alone. Hover a marked word to undo it.",
+ "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
+ "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
+ "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
+ "helpInsert": "Type between two words to add one, in amber: it reaches the captions and leaves the film alone.",
"noClips": "No clips yet",
"noTranscript": "No transcript yet",
"whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index ba4f052d1..595664612 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "Transcripción actual",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo. Pasa el cursor sobre una palabra marcada para deshacer.",
+ "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
+ "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
+ "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
+ "helpInsert": "Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo.",
"noClips": "Aún no hay clips",
"noTranscript": "Aún no hay transcripción",
"whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 05f6bff42..196fcf231 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "Transcription actuelle",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film. Survolez un mot marqué pour annuler.",
+ "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
+ "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
+ "helpInsert": "Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film.",
"noClips": "Aucun clip pour l'instant",
"noTranscript": "Aucune transcription pour l'instant",
"whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 1777770b4..3c7b2bdfe 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "Trascrizione corrente",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video. Passa sopra una parola contrassegnata per annullare.",
+ "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
+ "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
+ "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
+ "helpInsert": "Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video.",
"noClips": "Ancora nessun clip",
"noTranscript": "Ancora nessuna trascrizione",
"whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 201bc054c..b7fbec518 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "現在の文字起こし",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
+ "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "helpInsert": "単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。",
"noClips": "クリップがまだありません",
"noTranscript": "文字起こしがまだありません",
"whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index f598c862f..1d36f0fff 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "현재 전사",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 두 단어 사이에 입력하면 호박색 단어가 추가됩니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
+ "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "helpInsert": "두 단어 사이에 입력하면 호박색 단어가 추가됩니다.",
"noClips": "아직 클립이 없습니다",
"noTranscript": "아직 전사가 없습니다",
"whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 40f9a8e99..00f91bcfc 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "Transcrição atual",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo. Passe o mouse sobre uma palavra marcada para desfazer.",
+ "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
+ "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
+ "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
+ "helpInsert": "Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo.",
"noClips": "Nenhum clipe ainda",
"noTranscript": "Nenhuma transcrição ainda",
"whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index f39138626..204583c43 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "Текущая расшифровка",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
+ "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "helpInsert": "Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео.",
"noClips": "Клипов пока нет",
"noTranscript": "Расшифровки пока нет",
"whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 031cdb7d0..6cf39685b 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "Geçerli döküm",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
+ "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
+ "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "helpInsert": "İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz.",
"noClips": "Henüz klip yok",
"noTranscript": "Henüz döküm yok",
"whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 0b39eb91a..6bcb6bf24 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "Bản chép lời hiện tại",
- "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video. Di chuột lên từ được đánh dấu để hoàn tác.",
+ "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
+ "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
+ "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
+ "helpInsert": "Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video.",
"noClips": "Chưa có clip nào",
"noTranscript": "Chưa có bản chép lời",
"whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 4acef69fc..8d7b78eb6 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -264,7 +264,10 @@
},
"transcript": {
"title": "当前转录",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。将鼠标悬停在带标记的词上可撤销。",
+ "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
+ "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
+ "helpInsert": "在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。",
"noClips": "暂无片段",
"noTranscript": "暂无转录",
"whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 12894e080..5dcd32110 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -265,7 +265,10 @@
},
"transcript": {
"title": "目前的逐字稿",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。將滑鼠移到有標記的字上即可復原。",
+ "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
+ "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
+ "helpInsert": "在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。",
"noClips": "尚無片段",
"noTranscript": "尚無逐字稿",
"whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index ce3d4a961..4d86af1ef 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -650,3 +650,118 @@ describe("translated caption layout", () => {
);
});
});
+
+// ─── Captions across an inserted word's pause ────────────────────
+// Inserting a word SPLITS the clip it lands in — [before · freeze · after] — and a caption
+// line straddling the split was ventilated once per half, each half carrying the whole
+// line. On screen: the caption played, blinked out for the pause, then played again from
+// the top — dark over the one moment the pause exists for.
+
+describe("a caption line over a freeze", () => {
+ /** `clip-1` split at 1.2s, with 0.5s of held frame carrying an inserted word. */
+ function splitDoc(): AxcutDocument {
+ const withInsert = transcript();
+ withInsert.segments[0] = {
+ ...withInsert.segments[0],
+ text: "hello there really friend",
+ wordIds: ["w1", "w2", "synth_1", "w3"],
+ };
+ withInsert.words = [
+ ...withInsert.words,
+ {
+ id: "synth_1",
+ segmentId: "seg_1",
+ startSec: 1.2,
+ endSec: 1.2,
+ text: "really",
+ source: "synth",
+ },
+ ];
+ return doc({
+ transcripts: [withInsert],
+ timeline: {
+ ...doc().timeline,
+ clips: [
+ {
+ id: "clip-1_fzA",
+ assetId: "asset-1",
+ sourceStartSec: 0,
+ sourceEndSec: 1.2,
+ timelineStartSec: 0,
+ timelineEndSec: 1.2,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "clip-1_fz",
+ assetId: "asset-1",
+ sourceStartSec: 1.2,
+ sourceEndSec: 1.2,
+ timelineStartSec: 1.2,
+ timelineEndSec: 1.7,
+ wordRefs: [],
+ origin: "user",
+ reason: "Inserted word — held frame",
+ frozenSec: 0.5,
+ },
+ {
+ id: "clip-1_fzB",
+ assetId: "asset-1",
+ sourceStartSec: 1.2,
+ sourceEndSec: 10,
+ timelineStartSec: 1.7,
+ timelineEndSec: 10.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ },
+ });
+ }
+
+ it("plays the line once, straight through the pause", () => {
+ const cues = deriveCaptionCues(splitDoc(), ON, {});
+ const first = cues.filter((cue) => cue.text.includes("really"));
+ expect(first).toHaveLength(1);
+ // It starts before the freeze and is still up after it — no dark stretch.
+ expect(first[0].startMs).toBeLessThan(1200);
+ expect(first[0].endMs).toBeGreaterThan(1700);
+ });
+
+ it("still plays a line twice when one media is genuinely placed twice", () => {
+ // The spans do not touch on the ruler there, so the coalescing must leave them apart.
+ const twice = doc({
+ timeline: {
+ ...doc().timeline,
+ clips: [
+ {
+ id: "clip-1",
+ assetId: "asset-1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "clip-2",
+ assetId: "asset-1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 20,
+ timelineEndSec: 30,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ },
+ });
+ const cues = deriveCaptionCues(twice, ON, {});
+ expect(cues.filter((cue) => cue.text.includes("hello"))).toHaveLength(2);
+ });
+});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index c3287ebcb..8154788a1 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -161,6 +161,17 @@ export function sourceSpanToTimelineSpans(
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
if (clip.assetId !== assetId) continue;
+ // A FREEZE clip holds one source moment for created timeline time — an inserted
+ // word's pause. Its source window is that single point, so the overlap test below
+ // can never match it, and the line the pause exists for went DARK for its whole
+ // duration. A line covering the held moment covers the pause too.
+ if (clip.frozenSec !== undefined) {
+ const held = clip.sourceStartSec;
+ if (held >= startSec && held < endSec) {
+ out.push({ startSec: clip.timelineStartSec, endSec: clip.timelineEndSec });
+ }
+ continue;
+ }
const clipSourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
@@ -170,7 +181,24 @@ export function sourceSpanToTimelineSpans(
endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
});
}
- return out;
+
+ // Coalesce what meets on the ruler. Splitting a clip to make room for an inserted word
+ // leaves the line straddling [before · freeze · after]: three spans back to back, each
+ // carrying the WHOLE line's text, so the caption played, blinked out over the pause,
+ // then played again from the top. They are one appearance. A line genuinely played
+ // twice — two clips over one media — does not touch on the ruler and stays two.
+ const EPSILON_SEC = 0.001;
+ const ordered = [...out].sort((a, b) => a.startSec - b.startSec);
+ const merged: Array<{ startSec: number; endSec: number }> = [];
+ for (const span of ordered) {
+ const last = merged[merged.length - 1];
+ if (last && span.startSec <= last.endSec + EPSILON_SEC) {
+ last.endSec = Math.max(last.endSec, span.endSec);
+ continue;
+ }
+ merged.push({ ...span });
+ }
+ return merged;
}
/**
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 232bcb6ee..fa41c2bc6 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -127,6 +127,73 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
});
}
+/**
+ * Split the clip covering `atSec` (source time, `assetId`'s media) into
+ * [before · FREEZE · after], where the freeze holds the frame at `atSec` for
+ * `frozenSec` of timeline time. The pause an inserted word creates: without it the
+ * word only borrows free silence, and a word dropped between two words that run into
+ * each other has no time at all. With it the timeline grows, everything downstream
+ * shifts, and a future TTS voice has a slot to speak in.
+ *
+ * No clip covers `atSec` (gap between clips, boundary of the asset) → unchanged, the
+ * caller decides whether that is acceptable. Trims anchored to the split clip stay on
+ * their id; `trimAppliesToClip` matches by asset when a trim has no clipId, and both
+ * halves keep the asset, so a trim that straddles the freeze point still narrows both
+ * halves exactly as it narrowed the whole clip before.
+ */
+export function insertFreezeInClips(
+ clips: AxcutClip[],
+ assetId: string,
+ atSec: number,
+ frozenSec: number,
+): AxcutClip[] {
+ if (frozenSec <= 0) return clips;
+ const index = clips.findIndex(
+ (clip) =>
+ clip.assetId === assetId &&
+ (clip.frozenSec ?? 0) === 0 &&
+ (clip.sourceEndSec ?? -1) > atSec + 0.001 &&
+ atSec > clip.sourceStartSec,
+ );
+ if (index < 0) return clips;
+ const clip = clips[index];
+ // `resequenceClips` keeps each clip's OWN timeline length, so the halves must not
+ // inherit the un-split clip's — that would double the timeline. Lengths here are
+ // derived from the new source windows; resequence then only relays them.
+ const beforeLen = atSec - clip.sourceStartSec;
+ const afterLen = (clip.sourceEndSec ?? 0) - atSec;
+ const before: AxcutClip = {
+ ...clip,
+ id: `${clip.id}_fzA`,
+ sourceEndSec: atSec,
+ timelineEndSec: clip.timelineStartSec + beforeLen,
+ };
+ const freeze: AxcutClip = {
+ id: `${clip.id}_fz`,
+ assetId: clip.assetId,
+ sourceStartSec: atSec,
+ sourceEndSec: atSec,
+ timelineStartSec: 0,
+ timelineEndSec: frozenSec,
+ wordRefs: [],
+ origin: "user",
+ reason: "Inserted word — held frame",
+ frozenSec,
+ };
+ const after: AxcutClip | null =
+ afterLen > 0.001
+ ? {
+ ...clip,
+ id: `${clip.id}_fzB`,
+ sourceStartSec: atSec,
+ timelineStartSec: 0,
+ timelineEndSec: afterLen,
+ }
+ : null;
+ const replaced = after ? [before, freeze, after] : [before, freeze];
+ return resequenceClips([...clips.slice(0, index), ...replaced, ...clips.slice(index + 1)]);
+}
+
export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
const output: Interval[] = [];
for (const interval of intervals) {
@@ -170,7 +237,10 @@ export function resolvePlaybackSegments(
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
- // Duration not probed yet — pass through as a single segment, unchanged.
+ // Either not probed yet, or a FREEZE clip (source window is the point it
+ // holds; `frozenSec` carries its real length). Both pass through as one
+ // segment at their timeline length — for a freeze that KEEPS the created
+ // pause in the compressed stream, which is the whole point.
const dur = clip.timelineEndSec - clip.timelineStartSec;
result.push({
...clip,
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 9e8ace067..03f9e5aae 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -615,6 +615,65 @@ describe("insertDocumentWord / removeDocumentWords", () => {
});
});
+describe("insertDocumentWord freeze", () => {
+ /** One clip covering the whole fixture recording — what the editor starts from. */
+ function makeDocWithClip() {
+ const doc = makeDoc();
+ return {
+ ...doc,
+ timeline: {
+ ...doc.timeline,
+ clips: [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ],
+ },
+ };
+ }
+
+ it("splits the clip and creates held-frame time when the silence is insufficient", () => {
+ // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
+ // gets no silence at all and needs max(0.4, 6/15) = 0.4 s of created time.
+ const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_2", "after", "really");
+ const clips = result.timeline.clips;
+ expect(clips).toHaveLength(3);
+ expect(clips[0]).toMatchObject({ id: "clip_1_fzA", sourceStartSec: 0, sourceEndSec: 3 });
+ expect(clips[1]).toMatchObject({
+ id: "clip_1_fz",
+ sourceStartSec: 3,
+ sourceEndSec: 3,
+ frozenSec: 0.4,
+ });
+ expect(clips[2]).toMatchObject({ id: "clip_1_fzB", sourceStartSec: 3, sourceEndSec: 10 });
+ // The timeline grew by exactly the freeze, laid back-to-back.
+ expect(clips[1].timelineStartSec).toBe(3);
+ expect(clips[1].timelineEndSec).toBeCloseTo(3.4, 5);
+ expect(clips[2].timelineEndSec).toBeCloseTo(10.4, 5);
+ });
+
+ it("does not touch the clips when free silence covers the word", () => {
+ // word_3 ends at 4, word_4 starts at 5: a full second of silence.
+ const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_3", "after", "really");
+ expect(result.timeline.clips).toHaveLength(1);
+ expect(result.timeline.clips[0].id).toBe("clip_1");
+ });
+
+ it("leaves the timeline alone when no clip covers the insertion point", () => {
+ // makeDoc has no clips at all — the word rides the caption line, as before.
+ const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
+ expect(result.timeline.clips).toHaveLength(0);
+ });
+});
+
describe("carryOverWordEdits with inserted words", () => {
const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index dec291474..ae626ee69 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,4 +1,5 @@
import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
+import { insertFreezeInClips } from "./timeline";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -302,7 +303,15 @@ export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTr
}
/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does. */
+ * reason {@link setDocumentWordText} does.
+ *
+ * When the free silence the word can borrow is shorter than the text needs to be
+ * read, the deficit becomes a FREEZE on the timeline (`insertFreezeInClips`): the clip
+ * is split at the word's edge and a held-frame clip carries the missing time. The
+ * timeline grows, everything downstream shifts, and the word has a real slot a
+ * synthesized voice will later speak in. No document-layer gate on this: it is the
+ * correct document semantics for an inserted word — the product decision of whether
+ * the gesture exists at all lives in the transcript pane (`openInsertion`). */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -314,7 +323,28 @@ export function insertDocumentWord(
if (!transcript) {
throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
}
- return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+ const next = withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+
+ // The word `insertWord` just added — the one synth id the old transcript lacked.
+ const inserted = next.transcripts
+ .find((t) => t.assetId === assetId)
+ ?.words.find(
+ (word) => word.source === "synth" && !transcript.words.some((w) => w.id === word.id),
+ );
+ if (!inserted) return next;
+ const deficit = readingSeconds(inserted.text) - (inserted.endSec - inserted.startSec);
+ if (deficit <= 0.05) return next;
+ // The freeze continues the word's slot: after the word for "after" (silence, then
+ // held frame), before it for "before" (held frame, then silence) — one contiguous
+ // stretch of created time the future voice occupies.
+ const atSec = side === "after" ? inserted.endSec : inserted.startSec;
+ return {
+ ...next,
+ timeline: {
+ ...next.timeline,
+ clips: insertFreezeInClips(next.timeline.clips, assetId, atSec, deficit),
+ },
+ };
}
/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index 9a511fee5..155cddb2b 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -215,6 +215,14 @@ export const clipSchema = z
// that as the identity region {x:0,y:0,width:1,height:1} rather than
// storing the identity explicitly, so untouched clips stay lean.
cropRegion: clipCropRegionSchema.optional(),
+ // A FREEZE clip: holds the frame at `sourceStartSec` (source window is the
+ // zero-width point [sourceStartSec, sourceStartSec]) for `frozenSec` of
+ // TIMELINE time. The pause an inserted word creates so a future TTS voice has
+ // a slot to speak in — screen and webcam freeze together because both tracks
+ // are derived from the same asset source clock. Absent on every ordinary clip;
+ // `resolvePlaybackSegments`'s un-probed passthrough branch must not be confused
+ // with it (a frozen clip is pushed through unchanged too, see its comment).
+ frozenSec: z.number().positive().optional(),
})
.refine((data) => data.timelineEndSec >= data.timelineStartSec, {
message: "timelineEndSec must be greater than or equal to timelineStartSec",
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index adc70b6cb..60f1fdea6 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -474,3 +474,94 @@ describe("clipWordId", () => {
expect(new Set(scopedIds).size).toBe(scopedIds.length);
});
});
+
+// ─── Freeze clips in the pane ────────────────────────────────────
+// An inserted word SPLITS the clip it lands in — [before · freeze · after] — and the word
+// sits exactly on the split. Both the freeze and the half that starts there matched it, so
+// the pane showed the same word twice, in two blocks.
+
+describe("the section a freeze clip projects", () => {
+ const TRANSCRIPT: AxcutTranscript = {
+ assetId: "a1",
+ language: "fr",
+ segments: [
+ { id: "s1", kind: "speech", startSec: 0, endSec: 4, text: "un deux", wordIds: ["w1", "w2"] },
+ ],
+ words: [
+ { id: "w1", segmentId: "s1", startSec: 0, endSec: 2, text: "un" },
+ { id: "w2", segmentId: "s1", startSec: 2, endSec: 4, text: "deux" },
+ { id: "synth_1", segmentId: "s1", startSec: 2, endSec: 2, text: "vraiment", source: "synth" },
+ ],
+ };
+ const ASSET: AxcutAsset = {
+ id: "a1",
+ kind: "video",
+ label: "rec.mp4",
+ originalPath: "/r.mp4",
+ durationSec: 4,
+ cameraTrack: null,
+ };
+ const CLIPS: AxcutClip[] = [
+ {
+ id: "c_fzA",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 2,
+ timelineStartSec: 0,
+ timelineEndSec: 2,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "c_fz",
+ assetId: "a1",
+ sourceStartSec: 2,
+ sourceEndSec: 2,
+ timelineStartSec: 2,
+ timelineEndSec: 2.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ frozenSec: 0.5,
+ },
+ {
+ id: "c_fzB",
+ assetId: "a1",
+ sourceStartSec: 2,
+ sourceEndSec: 4,
+ timelineStartSec: 2.5,
+ timelineEndSec: 4.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ];
+
+ it("shows the inserted word the freeze exists for", () => {
+ const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
+ expect(sections[1].words.map((cw) => cw.word.text)).toEqual(["vraiment"]);
+ });
+
+ it("shows it exactly once across the whole pane", () => {
+ const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
+ const everywhere = sections.flatMap((section) =>
+ section.words.filter((cw) => cw.word.id === "synth_1"),
+ );
+ expect(everywhere).toHaveLength(1);
+ });
+
+ it("leaves the spoken words where they were", () => {
+ const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
+ expect(sections[0].words.map((cw) => cw.word.text)).toEqual(["un"]);
+ expect(sections[2].words.map((cw) => cw.word.text)).toEqual(["deux"]);
+ });
+
+ // The claim is scoped to freezes: with no freeze in the timeline, a word with no
+ // duration is shown by whichever clip its moment falls in, as before.
+ it("does not withhold an inserted word when no freeze claims it", () => {
+ const whole: AxcutClip[] = [{ ...CLIPS[0], id: "c1", sourceEndSec: 4, timelineEndSec: 4 }];
+ const sections = buildAggregatedSections(whole, [TRANSCRIPT], [ASSET], []);
+ expect(sections[0].words.map((cw) => cw.word.text)).toContain("vraiment");
+ });
+});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 0cd068056..de46d071e 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -160,6 +160,32 @@ export function buildClipSection(
asset: AxcutAsset | null,
trimRanges: AxcutTrimRange[],
): ClipSection {
+ // A FREEZE clip is the created time behind an inserted word: its source window is
+ // the single point it holds, so the ordinary range filter matches nothing — and it
+ // must not. The words it shows are the SYNTH words touching that point: the word
+ // the freeze exists for. While the playhead runs through the freeze the cue
+ // resolves against this section and lights the amber word through the whole pause.
+ if (clip.frozenSec !== undefined) {
+ const atSec = clip.sourceStartSec;
+ const words = transcript
+ ? transcript.words.filter(
+ (word) => word.source === "synth" && word.startSec <= atSec && word.endSec >= atSec,
+ )
+ : [];
+ return {
+ clip,
+ asset,
+ transcript,
+ words: words.map((word) => ({
+ id: clipWordId(clip.id, word.id),
+ word,
+ kept: true,
+ trimId: null,
+ })),
+ trimRuns: [],
+ };
+ }
+
// `trimAppliesToClip` — not a bare `assetId` match — is what keeps a cut on the
// second of two clips over the same media from also greying out the first one's
// words. Same media, same source range: only the clip anchor tells them apart.
@@ -244,7 +270,7 @@ export function buildAggregatedSections(
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
- return clips.map((clip) =>
+ const sections = clips.map((clip) =>
buildClipSection(
clip,
transcriptById.get(clip.assetId) ?? null,
@@ -252,6 +278,23 @@ export function buildAggregatedSections(
trimRanges,
),
);
+
+ // A freeze clip claims the inserted word it was created for, and the clip after the
+ // split starts at the very moment that word sits on — so the word matched BOTH and the
+ // pane showed it twice, in two blocks. The freeze owns it: it is the section the
+ // playhead is inside while the pause plays, and the one whose whole reason to exist is
+ // that word.
+ const claimed = new Set(
+ sections
+ .filter((section) => section.clip.frozenSec !== undefined)
+ .flatMap((section) => section.words.map((cw) => cw.word.id)),
+ );
+ if (claimed.size === 0) return sections;
+ return sections.map((section) =>
+ section.clip.frozenSec !== undefined
+ ? section
+ : { ...section, words: section.words.filter((cw) => !claimed.has(cw.word.id)) },
+ );
}
/** Where the playback head currently is, in source time. */
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index ea3f6c61b..ecc81a2d0 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -17,6 +17,7 @@ import {
replacePillSpan,
resolveNativePosition,
resolvePillIds,
+ segmentRawSpanSec,
} from "./timelineMap";
function clip(overrides: Partial & Pick): AxcutClip {
@@ -805,3 +806,63 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
+
+// ─── Freeze clips ────────────────────────────────────────────────
+// The pause an inserted word creates. Its source window is the single point it holds, so
+// every reader that derives a length from `sourceEndSec - sourceStartSec` gets zero for it
+// — and zero is the one answer that makes the playhead skip the pause entirely.
+
+describe("a freeze clip", () => {
+ const clips = [
+ clip({ id: "a", assetId: "m", sourceStartSec: 0, sourceEndSec: 3 }),
+ clip({
+ id: "fz",
+ assetId: "m",
+ sourceStartSec: 3,
+ sourceEndSec: 3,
+ timelineStartSec: 3,
+ timelineEndSec: 3.5,
+ frozenSec: 0.5,
+ }),
+ clip({
+ id: "b",
+ assetId: "m",
+ sourceStartSec: 3,
+ sourceEndSec: 6,
+ timelineStartSec: 3.5,
+ timelineEndSec: 6.5,
+ }),
+ ];
+
+ it("keeps its created time in the playback segments", () => {
+ // It carries no source range to compress, so a naive reader drops it and the pause
+ // vanishes from playback while the ruler still counts it.
+ const segments = resolvePlaybackSegments(clips, []);
+ const freeze = segments.find((segment) => segment.id === "fz");
+ expect(freeze).toBeDefined();
+ expect((freeze?.timelineEndSec ?? 0) - (freeze?.timelineStartSec ?? 0)).toBeCloseTo(0.5, 5);
+ });
+
+ it("spans its frozen time on the raw ruler, not its (zero) source length", () => {
+ const span = segmentRawSpanSec(clips[1], clips);
+ expect(span.endSec - span.startSec).toBeCloseTo(0.5, 5);
+ });
+
+ it("holds the source clock still while the playhead runs through it", () => {
+ // The raw playhead DOES advance through the pause. Letting that delta reach the
+ // decoder would push it past the held frame into the content that belongs after.
+ const segments = resolvePlaybackSegments(clips, []);
+ for (const rawSec of [3.05, 3.25, 3.45]) {
+ const position = resolveNativePosition(rawSec, segments, clips);
+ expect(position?.clip.id).toBe("fz");
+ expect(position?.sourceTimeSec).toBe(3);
+ }
+ });
+
+ it("hands the clip after the pause its own source moment again", () => {
+ const segments = resolvePlaybackSegments(clips, []);
+ const position = resolveNativePosition(4, segments, clips);
+ expect(position?.clip.id).toBe("b");
+ expect(position?.sourceTimeSec).toBeCloseTo(3.5, 5);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index b98601a03..081ede4d7 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -403,7 +403,13 @@ export function segmentRawSpanSec(
rawClips: AxcutClip[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips);
- const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ // A freeze clip's source window is the point it holds — its RAW span is its created
+ // timeline time, not the (zero) source length, or the playhead could never be
+ // "inside" it and would skip the pause entirely.
+ const lenSec =
+ segment.frozenSec !== undefined
+ ? segment.frozenSec
+ : (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -690,6 +696,20 @@ export function resolveNativePosition(
const seg = visibleSegments[index];
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
+ // Inside a freeze: the source clock does not advance. The whole point of the clip
+ // is created timeline time over one held frame — clamp the offset to zero rather
+ // than letting the raw-playhead delta (which DOES advance through the freeze) push
+ // the decoder past the held frame into content that belongs after the pause.
+ if (seg.frozenSec !== undefined) {
+ return {
+ clip: seg,
+ clipIndex: index,
+ sourceTimeSec: seg.sourceStartSec,
+ };
+ }
+ // No `clampToSegmentStart` any more: this branch used to snap the playhead to the
+ // next kept segment, and main now returns `positionUnderCut` before reaching here
+ // (issue #216), so the flag could never be true.
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
return {
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index 64e877b63..abe2f1c1a 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -41,6 +41,13 @@ export function useNativePlaybackSync(
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
+ // A freeze clip holds ONE frame for `frozenSec` of app-clock time. Free-running the
+ // decoder through it would play the frames after the pause instead; the app clock
+ // (which does traverse the freeze) then re-seeks on drift and stutters. Pausing the
+ // decoder for the duration of the freeze is what makes the pause a pause — the
+ // webcam track freezes with the screen track because both derive from the same
+ // asset source clock the freeze stops advancing.
+ const frozen = activePosition?.clip.frozenSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -49,13 +56,15 @@ export function useNativePlaybackSync(
() => getCurrentNativeViewId() !== null,
);
- // Play/pause → native free-run.
+ // Play/pause → native free-run. Inside a freeze the native side is PAUSED however
+ // the transport is set — the app clock advances through the created time while the
+ // decoder holds the frame.
useEffect(() => {
if (!active) {
return;
}
- setNativePlaying(playing);
- }, [active, playing]);
+ setNativePlaying(playing && !frozen);
+ }, [active, playing, frozen]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -68,6 +77,17 @@ export function useNativePlaybackSync(
}
const now = performance.now();
+ // Inside a freeze while playing: the decoder is paused (see the transport
+ // effect) and parked on the held frame. Refresh the drift refs every run so the
+ // drift check never sees the (correctly) frozen source clock as divergence and
+ // fights itself with repeated seeks.
+ if (playing && frozen) {
+ setNativeTime(sourceTimeSec);
+ lastSyncedSourceTimeRef.current = sourceTimeSec;
+ lastSyncedWallTimeRef.current = now;
+ return;
+ }
+
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -95,5 +115,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, activeClipId, sourceTimeSec]);
+ }, [active, playing, frozen, activeClipId, sourceTimeSec]);
}
From 2745da4d446248cfd71b81f2a42bedfa321dcf61 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 22:36:58 +0200
Subject: [PATCH 58/84] revert(editor): an added word no longer splits the clip
it lands in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Splitting the clip to make room for an inserted word did not survive the rest of
the app. A project saved after two inserts came back with one clip named
`clip_..._fzA_fzA` — split twice, both freezes and both after-halves gone, the
full source range restored onto the mangled id — and zero synthesized words. The
inserted text was lost on save, and the held-frame clips that did exist were
skipped in playback.
That is not a bug to chase. Clip surgery for a caption-only feature puts the
timeline's shape under the transcript's control, and every other writer of
`timeline.clips` — the duration probe, the recording import, resequencing — is
entitled to disagree with it. `frozenSec` goes with it, and so do the readers
that had to special-case a clip whose source window is a single point: the
playback segments, the raw span, the native position clamp, the decoder pause,
the caption ventilation, and the pane's split-clip header run.
What stays is the part that was always true on its own: an inserted word is a
word in the transcript with `source: "synth"`, it borrows whatever silence is
free where it lands, and it reaches the captions. The gesture stays dev-gated —
now for a second reason, since without created time an inserted word can only
speak inside a pause that already exists.
Creating time is still the right answer once there is a voice to put in it. It
belongs in a record of its own, beside `trimRanges`, which is the inverse
operation and the one shape the timeline already threads everywhere.
---
src/components/ai-edition/RightPanes.tsx | 179 ++++++------------
.../TranscriptPane.sharedMedia.test.tsx | 82 --------
src/lib/ai-edition/captions/captions.test.ts | 115 -----------
src/lib/ai-edition/captions/cues.ts | 30 +--
src/lib/ai-edition/document/timeline.ts | 72 +------
.../ai-edition/document/transcript.test.ts | 59 ------
src/lib/ai-edition/document/transcript.ts | 34 +---
src/lib/ai-edition/schema/index.ts | 8 -
.../timeline/aggregated-transcript.test.ts | 91 ---------
.../timeline/aggregated-transcript.ts | 45 +----
.../ai-edition/timeline/timelineMap.test.ts | 61 ------
src/lib/ai-edition/timeline/timelineMap.ts | 22 +--
src/native/useNativePlaybackSync.ts | 28 +--
13 files changed, 73 insertions(+), 753 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index e1f944986..8df7af234 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -855,8 +855,6 @@ export function TranscriptPane({
key={section.clip.id}
index={idx}
section={section}
- continuation={continuesPreviousSection(sections[idx - 1], section)}
- runLabel={runLabelFor(sections, idx)}
busy={busyAssetIds.includes(section.clip.assetId)}
cueWordId={cueWordId}
onSeek={onSeek}
@@ -871,46 +869,6 @@ export function TranscriptPane({
);
}
-/**
- * Whether this section merely continues the previous one: same media, and the previous
- * clip ends exactly where this one starts, on the source clock and on the ruler alike.
- *
- * Inserting a word SPLITS the clip it lands in — [before · freeze · after] — so one word
- * turned one recording into three headed blocks, each announcing the same filename and a
- * sliver of timecode. They are one continuous read and now render as one: the header
- * appears on the first section of the run, the rest flow straight on from it. Two clips
- * over the same media that are NOT contiguous still get a header each, which is the case
- * the header exists for.
- */
-function continuesPreviousSection(
- previous: ClipSection | undefined,
- section: ClipSection,
-): boolean {
- if (!previous || previous.clip.assetId !== section.clip.assetId) return false;
- const EPSILON_SEC = 0.001;
- const sourceMeets =
- Math.abs((previous.clip.sourceEndSec ?? Number.NaN) - section.clip.sourceStartSec) <
- EPSILON_SEC;
- const rulerMeets =
- Math.abs(previous.clip.timelineEndSec - section.clip.timelineStartSec) < EPSILON_SEC;
- return sourceMeets && rulerMeets;
-}
-
-/** The source range the whole run covers, for the one header that fronts it. */
-function runLabelFor(sections: ClipSection[], index: number): { start: number; end: number } {
- let last = index;
- while (
- last + 1 < sections.length &&
- continuesPreviousSection(sections[last], sections[last + 1])
- ) {
- last += 1;
- }
- return {
- start: sections[index].clip.sourceStartSec,
- end: sections[last].clip.sourceEndSec ?? sections[last].clip.sourceStartSec,
- };
-}
-
// One contentEditable block per clip — header (vignette + filename +
// range) and a flowing word stream. The stream contains every transcript
// word inside the clip's source range, color-coded by whether the word
@@ -926,8 +884,6 @@ function runLabelFor(sections: ClipSection[], index: number): { start: number; e
const TranscriptClipBlock = memo(function TranscriptClipBlock({
index,
section,
- continuation,
- runLabel,
busy,
cueWordId,
onSeek,
@@ -939,10 +895,6 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
}: {
index: number;
section: ClipSection;
- /** This section reads straight on from the one above — no header, no gap. */
- continuation: boolean;
- /** Source range of the whole contiguous run this section fronts. */
- runLabel: { start: number; end: number };
busy: boolean;
cueWordId: string | null;
onSeek: (sec: number) => void;
@@ -961,9 +913,10 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
[clip.assetId, clip.id],
);
const filename = asset?.label ?? clip.assetId;
- // The run's range, not this clip's: a split clip's own sliver would read as a
- // 0:02.5—0:02.5 recording.
- const sourceRangeLabel = `${formatMs(runLabel.start * 1000)}—${formatMs(runLabel.end * 1000)}`;
+ const sourceRangeLabel =
+ clip.sourceEndSec !== undefined
+ ? `${formatMs(clip.sourceStartSec * 1000)}—${formatMs(clip.sourceEndSec * 1000)}`
+ : `${formatMs(clip.sourceStartSec * 1000)}—`;
const editorRef = useRef(null);
const pendingCaretWordIdRef = useRef(null);
@@ -1240,85 +1193,79 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
return (
- {continuation ? null : (
+ 0 ? 16 : 0,
- marginBottom: 6,
+ justifyContent: "center",
+ background: "var(--accent-soft)",
+ color: "var(--accent)",
+ borderRadius: "var(--r-sm)",
+ font: "700 12px/1 var(--font-mono)",
+ flexShrink: 0,
}}
>
+ {index + 1}
+
+
+
+ {filename}
+
+
+ {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
+
+
+ {/* A block whose transcript is being regenerated is read-only — say it,
+ rather than letting the word stream look live and drop the edits. */}
+ {busy ? (
- {index + 1}
-
-
-
- {filename}
-
-
- {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
-
+
+ {ts("transcript.transcribing")}
- {/* A block whose transcript is being regenerated is read-only — say it,
- rather than letting the word stream look live and drop the edits. */}
- {busy ? (
-
-
- {ts("transcript.transcribing")}
-
- ) : null}
-
- )}
+ ) : null}
+
{words.length === 0 ? (
{
).toEqual(["clip_2:w2"]);
});
});
-
-// ─── Headers on a clip an inserted word split ────────────────────
-// Inserting a word splits the clip it lands in — [before · freeze · after] — so one word
-// turned one recording into three blocks, each announcing the same filename and a sliver
-// of timecode ("Clip 2 · 0:02.5—0:02.5"). They are one continuous read: one header, and
-// the words flow straight on. The two-copies case above must keep its two headers, which
-// is what tells the split apart from a media genuinely placed twice.
-
-const SPLIT_CLIPS: AxcutClip[] = [
- {
- id: "clip_1_fzA",
- assetId: "asset_1",
- sourceStartSec: 0,
- sourceEndSec: 6,
- timelineStartSec: 0,
- timelineEndSec: 6,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "clip_1_fz",
- assetId: "asset_1",
- sourceStartSec: 6,
- sourceEndSec: 6,
- timelineStartSec: 6,
- timelineEndSec: 6.5,
- wordRefs: [],
- origin: "user",
- reason: "Inserted word — held frame",
- frozenSec: 0.5,
- },
- {
- id: "clip_1_fzB",
- assetId: "asset_1",
- sourceStartSec: 6,
- sourceEndSec: 12,
- timelineStartSec: 6.5,
- timelineEndSec: 12.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
-];
-
-function renderClips(clips: AxcutClip[]) {
- return render(
-
-
- ,
- );
-}
-
-describe("clip headers", () => {
- it("fronts a split clip with one header covering the whole run", () => {
- const view = renderClips(SPLIT_CLIPS);
- const headers = view.container.querySelectorAll("[data-clip-header]");
- expect(headers).toHaveLength(1);
- // The run's range, not the first piece's — and not the freeze's 0:06.0—0:06.0.
- expect(headers[0].textContent).toContain("0:00.0—0:12.0");
- });
-
- it("still gives two headers to one media placed twice", () => {
- const view = renderClips(CLIPS);
- expect(view.container.querySelectorAll("[data-clip-header]")).toHaveLength(2);
- });
-});
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index 4d86af1ef..ce3d4a961 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -650,118 +650,3 @@ describe("translated caption layout", () => {
);
});
});
-
-// ─── Captions across an inserted word's pause ────────────────────
-// Inserting a word SPLITS the clip it lands in — [before · freeze · after] — and a caption
-// line straddling the split was ventilated once per half, each half carrying the whole
-// line. On screen: the caption played, blinked out for the pause, then played again from
-// the top — dark over the one moment the pause exists for.
-
-describe("a caption line over a freeze", () => {
- /** `clip-1` split at 1.2s, with 0.5s of held frame carrying an inserted word. */
- function splitDoc(): AxcutDocument {
- const withInsert = transcript();
- withInsert.segments[0] = {
- ...withInsert.segments[0],
- text: "hello there really friend",
- wordIds: ["w1", "w2", "synth_1", "w3"],
- };
- withInsert.words = [
- ...withInsert.words,
- {
- id: "synth_1",
- segmentId: "seg_1",
- startSec: 1.2,
- endSec: 1.2,
- text: "really",
- source: "synth",
- },
- ];
- return doc({
- transcripts: [withInsert],
- timeline: {
- ...doc().timeline,
- clips: [
- {
- id: "clip-1_fzA",
- assetId: "asset-1",
- sourceStartSec: 0,
- sourceEndSec: 1.2,
- timelineStartSec: 0,
- timelineEndSec: 1.2,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "clip-1_fz",
- assetId: "asset-1",
- sourceStartSec: 1.2,
- sourceEndSec: 1.2,
- timelineStartSec: 1.2,
- timelineEndSec: 1.7,
- wordRefs: [],
- origin: "user",
- reason: "Inserted word — held frame",
- frozenSec: 0.5,
- },
- {
- id: "clip-1_fzB",
- assetId: "asset-1",
- sourceStartSec: 1.2,
- sourceEndSec: 10,
- timelineStartSec: 1.7,
- timelineEndSec: 10.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- ],
- },
- });
- }
-
- it("plays the line once, straight through the pause", () => {
- const cues = deriveCaptionCues(splitDoc(), ON, {});
- const first = cues.filter((cue) => cue.text.includes("really"));
- expect(first).toHaveLength(1);
- // It starts before the freeze and is still up after it — no dark stretch.
- expect(first[0].startMs).toBeLessThan(1200);
- expect(first[0].endMs).toBeGreaterThan(1700);
- });
-
- it("still plays a line twice when one media is genuinely placed twice", () => {
- // The spans do not touch on the ruler there, so the coalescing must leave them apart.
- const twice = doc({
- timeline: {
- ...doc().timeline,
- clips: [
- {
- id: "clip-1",
- assetId: "asset-1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "clip-2",
- assetId: "asset-1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 20,
- timelineEndSec: 30,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- ],
- },
- });
- const cues = deriveCaptionCues(twice, ON, {});
- expect(cues.filter((cue) => cue.text.includes("hello"))).toHaveLength(2);
- });
-});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 8154788a1..c3287ebcb 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -161,17 +161,6 @@ export function sourceSpanToTimelineSpans(
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
if (clip.assetId !== assetId) continue;
- // A FREEZE clip holds one source moment for created timeline time — an inserted
- // word's pause. Its source window is that single point, so the overlap test below
- // can never match it, and the line the pause exists for went DARK for its whole
- // duration. A line covering the held moment covers the pause too.
- if (clip.frozenSec !== undefined) {
- const held = clip.sourceStartSec;
- if (held >= startSec && held < endSec) {
- out.push({ startSec: clip.timelineStartSec, endSec: clip.timelineEndSec });
- }
- continue;
- }
const clipSourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
@@ -181,24 +170,7 @@ export function sourceSpanToTimelineSpans(
endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
});
}
-
- // Coalesce what meets on the ruler. Splitting a clip to make room for an inserted word
- // leaves the line straddling [before · freeze · after]: three spans back to back, each
- // carrying the WHOLE line's text, so the caption played, blinked out over the pause,
- // then played again from the top. They are one appearance. A line genuinely played
- // twice — two clips over one media — does not touch on the ruler and stays two.
- const EPSILON_SEC = 0.001;
- const ordered = [...out].sort((a, b) => a.startSec - b.startSec);
- const merged: Array<{ startSec: number; endSec: number }> = [];
- for (const span of ordered) {
- const last = merged[merged.length - 1];
- if (last && span.startSec <= last.endSec + EPSILON_SEC) {
- last.endSec = Math.max(last.endSec, span.endSec);
- continue;
- }
- merged.push({ ...span });
- }
- return merged;
+ return out;
}
/**
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index fa41c2bc6..232bcb6ee 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -127,73 +127,6 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
});
}
-/**
- * Split the clip covering `atSec` (source time, `assetId`'s media) into
- * [before · FREEZE · after], where the freeze holds the frame at `atSec` for
- * `frozenSec` of timeline time. The pause an inserted word creates: without it the
- * word only borrows free silence, and a word dropped between two words that run into
- * each other has no time at all. With it the timeline grows, everything downstream
- * shifts, and a future TTS voice has a slot to speak in.
- *
- * No clip covers `atSec` (gap between clips, boundary of the asset) → unchanged, the
- * caller decides whether that is acceptable. Trims anchored to the split clip stay on
- * their id; `trimAppliesToClip` matches by asset when a trim has no clipId, and both
- * halves keep the asset, so a trim that straddles the freeze point still narrows both
- * halves exactly as it narrowed the whole clip before.
- */
-export function insertFreezeInClips(
- clips: AxcutClip[],
- assetId: string,
- atSec: number,
- frozenSec: number,
-): AxcutClip[] {
- if (frozenSec <= 0) return clips;
- const index = clips.findIndex(
- (clip) =>
- clip.assetId === assetId &&
- (clip.frozenSec ?? 0) === 0 &&
- (clip.sourceEndSec ?? -1) > atSec + 0.001 &&
- atSec > clip.sourceStartSec,
- );
- if (index < 0) return clips;
- const clip = clips[index];
- // `resequenceClips` keeps each clip's OWN timeline length, so the halves must not
- // inherit the un-split clip's — that would double the timeline. Lengths here are
- // derived from the new source windows; resequence then only relays them.
- const beforeLen = atSec - clip.sourceStartSec;
- const afterLen = (clip.sourceEndSec ?? 0) - atSec;
- const before: AxcutClip = {
- ...clip,
- id: `${clip.id}_fzA`,
- sourceEndSec: atSec,
- timelineEndSec: clip.timelineStartSec + beforeLen,
- };
- const freeze: AxcutClip = {
- id: `${clip.id}_fz`,
- assetId: clip.assetId,
- sourceStartSec: atSec,
- sourceEndSec: atSec,
- timelineStartSec: 0,
- timelineEndSec: frozenSec,
- wordRefs: [],
- origin: "user",
- reason: "Inserted word — held frame",
- frozenSec,
- };
- const after: AxcutClip | null =
- afterLen > 0.001
- ? {
- ...clip,
- id: `${clip.id}_fzB`,
- sourceStartSec: atSec,
- timelineStartSec: 0,
- timelineEndSec: afterLen,
- }
- : null;
- const replaced = after ? [before, freeze, after] : [before, freeze];
- return resequenceClips([...clips.slice(0, index), ...replaced, ...clips.slice(index + 1)]);
-}
-
export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
const output: Interval[] = [];
for (const interval of intervals) {
@@ -237,10 +170,7 @@ export function resolvePlaybackSegments(
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
- // Either not probed yet, or a FREEZE clip (source window is the point it
- // holds; `frozenSec` carries its real length). Both pass through as one
- // segment at their timeline length — for a freeze that KEEPS the created
- // pause in the compressed stream, which is the whole point.
+ // Duration not probed yet — pass through as a single segment, unchanged.
const dur = clip.timelineEndSec - clip.timelineStartSec;
result.push({
...clip,
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 03f9e5aae..9e8ace067 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -615,65 +615,6 @@ describe("insertDocumentWord / removeDocumentWords", () => {
});
});
-describe("insertDocumentWord freeze", () => {
- /** One clip covering the whole fixture recording — what the editor starts from. */
- function makeDocWithClip() {
- const doc = makeDoc();
- return {
- ...doc,
- timeline: {
- ...doc.timeline,
- clips: [
- {
- id: "clip_1",
- assetId: "asset_1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user" as const,
- reason: "",
- },
- ],
- },
- };
- }
-
- it("splits the clip and creates held-frame time when the silence is insufficient", () => {
- // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
- // gets no silence at all and needs max(0.4, 6/15) = 0.4 s of created time.
- const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_2", "after", "really");
- const clips = result.timeline.clips;
- expect(clips).toHaveLength(3);
- expect(clips[0]).toMatchObject({ id: "clip_1_fzA", sourceStartSec: 0, sourceEndSec: 3 });
- expect(clips[1]).toMatchObject({
- id: "clip_1_fz",
- sourceStartSec: 3,
- sourceEndSec: 3,
- frozenSec: 0.4,
- });
- expect(clips[2]).toMatchObject({ id: "clip_1_fzB", sourceStartSec: 3, sourceEndSec: 10 });
- // The timeline grew by exactly the freeze, laid back-to-back.
- expect(clips[1].timelineStartSec).toBe(3);
- expect(clips[1].timelineEndSec).toBeCloseTo(3.4, 5);
- expect(clips[2].timelineEndSec).toBeCloseTo(10.4, 5);
- });
-
- it("does not touch the clips when free silence covers the word", () => {
- // word_3 ends at 4, word_4 starts at 5: a full second of silence.
- const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_3", "after", "really");
- expect(result.timeline.clips).toHaveLength(1);
- expect(result.timeline.clips[0].id).toBe("clip_1");
- });
-
- it("leaves the timeline alone when no clip covers the insertion point", () => {
- // makeDoc has no clips at all — the word rides the caption line, as before.
- const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
- expect(result.timeline.clips).toHaveLength(0);
- });
-});
-
describe("carryOverWordEdits with inserted words", () => {
const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index ae626ee69..dec291474 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,5 +1,4 @@
import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
-import { insertFreezeInClips } from "./timeline";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -303,15 +302,7 @@ export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTr
}
/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does.
- *
- * When the free silence the word can borrow is shorter than the text needs to be
- * read, the deficit becomes a FREEZE on the timeline (`insertFreezeInClips`): the clip
- * is split at the word's edge and a held-frame clip carries the missing time. The
- * timeline grows, everything downstream shifts, and the word has a real slot a
- * synthesized voice will later speak in. No document-layer gate on this: it is the
- * correct document semantics for an inserted word — the product decision of whether
- * the gesture exists at all lives in the transcript pane (`openInsertion`). */
+ * reason {@link setDocumentWordText} does. */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -323,28 +314,7 @@ export function insertDocumentWord(
if (!transcript) {
throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
}
- const next = withTranscript(document, insertWord(transcript, anchorWordId, side, text));
-
- // The word `insertWord` just added — the one synth id the old transcript lacked.
- const inserted = next.transcripts
- .find((t) => t.assetId === assetId)
- ?.words.find(
- (word) => word.source === "synth" && !transcript.words.some((w) => w.id === word.id),
- );
- if (!inserted) return next;
- const deficit = readingSeconds(inserted.text) - (inserted.endSec - inserted.startSec);
- if (deficit <= 0.05) return next;
- // The freeze continues the word's slot: after the word for "after" (silence, then
- // held frame), before it for "before" (held frame, then silence) — one contiguous
- // stretch of created time the future voice occupies.
- const atSec = side === "after" ? inserted.endSec : inserted.startSec;
- return {
- ...next,
- timeline: {
- ...next.timeline,
- clips: insertFreezeInClips(next.timeline.clips, assetId, atSec, deficit),
- },
- };
+ return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
}
/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index 155cddb2b..9a511fee5 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -215,14 +215,6 @@ export const clipSchema = z
// that as the identity region {x:0,y:0,width:1,height:1} rather than
// storing the identity explicitly, so untouched clips stay lean.
cropRegion: clipCropRegionSchema.optional(),
- // A FREEZE clip: holds the frame at `sourceStartSec` (source window is the
- // zero-width point [sourceStartSec, sourceStartSec]) for `frozenSec` of
- // TIMELINE time. The pause an inserted word creates so a future TTS voice has
- // a slot to speak in — screen and webcam freeze together because both tracks
- // are derived from the same asset source clock. Absent on every ordinary clip;
- // `resolvePlaybackSegments`'s un-probed passthrough branch must not be confused
- // with it (a frozen clip is pushed through unchanged too, see its comment).
- frozenSec: z.number().positive().optional(),
})
.refine((data) => data.timelineEndSec >= data.timelineStartSec, {
message: "timelineEndSec must be greater than or equal to timelineStartSec",
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index 60f1fdea6..adc70b6cb 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -474,94 +474,3 @@ describe("clipWordId", () => {
expect(new Set(scopedIds).size).toBe(scopedIds.length);
});
});
-
-// ─── Freeze clips in the pane ────────────────────────────────────
-// An inserted word SPLITS the clip it lands in — [before · freeze · after] — and the word
-// sits exactly on the split. Both the freeze and the half that starts there matched it, so
-// the pane showed the same word twice, in two blocks.
-
-describe("the section a freeze clip projects", () => {
- const TRANSCRIPT: AxcutTranscript = {
- assetId: "a1",
- language: "fr",
- segments: [
- { id: "s1", kind: "speech", startSec: 0, endSec: 4, text: "un deux", wordIds: ["w1", "w2"] },
- ],
- words: [
- { id: "w1", segmentId: "s1", startSec: 0, endSec: 2, text: "un" },
- { id: "w2", segmentId: "s1", startSec: 2, endSec: 4, text: "deux" },
- { id: "synth_1", segmentId: "s1", startSec: 2, endSec: 2, text: "vraiment", source: "synth" },
- ],
- };
- const ASSET: AxcutAsset = {
- id: "a1",
- kind: "video",
- label: "rec.mp4",
- originalPath: "/r.mp4",
- durationSec: 4,
- cameraTrack: null,
- };
- const CLIPS: AxcutClip[] = [
- {
- id: "c_fzA",
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 2,
- timelineStartSec: 0,
- timelineEndSec: 2,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "c_fz",
- assetId: "a1",
- sourceStartSec: 2,
- sourceEndSec: 2,
- timelineStartSec: 2,
- timelineEndSec: 2.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- frozenSec: 0.5,
- },
- {
- id: "c_fzB",
- assetId: "a1",
- sourceStartSec: 2,
- sourceEndSec: 4,
- timelineStartSec: 2.5,
- timelineEndSec: 4.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- ];
-
- it("shows the inserted word the freeze exists for", () => {
- const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
- expect(sections[1].words.map((cw) => cw.word.text)).toEqual(["vraiment"]);
- });
-
- it("shows it exactly once across the whole pane", () => {
- const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
- const everywhere = sections.flatMap((section) =>
- section.words.filter((cw) => cw.word.id === "synth_1"),
- );
- expect(everywhere).toHaveLength(1);
- });
-
- it("leaves the spoken words where they were", () => {
- const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
- expect(sections[0].words.map((cw) => cw.word.text)).toEqual(["un"]);
- expect(sections[2].words.map((cw) => cw.word.text)).toEqual(["deux"]);
- });
-
- // The claim is scoped to freezes: with no freeze in the timeline, a word with no
- // duration is shown by whichever clip its moment falls in, as before.
- it("does not withhold an inserted word when no freeze claims it", () => {
- const whole: AxcutClip[] = [{ ...CLIPS[0], id: "c1", sourceEndSec: 4, timelineEndSec: 4 }];
- const sections = buildAggregatedSections(whole, [TRANSCRIPT], [ASSET], []);
- expect(sections[0].words.map((cw) => cw.word.text)).toContain("vraiment");
- });
-});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index de46d071e..0cd068056 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -160,32 +160,6 @@ export function buildClipSection(
asset: AxcutAsset | null,
trimRanges: AxcutTrimRange[],
): ClipSection {
- // A FREEZE clip is the created time behind an inserted word: its source window is
- // the single point it holds, so the ordinary range filter matches nothing — and it
- // must not. The words it shows are the SYNTH words touching that point: the word
- // the freeze exists for. While the playhead runs through the freeze the cue
- // resolves against this section and lights the amber word through the whole pause.
- if (clip.frozenSec !== undefined) {
- const atSec = clip.sourceStartSec;
- const words = transcript
- ? transcript.words.filter(
- (word) => word.source === "synth" && word.startSec <= atSec && word.endSec >= atSec,
- )
- : [];
- return {
- clip,
- asset,
- transcript,
- words: words.map((word) => ({
- id: clipWordId(clip.id, word.id),
- word,
- kept: true,
- trimId: null,
- })),
- trimRuns: [],
- };
- }
-
// `trimAppliesToClip` — not a bare `assetId` match — is what keeps a cut on the
// second of two clips over the same media from also greying out the first one's
// words. Same media, same source range: only the clip anchor tells them apart.
@@ -270,7 +244,7 @@ export function buildAggregatedSections(
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
- const sections = clips.map((clip) =>
+ return clips.map((clip) =>
buildClipSection(
clip,
transcriptById.get(clip.assetId) ?? null,
@@ -278,23 +252,6 @@ export function buildAggregatedSections(
trimRanges,
),
);
-
- // A freeze clip claims the inserted word it was created for, and the clip after the
- // split starts at the very moment that word sits on — so the word matched BOTH and the
- // pane showed it twice, in two blocks. The freeze owns it: it is the section the
- // playhead is inside while the pause plays, and the one whose whole reason to exist is
- // that word.
- const claimed = new Set(
- sections
- .filter((section) => section.clip.frozenSec !== undefined)
- .flatMap((section) => section.words.map((cw) => cw.word.id)),
- );
- if (claimed.size === 0) return sections;
- return sections.map((section) =>
- section.clip.frozenSec !== undefined
- ? section
- : { ...section, words: section.words.filter((cw) => !claimed.has(cw.word.id)) },
- );
}
/** Where the playback head currently is, in source time. */
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index ecc81a2d0..ea3f6c61b 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -17,7 +17,6 @@ import {
replacePillSpan,
resolveNativePosition,
resolvePillIds,
- segmentRawSpanSec,
} from "./timelineMap";
function clip(overrides: Partial & Pick): AxcutClip {
@@ -806,63 +805,3 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
-
-// ─── Freeze clips ────────────────────────────────────────────────
-// The pause an inserted word creates. Its source window is the single point it holds, so
-// every reader that derives a length from `sourceEndSec - sourceStartSec` gets zero for it
-// — and zero is the one answer that makes the playhead skip the pause entirely.
-
-describe("a freeze clip", () => {
- const clips = [
- clip({ id: "a", assetId: "m", sourceStartSec: 0, sourceEndSec: 3 }),
- clip({
- id: "fz",
- assetId: "m",
- sourceStartSec: 3,
- sourceEndSec: 3,
- timelineStartSec: 3,
- timelineEndSec: 3.5,
- frozenSec: 0.5,
- }),
- clip({
- id: "b",
- assetId: "m",
- sourceStartSec: 3,
- sourceEndSec: 6,
- timelineStartSec: 3.5,
- timelineEndSec: 6.5,
- }),
- ];
-
- it("keeps its created time in the playback segments", () => {
- // It carries no source range to compress, so a naive reader drops it and the pause
- // vanishes from playback while the ruler still counts it.
- const segments = resolvePlaybackSegments(clips, []);
- const freeze = segments.find((segment) => segment.id === "fz");
- expect(freeze).toBeDefined();
- expect((freeze?.timelineEndSec ?? 0) - (freeze?.timelineStartSec ?? 0)).toBeCloseTo(0.5, 5);
- });
-
- it("spans its frozen time on the raw ruler, not its (zero) source length", () => {
- const span = segmentRawSpanSec(clips[1], clips);
- expect(span.endSec - span.startSec).toBeCloseTo(0.5, 5);
- });
-
- it("holds the source clock still while the playhead runs through it", () => {
- // The raw playhead DOES advance through the pause. Letting that delta reach the
- // decoder would push it past the held frame into the content that belongs after.
- const segments = resolvePlaybackSegments(clips, []);
- for (const rawSec of [3.05, 3.25, 3.45]) {
- const position = resolveNativePosition(rawSec, segments, clips);
- expect(position?.clip.id).toBe("fz");
- expect(position?.sourceTimeSec).toBe(3);
- }
- });
-
- it("hands the clip after the pause its own source moment again", () => {
- const segments = resolvePlaybackSegments(clips, []);
- const position = resolveNativePosition(4, segments, clips);
- expect(position?.clip.id).toBe("b");
- expect(position?.sourceTimeSec).toBeCloseTo(3.5, 5);
- });
-});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index 081ede4d7..b98601a03 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -403,13 +403,7 @@ export function segmentRawSpanSec(
rawClips: AxcutClip[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips);
- // A freeze clip's source window is the point it holds — its RAW span is its created
- // timeline time, not the (zero) source length, or the playhead could never be
- // "inside" it and would skip the pause entirely.
- const lenSec =
- segment.frozenSec !== undefined
- ? segment.frozenSec
- : (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -696,20 +690,6 @@ export function resolveNativePosition(
const seg = visibleSegments[index];
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
- // Inside a freeze: the source clock does not advance. The whole point of the clip
- // is created timeline time over one held frame — clamp the offset to zero rather
- // than letting the raw-playhead delta (which DOES advance through the freeze) push
- // the decoder past the held frame into content that belongs after the pause.
- if (seg.frozenSec !== undefined) {
- return {
- clip: seg,
- clipIndex: index,
- sourceTimeSec: seg.sourceStartSec,
- };
- }
- // No `clampToSegmentStart` any more: this branch used to snap the playhead to the
- // next kept segment, and main now returns `positionUnderCut` before reaching here
- // (issue #216), so the flag could never be true.
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
return {
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index abe2f1c1a..64e877b63 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -41,13 +41,6 @@ export function useNativePlaybackSync(
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
- // A freeze clip holds ONE frame for `frozenSec` of app-clock time. Free-running the
- // decoder through it would play the frames after the pause instead; the app clock
- // (which does traverse the freeze) then re-seeks on drift and stutters. Pausing the
- // decoder for the duration of the freeze is what makes the pause a pause — the
- // webcam track freezes with the screen track because both derive from the same
- // asset source clock the freeze stops advancing.
- const frozen = activePosition?.clip.frozenSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -56,15 +49,13 @@ export function useNativePlaybackSync(
() => getCurrentNativeViewId() !== null,
);
- // Play/pause → native free-run. Inside a freeze the native side is PAUSED however
- // the transport is set — the app clock advances through the created time while the
- // decoder holds the frame.
+ // Play/pause → native free-run.
useEffect(() => {
if (!active) {
return;
}
- setNativePlaying(playing && !frozen);
- }, [active, playing, frozen]);
+ setNativePlaying(playing);
+ }, [active, playing]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -77,17 +68,6 @@ export function useNativePlaybackSync(
}
const now = performance.now();
- // Inside a freeze while playing: the decoder is paused (see the transport
- // effect) and parked on the held frame. Refresh the drift refs every run so the
- // drift check never sees the (correctly) frozen source clock as divergence and
- // fights itself with repeated seeks.
- if (playing && frozen) {
- setNativeTime(sourceTimeSec);
- lastSyncedSourceTimeRef.current = sourceTimeSec;
- lastSyncedWallTimeRef.current = now;
- return;
- }
-
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -115,5 +95,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, frozen, activeClipId, sourceTimeSec]);
+ }, [active, playing, activeClipId, sourceTimeSec]);
}
From bef60e924c60bb1309eba22fd6f06a05c48f1c92 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 22:51:01 +0200
Subject: [PATCH 59/84] feat(timeline): mark where words were added, without
storing anything new
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An added word was visible only in the transcript pane. On the timeline — where
you decide what the film does — nothing said a moment carried text with no
audio behind it.
Each one now gets a thin amber tick on its clip's own track, at the moment it
sits on, carrying its text in the tooltip and seeking to it on click. Amber is
the colour the pane gives the same word, so the two read as one thing.
Derived, never stored: the mark is computed from the transcript's `synth` words
on every render, so there is no second record to fall out of step with the
first, and nothing for another writer of `timeline.clips` to lose. Positioning
is a percentage inside the clip's own box rather than an absolute ruler offset,
so a mark travels with its clip through a reorder without arithmetic of its own.
---
.../ai-edition/v4/EditorShellV4.module.css | 35 ++++++++++++++
.../v4/V4Timeline.geometry.test.tsx | 3 ++
src/components/ai-edition/v4/V4Timeline.tsx | 47 ++++++++++++++++++-
.../v4/V4Timeline.waveform.test.tsx | 2 +
src/i18n/locales/ar/timeline.json | 3 +-
src/i18n/locales/en/timeline.json | 3 +-
src/i18n/locales/es/timeline.json | 3 +-
src/i18n/locales/fr/timeline.json | 3 +-
src/i18n/locales/it/timeline.json | 3 +-
src/i18n/locales/ja-JP/timeline.json | 3 +-
src/i18n/locales/ko-KR/timeline.json | 3 +-
src/i18n/locales/pt-BR/timeline.json | 3 +-
src/i18n/locales/ru/timeline.json | 3 +-
src/i18n/locales/tr/timeline.json | 3 +-
src/i18n/locales/vi/timeline.json | 3 +-
src/i18n/locales/zh-CN/timeline.json | 3 +-
src/i18n/locales/zh-TW/timeline.json | 3 +-
src/lib/ai-edition/store/useTimeline.ts | 4 ++
18 files changed, 116 insertions(+), 14 deletions(-)
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 1e313e34c..fd19b3f57 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -1643,6 +1643,41 @@
background: var(--danger-soft);
color: var(--danger);
}
+
+/* Where the user has ADDED a word: text with no audio behind it. A thin amber tick
+ over the waveform, at the moment the word sits on, wide enough to hit and no wider
+ — the clip underneath still has to be draggable everywhere else. Amber is the
+ colour the transcript pane gives the same word, so the two read as one thing. */
+.tlClipInsert {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 2;
+ width: 9px;
+ margin-left: -4px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+.tlClipInsert::before {
+ content: "";
+ position: absolute;
+ left: 3px;
+ top: 4px;
+ bottom: 4px;
+ width: 3px;
+ border-radius: 2px;
+ background: var(--warn);
+ box-shadow: 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent);
+ transition: box-shadow var(--motion-fast) var(--ease);
+}
+.tlClipInsert:hover::before,
+.tlClipInsert:focus-visible::before {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent),
+ 0 0 0 4px var(--warn-soft);
+}
.tlDropHint {
position: absolute;
inset: 0;
diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
index 8e515568c..f8d0b9b67 100644
--- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
@@ -75,6 +75,9 @@ function renderTimeline(
) {
const tl = {
clips,
+ // Marks for added words are read straight off the transcript (see the pane's
+ // amber words) — no project here has any.
+ transcripts: [],
assets,
annotationRegions: [annotation],
speedRegions: [],
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index f6c94e2ca..901863ad0 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -29,7 +29,7 @@ import { useScopedT } from "@/contexts/I18nContext";
import { useAudioPeaks } from "@/hooks/useAudioPeaks";
import { createId } from "@/lib/ai-edition/document/ids";
import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe";
-import type { AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutClip, AxcutWord } from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionStore";
@@ -509,6 +509,32 @@ export function V4Timeline({
label: `${(p.member.customScale ?? ZOOM_DEPTH_SCALES[p.member.depth]).toFixed(2)}×`,
sourceIds: p.ids,
}));
+ // Where the user has ADDED words. Derived from the transcript on every render and
+ // stored nowhere: the word carries `source: "synth"` and its own source time, so a mark
+ // built from it cannot drift from the amber word the transcript pane shows. Grouped by
+ // clip because each mark is positioned inside its clip's own box — it then travels with
+ // the clip through a reorder for free, with no ruler arithmetic of its own.
+ const insertedWordsByClip = useMemo(() => {
+ const byAsset = new Map();
+ for (const transcript of tl.transcripts) {
+ const added = transcript.words.filter((word) => word.source === "synth");
+ if (added.length > 0) byAsset.set(transcript.assetId, added);
+ }
+ if (byAsset.size === 0) return new Map>();
+ const out = new Map>();
+ for (const clip of clips) {
+ const words = byAsset.get(clip.assetId);
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ const span = sourceEnd - clip.sourceStartSec;
+ if (!words || span <= 0) continue;
+ const marks = words
+ .filter((word) => word.startSec >= clip.sourceStartSec && word.startSec <= sourceEnd)
+ .map((word) => ({ word, atPct: ((word.startSec - clip.sourceStartSec) / span) * 100 }));
+ if (marks.length > 0) out.set(clip.id, marks);
+ }
+ return out;
+ }, [tl.transcripts, clips]);
+
// trims: content-free (no per-instance text/settings), so touching rows —
// inevitable once a trim is ventilated across a clip boundary — are
// coalesced into one pill. This is what makes growing a trim across a
@@ -1578,6 +1604,25 @@ export function V4Timeline({
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId}
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => (
+ e.stopPropagation()}
+ onClick={(e) => {
+ // Jump to the moment the added text sits on. The clip box
+ // underneath would otherwise take this as a selection.
+ e.stopPropagation();
+ setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
+ }}
+ />
+ ))}
{selected ? (
{
function renderBars(atGainDb: number): string[] {
gainDb = atGainDb;
const tl = {
+ // Marks for added words come from the transcript; this project has none.
+ transcripts: [],
clips: [
{
id: "c0",
diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json
index 4412a0a0b..4a7500c53 100644
--- a/src/i18n/locales/ar/timeline.json
+++ b/src/i18n/locales/ar/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "يتطلب نصًا منسوخًا",
"smartCutsNoAudio": "لا يحتوي هذا الملف على صوت",
"smartCutsNoSpeech": "لم يتم اكتشاف كلام",
- "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط"
+ "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط",
+ "addedWord": "كلمة مضافة: \"{{word}}\" — لا صوت خلفها"
}
}
diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json
index c41966115..7c3287ca3 100644
--- a/src/i18n/locales/en/timeline.json
+++ b/src/i18n/locales/en/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Needs a transcript",
"smartCutsNoAudio": "This media has no audio",
"smartCutsNoSpeech": "No speech detected",
- "smartCutsFailed": "Transcription failed — retry it from Media"
+ "smartCutsFailed": "Transcription failed — retry it from Media",
+ "addedWord": "Added word: \"{{word}}\" — no audio behind it"
}
}
diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json
index 989289e00..b070072af 100644
--- a/src/i18n/locales/es/timeline.json
+++ b/src/i18n/locales/es/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Requiere una transcripción",
"smartCutsNoAudio": "Este medio no tiene audio",
"smartCutsNoSpeech": "No se detectó voz",
- "smartCutsFailed": "La transcripción falló: reinténtala desde Medios"
+ "smartCutsFailed": "La transcripción falló: reinténtala desde Medios",
+ "addedWord": "Palabra añadida: «{{word}}» — sin audio detrás"
}
}
diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json
index a35b8858a..4e4315982 100644
--- a/src/i18n/locales/fr/timeline.json
+++ b/src/i18n/locales/fr/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Nécessite une transcription",
"smartCutsNoAudio": "Ce média n'a pas d'audio",
"smartCutsNoSpeech": "Aucune parole détectée",
- "smartCutsFailed": "Échec de la transcription — relancez-la depuis Médias"
+ "smartCutsFailed": "Échec de la transcription — relancez-la depuis Médias",
+ "addedWord": "Mot ajouté : « {{word}} » — aucun son derrière"
}
}
diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json
index 09bb116ee..4bda89baa 100644
--- a/src/i18n/locales/it/timeline.json
+++ b/src/i18n/locales/it/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Richiede una trascrizione",
"smartCutsNoAudio": "Questo contenuto non ha audio",
"smartCutsNoSpeech": "Nessun parlato rilevato",
- "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali"
+ "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali",
+ "addedWord": "Parola aggiunta: «{{word}}» — nessun audio dietro"
}
}
diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json
index 68911ba91..a48c39f6a 100644
--- a/src/i18n/locales/ja-JP/timeline.json
+++ b/src/i18n/locales/ja-JP/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "文字起こしが必要です",
"smartCutsNoAudio": "このメディアには音声がありません",
"smartCutsNoSpeech": "音声が検出されませんでした",
- "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください"
+ "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください",
+ "addedWord": "追加した単語:「{{word}}」— 音声はありません"
}
}
diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json
index 8a100ee6d..9dc6d0f9b 100644
--- a/src/i18n/locales/ko-KR/timeline.json
+++ b/src/i18n/locales/ko-KR/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "받아쓰기가 필요합니다",
"smartCutsNoAudio": "이 미디어에는 오디오가 없습니다",
"smartCutsNoSpeech": "음성이 감지되지 않음",
- "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요"
+ "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요",
+ "addedWord": "추가한 단어: \"{{word}}\" — 뒤에 오디오가 없습니다"
}
}
diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json
index 5359feba9..34e7f9209 100644
--- a/src/i18n/locales/pt-BR/timeline.json
+++ b/src/i18n/locales/pt-BR/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Requer uma transcrição",
"smartCutsNoAudio": "Esta mídia não tem áudio",
"smartCutsNoSpeech": "Nenhuma fala detectada",
- "smartCutsFailed": "Falha na transcrição — tente de novo em Mídia"
+ "smartCutsFailed": "Falha na transcrição — tente de novo em Mídia",
+ "addedWord": "Palavra adicionada: \"{{word}}\" — sem áudio por trás"
}
}
diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json
index 387086953..46a52bd2b 100644
--- a/src/i18n/locales/ru/timeline.json
+++ b/src/i18n/locales/ru/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Нужна расшифровка",
"smartCutsNoAudio": "В этом медиафайле нет звука",
"smartCutsNoSpeech": "Речь не обнаружена",
- "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»"
+ "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»",
+ "addedWord": "Добавленное слово: «{{word}}» — за ним нет звука"
}
}
diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json
index d5a531f3e..b9124c176 100644
--- a/src/i18n/locales/tr/timeline.json
+++ b/src/i18n/locales/tr/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Bir döküm gerekiyor",
"smartCutsNoAudio": "Bu medyada ses yok",
"smartCutsNoSpeech": "Konuşma algılanmadı",
- "smartCutsFailed": "Metne dökme başarısız — Medya bölümünden yeniden deneyin"
+ "smartCutsFailed": "Metne dökme başarısız — Medya bölümünden yeniden deneyin",
+ "addedWord": "Eklenen kelime: \"{{word}}\" — arkasında ses yok"
}
}
diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json
index 1d963e585..eef9c43e3 100644
--- a/src/i18n/locales/vi/timeline.json
+++ b/src/i18n/locales/vi/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "Cần có bản phiên âm",
"smartCutsNoAudio": "Media này không có âm thanh",
"smartCutsNoSpeech": "Không phát hiện giọng nói",
- "smartCutsFailed": "Phiên âm thất bại — thử lại trong Media"
+ "smartCutsFailed": "Phiên âm thất bại — thử lại trong Media",
+ "addedWord": "Từ đã thêm: \"{{word}}\" — không có âm thanh phía sau"
}
}
diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json
index 1451e6d31..e8876d480 100644
--- a/src/i18n/locales/zh-CN/timeline.json
+++ b/src/i18n/locales/zh-CN/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "需要转录文本",
"smartCutsNoAudio": "此媒体没有音频",
"smartCutsNoSpeech": "未检测到语音",
- "smartCutsFailed": "转录失败 — 请在“媒体”中重试"
+ "smartCutsFailed": "转录失败 — 请在“媒体”中重试",
+ "addedWord": "已添加的词:“{{word}}” — 背后没有声音"
}
}
diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json
index 7f4ba9874..273360450 100644
--- a/src/i18n/locales/zh-TW/timeline.json
+++ b/src/i18n/locales/zh-TW/timeline.json
@@ -85,6 +85,7 @@
"smartCutsNeedsTranscript": "需要轉錄文字",
"smartCutsNoAudio": "此媒體沒有音訊",
"smartCutsNoSpeech": "未偵測到語音",
- "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試"
+ "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試",
+ "addedWord": "已加入的字詞:「{{word}}」— 背後沒有聲音"
}
}
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 7823459ec..518e655cc 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -1181,6 +1181,10 @@ export function useTimeline() {
cameraFullscreenRegions,
clips: document?.timeline.clips ?? [],
assets: document?.assets ?? [],
+ // The timeline marks where the user has ADDED words — text with no audio behind it.
+ // Read straight off the transcript: the word is the only record of an insert, and a
+ // mark derived from it can never disagree with the pane that shows the same word.
+ transcripts: document?.transcripts ?? [],
hasDoc,
selection,
multiSelection,
From 0258709d22271ed605524ec4337374b36cd0e5a1 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 23:00:12 +0200
Subject: [PATCH 60/84] feat(ai): let the chat correct a word it heard wrong
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The agent could read the transcript and cut it, and that was all. Asked to fix a
misheard name — the request the whole transcript-editing feature exists for — it
had exactly one tool that touched a word, `addTrim`, which removes the audio
along with it. It would either do the destructive thing or say it had no tool,
while the app had had the operation for three commits.
`setWordText` writes `transcript.words[].text` through the same document
function the pane uses, so the captions follow and the timeline does not move.
Empty text blanks the word, which is how a junk token leaves the captions
without cutting the speech around it. It is registered as a mutating tool: it
writes the document, so it passes the consent gate like every other edit.
It needed a read to address anything. `getTranscript` answers in SEGMENTS, whose
ids live in a different namespace than the words and are refused by name — the
trap a model would fall into first, so the refusal says which read hands out the
right ones. `getTranscriptWords` is that read: id, text, span, and — only when
the word is not plain transcription — where it came from and what the
transcriber had originally heard. It takes a span, because a half-hour
transcript is ~70k tokens and fixing one name should cost one phrase.
Inserting a word is deliberately NOT exposed. The gesture is dev-gated in the UI
until a voice can be synthesized for it, and handing the model a tool for
something a release build refuses to do would be the same dead affordance the
pane was careful not to advertise.
---
electron/ai-edition/agent-tools.test.ts | 157 ++++++++++++++++++
electron/ai-edition/agent-tools.ts | 108 ++++++++++++
.../ai-edition/deep-agent/service.test.ts | 16 +-
electron/ai-edition/deep-agent/service.ts | 8 +
4 files changed, 287 insertions(+), 2 deletions(-)
diff --git a/electron/ai-edition/agent-tools.test.ts b/electron/ai-edition/agent-tools.test.ts
index 8f8361e59..15e121f93 100644
--- a/electron/ai-edition/agent-tools.test.ts
+++ b/electron/ai-edition/agent-tools.test.ts
@@ -188,6 +188,7 @@ describe("the mutating-tool table", () => {
"setClipRange",
"setSpeed",
"setTrim",
+ "setWordText",
"setZoom",
].sort(),
);
@@ -2054,3 +2055,159 @@ describe("setZoom answers for the focus it kept", () => {
expect(result.resultJson).not.toContain("cursorAnchor");
});
});
+
+// ─── Correcting a word from the chat ─────────────────────────────
+// The model could READ the transcript and CUT it, and that was all. Asked to fix a
+// misheard name it had exactly one tool that touched a word — addTrim — which removes the
+// audio with it. These two close that: one read that hands out word ids, one write that
+// changes text and nothing else.
+
+/** A transcript with real words, one of them already corrected by the user. */
+function documentWithWords(): AxcutDocument {
+ const base = fixtureDocument();
+ return {
+ ...base,
+ transcripts: [
+ {
+ assetId: "asset_1",
+ language: "en",
+ segments: [
+ {
+ id: "seg_1",
+ kind: "speech",
+ startSec: 0,
+ endSec: 3,
+ text: "I use Cuber Nettes",
+ wordIds: ["word_1", "word_2", "word_3"],
+ },
+ ],
+ words: [
+ { id: "word_1", segmentId: "seg_1", startSec: 0, endSec: 1, text: "I" },
+ { id: "word_2", segmentId: "seg_1", startSec: 1, endSec: 2, text: "use" },
+ {
+ id: "word_3",
+ segmentId: "seg_1",
+ startSec: 2,
+ endSec: 3,
+ text: "Cuber Nettes",
+ },
+ ],
+ },
+ ],
+ };
+}
+
+function run(document: AxcutDocument, name: string, args: unknown) {
+ return executeAgentTool(document, name, JSON.stringify(args), { editsAllowed: true });
+}
+
+describe("getTranscriptWords", () => {
+ it("hands out the ids setWordText takes", () => {
+ const result = run(documentWithWords(), "getTranscriptWords", {});
+ const payload = JSON.parse(result.resultJson) as {
+ words: Array<{ id: string; text: string }>;
+ total: number;
+ };
+ expect(result.ok).toBe(true);
+ expect(payload.total).toBe(3);
+ expect(payload.words.map((w) => w.id)).toEqual(["word_1", "word_2", "word_3"]);
+ });
+
+ // A half-hour transcript is ~70k tokens. Fixing one name should cost one phrase.
+ it("returns only the words touching the span it is given", () => {
+ const result = run(documentWithWords(), "getTranscriptWords", { startSec: 2, endSec: 3 });
+ const payload = JSON.parse(result.resultJson) as {
+ words: Array<{ id: string }>;
+ total: number;
+ };
+ // Touching counts: `word_2` ends exactly where the span begins. Inclusive on
+ // purpose — a word with no duration at all (one the user typed in) sits on a
+ // single point, and a strict overlap would drop it from every span it meets.
+ expect(payload.words.map((w) => w.id)).toEqual(["word_2", "word_3"]);
+ // `total` still reports the whole transcript, so a filtered read never reads as
+ // the entire thing.
+ expect(payload.total).toBe(3);
+ });
+
+ it("says nothing about provenance for a plainly transcribed word", () => {
+ const result = run(documentWithWords(), "getTranscriptWords", {});
+ const payload = JSON.parse(result.resultJson) as { words: Array> };
+ expect(payload.words[0]).not.toHaveProperty("source");
+ expect(payload.words[0]).not.toHaveProperty("originalText");
+ });
+
+ it("names what the transcriber had heard, once a word is corrected", () => {
+ const corrected = run(documentWithWords(), "setWordText", {
+ wordId: "word_3",
+ text: "Kubernetes",
+ });
+ const result = run(corrected.document as AxcutDocument, "getTranscriptWords", {});
+ const payload = JSON.parse(result.resultJson) as {
+ words: Array<{ id: string; source?: string; originalText?: string }>;
+ };
+ expect(payload.words.find((w) => w.id === "word_3")).toMatchObject({
+ source: "user",
+ originalText: "Cuber Nettes",
+ });
+ });
+
+ it("refuses an asset with no transcript instead of answering with nothing", () => {
+ const result = run({ ...fixtureDocument(), transcripts: [] }, "getTranscriptWords", {});
+ expect(result.ok).toBe(false);
+ expect(result.resultJson).toContain("No transcript");
+ });
+});
+
+describe("setWordText", () => {
+ it("changes the text and leaves the timeline alone", () => {
+ const before = documentWithWords();
+ const result = run(before, "setWordText", { wordId: "word_3", text: "Kubernetes" });
+ expect(result.ok).toBe(true);
+ const next = result.document as AxcutDocument;
+ expect(next.transcripts[0].words.find((w) => w.id === "word_3")?.text).toBe("Kubernetes");
+ expect(next.timeline).toEqual(before.timeline);
+ expect(next.transcripts[0].segments[0].text).toBe("I use Kubernetes");
+ });
+
+ // The document carries the transcript twice; a write that reaches only one leaves the
+ // legacy mirror serving the old text forever.
+ it("writes the legacy mirror too", () => {
+ const result = run(documentWithWords(), "setWordText", {
+ wordId: "word_3",
+ text: "Kubernetes",
+ });
+ const next = result.document as AxcutDocument;
+ expect(next.transcript).toBe(next.transcripts.find((t) => t.assetId === "asset_1"));
+ });
+
+ it("empties a word without cutting the speech around it", () => {
+ const result = run(documentWithWords(), "setWordText", { wordId: "word_2", text: "" });
+ const next = result.document as AxcutDocument;
+ expect(next.transcripts[0].words.find((w) => w.id === "word_2")?.text).toBe("");
+ expect(next.transcripts[0].segments[0].text).toBe("I Cuber Nettes");
+ expect(JSON.parse(result.resultJson)).toMatchObject({ blanked: true });
+ });
+
+ it("points an unknown id at the read that hands them out", () => {
+ const result = run(documentWithWords(), "setWordText", { wordId: "seg_1", text: "x" });
+ expect(result.ok).toBe(false);
+ // `seg_1` is a real id — of a SEGMENT. The two namespaces are the trap.
+ expect(result.resultJson).toContain("getTranscriptWords");
+ });
+
+ it("refuses a write that would change nothing", () => {
+ const result = run(documentWithWords(), "setWordText", { wordId: "word_2", text: "use" });
+ expect(result.ok).toBe(false);
+ expect(result.document).toBeUndefined();
+ });
+
+ it("is a consented edit, not a read", () => {
+ const result = executeAgentTool(
+ documentWithWords(),
+ "setWordText",
+ JSON.stringify({ wordId: "word_3", text: "Kubernetes" }),
+ { editsAllowed: false },
+ );
+ expect(result.document).toBeUndefined();
+ });
+});
diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts
index 5a0966398..ff444b192 100644
--- a/electron/ai-edition/agent-tools.ts
+++ b/electron/ai-edition/agent-tools.ts
@@ -26,6 +26,7 @@ import {
replaceTimeline,
setClipSourceRange,
} from "../../src/lib/ai-edition/document/timeline";
+import { setDocumentWordText } from "../../src/lib/ai-edition/document/transcript";
import type { AxcutDocument } from "../../src/lib/ai-edition/schema";
import { hasAnyClipWithCamera } from "../../src/lib/ai-edition/timeline/camera";
import {
@@ -490,6 +491,18 @@ export const setCameraFullscreenArgs = z.object({
endSec: secondsSchema.optional(),
});
+export const getTranscriptWordsArgs = z.object({
+ assetId: z.string().min(1).optional(),
+ startSec: secondsSchema.optional(),
+ endSec: secondsSchema.optional(),
+});
+
+export const setWordTextArgs = z.object({
+ wordId: z.string().min(1),
+ text: z.string(),
+ assetId: z.string().min(1).optional(),
+});
+
export const removeTrimArgs = z.object({
trimRangeId: z.string().min(1),
});
@@ -525,7 +538,9 @@ export const removeClipArgs = z.object({
export const OPENSCREEN_TOOL_NAMES = [
"getCurrentDocument",
"getTranscript",
+ "getTranscriptWords",
"getCursorTrack",
+ "setWordText",
"addTrim",
"addTrims",
"setTrim",
@@ -592,6 +607,9 @@ export const PHANTOM_TOOL_NAMES = [
* remaining surfaces (descriptions, built tools, executor cases) to each other.
*/
export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([
+ // Writes the transcript, not the timeline — but it writes the document, so it is a
+ // consented edit like any other.
+ "setWordText",
"addTrim",
"addTrims",
"addZooms",
@@ -1203,6 +1221,96 @@ export function executeAgentTool(
};
}
+ // The word-level read. `getTranscript` answers in SEGMENTS, whose ids belong to a
+ // different namespace than the words — so on its own it cannot address anything
+ // `setWordText` takes. This is the one that can. It is separate rather than folded
+ // in because a whole transcript is already ~70k tokens and most turns never touch a
+ // word; the span filter is there so fixing one name costs one phrase, not the film.
+ case "getTranscriptWords": {
+ const parsed = getTranscriptWordsArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const assetId =
+ parsed.data.assetId ?? document.project.primaryAssetId ?? document.assets[0]?.id;
+ const transcript =
+ document.transcripts.find((t) => t.assetId === assetId) ??
+ (document.transcript?.assetId === assetId ? document.transcript : null);
+ if (!transcript) {
+ return failure(`No transcript for asset ${assetId ?? "(none)"}.`);
+ }
+ const from = parsed.data.startSec ?? Number.NEGATIVE_INFINITY;
+ const to = parsed.data.endSec ?? Number.POSITIVE_INFINITY;
+ const words = transcript.words
+ .filter((word) => word.endSec >= from && word.startSec <= to)
+ .map((word) => ({
+ id: word.id,
+ text: word.text,
+ startSec: word.startSec,
+ endSec: word.endSec,
+ // Only the words that are NOT plain transcription say so, so the common
+ // case costs nothing to read.
+ ...(word.source ? { source: word.source } : {}),
+ ...(word.originalText !== undefined ? { originalText: word.originalText } : {}),
+ }));
+ return {
+ ok: true,
+ resultJson: JSON.stringify({
+ assetId,
+ language: transcript.language,
+ total: transcript.words.length,
+ returned: words.length,
+ words,
+ }),
+ };
+ }
+
+ // Correcting what the transcriber HEARD. This writes text and nothing else: the
+ // captions follow it, the film does not move. The tool for making a spoken word go
+ // away is addTrim, which removes its audio with it.
+ case "setWordText": {
+ const parsed = setWordTextArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const assetId =
+ parsed.data.assetId ?? document.project.primaryAssetId ?? document.assets[0]?.id;
+ if (!assetId) return failure("Project has no assets — nothing to correct.");
+ const { wordId, text } = parsed.data;
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ const before = transcript?.words.find((word) => word.id === wordId);
+ if (!before) {
+ return failure(
+ `No word ${wordId} in the transcript for asset ${assetId}. ` +
+ `Call getTranscriptWords to read the ids.`,
+ );
+ }
+ if (before.text === text) {
+ return failure(`Word ${wordId} already reads "${text}" — nothing to change.`);
+ }
+ let next: AxcutDocument;
+ try {
+ next = setDocumentWordText(document, assetId, wordId, text);
+ } catch (error) {
+ return failure(error instanceof Error ? error.message : String(error));
+ }
+ const after = next.transcripts
+ .find((t) => t.assetId === assetId)
+ ?.words.find((word) => word.id === wordId);
+ return {
+ ok: true,
+ document: next,
+ resultJson: JSON.stringify({
+ wordId,
+ assetId,
+ text: after?.text ?? text,
+ was: before.text,
+ // Absent once the word is back to what the transcriber said — the pair is
+ // cleared on that round trip, and the model should be able to see it.
+ originalText: after?.originalText,
+ blanked: text.trim().length === 0,
+ }),
+ summary:
+ text.trim().length === 0 ? `blanked "${before.text}"` : `"${before.text}" → "${text}"`,
+ };
+ }
+
case "addTrim": {
const parsed = addTrimArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
diff --git a/electron/ai-edition/deep-agent/service.test.ts b/electron/ai-edition/deep-agent/service.test.ts
index 7729bd624..4a126ec0a 100644
--- a/electron/ai-edition/deep-agent/service.test.ts
+++ b/electron/ai-edition/deep-agent/service.test.ts
@@ -57,7 +57,9 @@ const PHANTOM_TOOLS: readonly string[] = PHANTOM_TOOL_NAMES;
const ARGS: Record = {
getCurrentDocument: {},
getTranscript: {},
+ getTranscriptWords: {},
getCursorTrack: {},
+ setWordText: { wordId: "word_1", text: "Hullo" },
addTrim: { startSec: 1, endSec: 2 },
addTrims: { ranges: [{ startSec: 1, endSec: 2 }] },
setTrim: { trimRangeId: "trim_1", startSec: 1, endSec: 2 },
@@ -109,9 +111,18 @@ function fixtureDocument(): AxcutDocument {
assetId: "asset_1",
language: "en",
segments: [
- { id: "seg_1", kind: "speech", startSec: 0, endSec: 5, text: "Hello", wordIds: [] },
+ {
+ id: "seg_1",
+ kind: "speech",
+ startSec: 0,
+ endSec: 5,
+ text: "Hello",
+ // A real word, so `setWordText` lands on its WRITE branch in the table
+ // below — a tool refused for an unknown id would look non-mutating.
+ wordIds: ["word_1"],
+ },
],
- words: [],
+ words: [{ id: "word_1", segmentId: "seg_1", startSec: 0, endSec: 5, text: "Hello" }],
},
],
timeline: {
@@ -351,6 +362,7 @@ describe("one description of the tools, not two", () => {
expect(OPENSCREEN_TOOLS.filter((n) => !isMutatingTool(n))).toEqual([
"getCurrentDocument",
"getTranscript",
+ "getTranscriptWords",
"getCursorTrack",
]);
});
diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts
index d804a3b1a..72598c09f 100644
--- a/electron/ai-edition/deep-agent/service.ts
+++ b/electron/ai-edition/deep-agent/service.ts
@@ -37,6 +37,7 @@ import {
executeAgentTool,
getCursorTrackArgs,
getTranscriptArgs,
+ getTranscriptWordsArgs,
isMutatingTool,
moveClipArgs,
removeClipArgs,
@@ -49,6 +50,7 @@ import {
setClipRangeArgs,
setSpeedArgs,
setTrimArgs,
+ setWordTextArgs,
setZoomArgs,
} from "../agent-tools";
import {
@@ -143,6 +145,10 @@ export const TOOL_DESCRIPTIONS: Record = {
"Read the transcript segments (speech and silence, with start/end seconds and text) for an asset. Omit assetId to read the primary asset's transcript.",
getCursorTrack:
"Read the recorded pointer track for an asset: where the cursor was over time, downsampled to a readable rate. Each point carries atSec (the asset's own source clock), virtualSec (the same instant on the edited timeline — the coordinate addZoom takes, null when no clip carries it), cx/cy as 0–1 fractions of the frame, and `shape`, an index into the pointer bitmaps the recording used (equal values are the same pointer; a change means the pointer changed, e.g. arrow to text caret). Points that are not plain moves carry `kind`; points a trim cuts out of playback carry `trimmed`. These are real samples, not a summary — reading what the pointer was doing is yours. Omit assetId for the primary asset. It answers `available:false` in two DIFFERENT ways you must not confuse: reason 'no-sidecar' means this asset was checked and genuinely has no telemetry, while reason 'unavailable' means it could not be read from here.",
+ getTranscriptWords:
+ 'Read the transcript one WORD at a time for an asset: each word\'s id, text, start/end seconds, and — only when it is not plain transcription — `source` ("user" for a word the user corrected, "synth" for one they typed in) and `originalText` (what the transcriber had heard before the correction). This is the ONLY read that gives you the ids setWordText takes; getTranscript answers in segments, whose ids belong to a different namespace and are not accepted there. A whole transcript is large, so pass startSec/endSec to read just the passage you mean to fix. Omit assetId for the primary asset.',
+ setWordText:
+ "Correct ONE word's text, by the id getTranscriptWords returns. This changes the TRANSCRIPT and nothing else: the captions follow it, the film is untouched and no audio is cut. Use it when the transcriber misheard something — a name, a technical term — and the user asks for it to read correctly. Passing an empty string BLANKS the word: it keeps its place in the media but leaves the captions, which is how a junk token like \"(inaudible)\" is removed without cutting the speech around it. Writing the transcriber's own text back clears the correction. This is NOT how you make a spoken word go away — that removes only the label and leaves the film saying it; use addTrim, which cuts the audio with it.",
addTrim:
"Add ONE trim range: a cut of a span inside a clip (this source-time span will not be played or exported) that does NOT split the clip. Times are in seconds of the asset's source time. This is the preferred (and for 'remove silences' requests, the only) way to handle silences; it preserves the user's placed clips and only adds a cut. When you have several cuts to make, use addTrims and send them together — this one is for a single cut or a later correction. A cut belongs to ONE clip: `clipId` is inferred when a single clip covers the range, but when several clips draw on the same asset over it the call FAILS and lists them — pass the `clipId` you mean (ids come from getCurrentDocument).",
addTrims:
@@ -323,7 +329,9 @@ export function buildTools(
return [
build("getCurrentDocument", z.object({})),
build("getTranscript", getTranscriptArgs),
+ build("getTranscriptWords", getTranscriptWordsArgs),
build("getCursorTrack", getCursorTrackArgs),
+ build("setWordText", setWordTextArgs),
build("addTrim", addTrimArgs),
build("addTrims", addTrimsArgs),
build("setTrim", setTrimArgs),
From 24ae84275a66ad0a50fee4df77852a6caa3d89e1 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Tue, 1 Sep 2026 09:05:36 +0200
Subject: [PATCH 61/84] feat(document): store the pause an added word needs, as
a region
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adding a word only ever borrowed the silence that happened to be free where it
landed, so a word dropped between two words that run into each other got no time
at all — nothing to see on the timeline, nothing for a voice to speak into.
The pause is now a record of its own: `timeline.insertRanges`, anchored in
source time, shaped like a trim and stored for the same reason. It is the
inverse operation, and a region is the one shape this timeline already carries
safely from end to end. The first attempt made CLIPS for it and lost both the
pauses and the words they belonged to, because every other writer of
`timeline.clips` — the duration probe, the recording import, resequencing — is
entitled to disagree with a clip it did not make.
Stored, but with one writer and one invariant. `withInsertRangesForWords` runs
after every word write and is the only thing that touches the array: it adds the
pause an added word needs, resizes one whose text changed length, and drops the
ones whose word is gone. `insertRangesMatchWords` is that same rule read back,
so a test holds the writer to it rather than trusting it. A pause under 50ms is
not stored at all — a few frames of held image is a stutter, not a slot.
`timeline/inserted-time.ts` is the arithmetic the readers will need, pure and on
its own: where a pause lands on the ruler once projected through the clip that
plays its moment, and the pair that converts between stored raw seconds and the
seconds the user actually scrubs. They are inverses everywhere except inside a
pause, where a stretch of ruler stands for one held source moment — so the
collapse answers with that moment AND says it is being held, which is what a
caller driving a decoder needs to park rather than seek.
Nothing reads it yet. The ruler, playback and the captions come next; this is
the record and the arithmetic they will share.
---
.../ai-edition/EditorEmptyState.test.tsx | 1 +
.../ExportDialog.showInFolder.test.tsx | 1 +
.../ai-edition/ExportDialog.test.ts | 1 +
.../ai-edition/WebcamOverlay.test.tsx | 1 +
.../ai-edition/document/outputFormat.test.ts | 1 +
src/lib/ai-edition/document/timeline.test.ts | 9 +
.../ai-edition/document/transcribe.test.ts | 1 +
.../ai-edition/document/transcript.test.ts | 100 ++++++++++-
src/lib/ai-edition/document/transcript.ts | 130 ++++++++++++++-
src/lib/ai-edition/schema/index.ts | 34 ++++
.../ai-edition/store/editorSettings.test.ts | 1 +
src/lib/ai-edition/store/projectStore.test.ts | 1 +
.../ai-edition/store/undo.modalGuard.test.tsx | 1 +
src/lib/ai-edition/store/useCaptions.test.ts | 1 +
.../store/useEditorSettings.test.ts | 1 +
src/lib/ai-edition/store/useTimeline.test.ts | 1 +
.../ai-edition/timeline/inserted-time.test.ts | 157 ++++++++++++++++++
src/lib/ai-edition/timeline/inserted-time.ts | 108 ++++++++++++
.../ai-edition/transcription/status.test.ts | 1 +
src/native/sceneDescription.test.ts | 1 +
20 files changed, 544 insertions(+), 8 deletions(-)
create mode 100644 src/lib/ai-edition/timeline/inserted-time.test.ts
create mode 100644 src/lib/ai-edition/timeline/inserted-time.ts
diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx
index acb513e4b..a4c6cd2b8 100644
--- a/src/components/ai-edition/EditorEmptyState.test.tsx
+++ b/src/components/ai-edition/EditorEmptyState.test.tsx
@@ -47,6 +47,7 @@ const sampleDoc = vi.hoisted(
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
index c5b45637c..f467fcc92 100644
--- a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
+++ b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
@@ -71,6 +71,7 @@ const DOC: AxcutDocument = {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts
index 3aa8d85b5..fa61fec61 100644
--- a/src/components/ai-edition/ExportDialog.test.ts
+++ b/src/components/ai-edition/ExportDialog.test.ts
@@ -51,6 +51,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx
index 16da96666..019c0d415 100644
--- a/src/components/ai-edition/WebcamOverlay.test.tsx
+++ b/src/components/ai-edition/WebcamOverlay.test.tsx
@@ -68,6 +68,7 @@ function makeDocument(): AxcutDocument {
clips: [CLIP_WITH_CAMERA, CLIP_WITHOUT_CAMERA],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts
index 4ceac442f..a3f4cc764 100644
--- a/src/lib/ai-edition/document/outputFormat.test.ts
+++ b/src/lib/ai-edition/document/outputFormat.test.ts
@@ -61,6 +61,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index a561dd2f9..fcc43c56f 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -51,6 +51,7 @@ function makeDoc(overrides: Partial = {}): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -210,6 +211,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -307,6 +309,7 @@ describe("timeline pure functions", () => {
clips: [],
gaps: [],
trimRanges: [makeTrim({ id: "trim_other", assetId: "asset_2", startSec: 1, endSec: 2 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -408,6 +411,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -533,6 +537,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -609,6 +614,7 @@ describe("timeline pure functions", () => {
trimRanges: [
{ id: "s1", assetId: "asset_1", startSec: 10, endSec: 20, origin: "user", reason: "" },
],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -646,6 +652,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -877,6 +884,7 @@ describe("duplicateClip / moveClip", () => {
...makeDoc().timeline,
clips: [makeClip({ id: "clip_a", sourceStartSec: 0, sourceEndSec: 10 })],
trimRanges: [makeTrim({ id: "t1", clipId: "clip_a", startSec: 2, endSec: 4 })],
+ insertRanges: [],
},
});
const next = duplicateClip(doc, "clip_a");
@@ -1230,6 +1238,7 @@ describe("removeRegion — the one shared region-delete mutator", () => {
timeline: {
...makeDoc().timeline,
trimRanges: [makeTrim({ id: "trim_1" }), makeTrim({ id: "trim_2" })],
+ insertRanges: [],
},
});
const next = removeRegion(doc, "trim", "trim_1");
diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts
index 034abf212..2e760c16c 100644
--- a/src/lib/ai-edition/document/transcribe.test.ts
+++ b/src/lib/ai-edition/document/transcribe.test.ts
@@ -43,6 +43,7 @@ function makeDoc(): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 9e8ace067..2f531542e 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
-import { type AxcutTranscript, createEmptyDocument } from "../schema";
+import { type AxcutTranscript, createEmptyDocument, documentSchema } from "../schema";
import {
carryOverWordEdits,
insertDocumentWord,
+ insertRangesMatchWords,
insertWord,
removeDocumentWords,
removeWord,
@@ -667,3 +668,100 @@ describe("carryOverWordEdits with inserted words", () => {
expect(result.transcript.words.some((w) => w.text === "really")).toBe(true);
});
});
+
+// ─── The pause an added word needs ───────────────────────────────
+// Created time is STORED, as a region beside the trims. Something has to keep those
+// records true against the words they belong to, and `withInsertRangesForWords` is the one
+// writer — these hold it to the invariant it maintains. The first attempt at this made
+// CLIPS instead, and every other writer of `timeline.clips` disagreed with them.
+
+describe("insert ranges", () => {
+ function docWithClip() {
+ const doc = makeDoc();
+ return {
+ ...doc,
+ timeline: {
+ ...doc.timeline,
+ clips: [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ],
+ },
+ };
+ }
+
+ it("stores a pause when the free silence does not cover the word", () => {
+ // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
+ // borrows nothing and needs its whole reading time — max(0.4, 6/15) = 0.4s.
+ const result = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ expect(result.timeline.insertRanges).toHaveLength(1);
+ expect(result.timeline.insertRanges[0]).toMatchObject({
+ assetId: "asset_1",
+ wordId: "synth_1",
+ atSec: 3,
+ durationSec: 0.4,
+ origin: "user",
+ });
+ expect(insertRangesMatchWords(result)).toBe(true);
+ });
+
+ // The clips are the thing the first attempt broke. Nothing here may touch them.
+ it("leaves the clips exactly as they were", () => {
+ const before = docWithClip();
+ const result = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
+ expect(result.timeline.clips).toEqual(before.timeline.clips);
+ });
+
+ it("stores nothing when the word fits in silence that is already there", () => {
+ // word_3 ends at 4 and word_4 starts at 5: a full second, more than "really" needs.
+ const result = insertDocumentWord(docWithClip(), "asset_1", "word_3", "after", "really");
+ expect(result.timeline.insertRanges).toEqual([]);
+ expect(insertRangesMatchWords(result)).toBe(true);
+ });
+
+ it("resizes the pause when the word is rewritten longer", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const longer = setDocumentWordText(added, "asset_1", "synth_1", "really quite genuinely so");
+ const [range] = longer.timeline.insertRanges;
+ expect(range.durationSec).toBeCloseTo(25 / 15, 5);
+ expect(range.id).toBe(added.timeline.insertRanges[0].id);
+ expect(insertRangesMatchWords(longer)).toBe(true);
+ });
+
+ it("drops the pause with the word", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const gone = removeDocumentWords(added, "asset_1", ["synth_1"]);
+ expect(gone.timeline.insertRanges).toEqual([]);
+ expect(insertRangesMatchWords(gone)).toBe(true);
+ });
+
+ it("keeps one pause per added word, and no more", () => {
+ let doc = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ doc = insertDocumentWord(doc, "asset_1", "word_1", "after", "personally");
+ expect(doc.timeline.insertRanges).toHaveLength(2);
+ expect(new Set(doc.timeline.insertRanges.map((r) => r.wordId)).size).toBe(2);
+ expect(insertRangesMatchWords(doc)).toBe(true);
+ });
+
+ // Correcting a SPOKEN word must not invent a pause: it has audio behind it already.
+ it("stores nothing for an ordinary correction", () => {
+ const result = setDocumentWordText(docWithClip(), "asset_1", "word_3", "OpenScreenApp");
+ expect(result.timeline.insertRanges).toEqual([]);
+ });
+
+ it("survives the document schema", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const parsed = documentSchema.parse(JSON.parse(JSON.stringify(added)));
+ expect(parsed.timeline.insertRanges).toHaveLength(1);
+ expect(insertRangesMatchWords(parsed)).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index dec291474..331c2d7ec 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,4 +1,5 @@
-import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
+import type { AxcutDocument, AxcutInsertRange, AxcutTranscript, AxcutWord } from "../schema";
+import { createId } from "./ids";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -144,7 +145,12 @@ export function setDocumentWordText(
if (!transcript) {
throw new Error(`Cannot edit a word of asset "${assetId}": it has no transcript`);
}
- return withTranscript(document, setWordText(transcript, wordId, text));
+ // Rewriting an added word changes how long it takes to read, so its pause is resized
+ // here too — the one writer, whatever the edit was.
+ return withInsertRangesForWords(
+ withTranscript(document, setWordText(transcript, wordId, text)),
+ assetId,
+ );
}
/** Where a new word goes relative to the word the caret was resting on. */
@@ -301,8 +307,112 @@ export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTr
};
}
+/**
+ * How much created time an added word still needs, on top of the silence it borrowed.
+ *
+ * Zero when the pause it landed in was already long enough — an added word between two
+ * sentences costs the film nothing.
+ */
+function pauseDeficitSec(word: AxcutWord): number {
+ const borrowed = word.endSec - word.startSec;
+ return Math.max(0, readingSeconds(word.text) - borrowed);
+}
+
+/** Below this, a pause is not worth a record — a few milliseconds of held frame is a
+ * stutter, not a slot to speak in. */
+const MIN_PAUSE_SEC = 0.05;
+
+/**
+ * Bring the document's insert ranges back in line with its words.
+ *
+ * The ranges are STORED, so something has to keep them true; this is that something, and
+ * it is the only writer. Called after every word write, it adds the pause an added word
+ * needs, resizes one whose text changed length, and drops the ones whose word is gone —
+ * so no caller has to remember any of the three. `insertRangesMatchWords` is the same rule
+ * read back, for a test to hold this to.
+ */
+function withInsertRangesForWords(document: AxcutDocument, assetId: string): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ const words = transcript?.words ?? [];
+ const wanted = new Map();
+ for (const word of words) {
+ if (word.source !== "synth") continue;
+ const deficit = pauseDeficitSec(word);
+ if (deficit >= MIN_PAUSE_SEC) wanted.set(word.id, deficit);
+ }
+
+ const existing = document.timeline.insertRanges;
+ const kept: AxcutInsertRange[] = [];
+ const seen = new Set();
+ for (const range of existing) {
+ // Ranges for OTHER assets are none of this call's business.
+ if (range.assetId !== assetId) {
+ kept.push(range);
+ continue;
+ }
+ const durationSec = wanted.get(range.wordId);
+ if (durationSec === undefined) continue; // its word is gone, or needs no pause now
+ seen.add(range.wordId);
+ const word = words.find((w) => w.id === range.wordId);
+ const atSec = word?.endSec ?? range.atSec;
+ kept.push(
+ durationSec === range.durationSec && atSec === range.atSec
+ ? range
+ : { ...range, atSec, durationSec },
+ );
+ }
+ for (const [wordId, durationSec] of wanted) {
+ if (seen.has(wordId)) continue;
+ const word = words.find((w) => w.id === wordId);
+ if (!word) continue;
+ kept.push({
+ id: createId("insert"),
+ assetId,
+ atSec: word.endSec,
+ durationSec,
+ wordId,
+ reason: `Held frame for the added word "${word.text}".`,
+ origin: "user",
+ });
+ }
+
+ if (kept.length === existing.length && kept.every((range, i) => range === existing[i])) {
+ return document;
+ }
+ return { ...document, timeline: { ...document.timeline, insertRanges: kept } };
+}
+
+/**
+ * The invariant {@link withInsertRangesForWords} maintains, read back: every stored pause
+ * belongs to an added word that still needs one, sits where that word ends, and lasts what
+ * its text needs. Exported for the test that holds the writer to it.
+ */
+export function insertRangesMatchWords(document: AxcutDocument): boolean {
+ const byAsset = new Map(document.transcripts.map((t) => [t.assetId, t]));
+ const expected = new Set();
+ for (const transcript of document.transcripts) {
+ for (const word of transcript.words) {
+ if (word.source === "synth" && pauseDeficitSec(word) >= MIN_PAUSE_SEC) {
+ expected.add(`${transcript.assetId}::${word.id}`);
+ }
+ }
+ }
+ const seen = new Set();
+ for (const range of document.timeline.insertRanges) {
+ const key = `${range.assetId}::${range.wordId}`;
+ if (!expected.has(key) || seen.has(key)) return false;
+ seen.add(key);
+ const word = byAsset.get(range.assetId)?.words.find((w) => w.id === range.wordId);
+ if (!word) return false;
+ if (range.atSec !== word.endSec) return false;
+ if (Math.abs(range.durationSec - pauseDeficitSec(word)) > 1e-9) return false;
+ }
+ return seen.size === expected.size;
+}
+
/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does. */
+ * reason {@link setDocumentWordText} does, and leaves behind the pause the new word
+ * needs — see {@link withInsertRangesForWords}. */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -314,7 +424,10 @@ export function insertDocumentWord(
if (!transcript) {
throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
}
- return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+ return withInsertRangesForWords(
+ withTranscript(document, insertWord(transcript, anchorWordId, side, text)),
+ assetId,
+ );
}
/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
@@ -329,9 +442,12 @@ export function removeDocumentWords(
if (!transcript) {
throw new Error(`Cannot remove a word from asset "${assetId}": it has no transcript`);
}
- return withTranscript(
- document,
- wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
+ return withInsertRangesForWords(
+ withTranscript(
+ document,
+ wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
+ ),
+ assetId,
);
}
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index 9a511fee5..a6d0bb32f 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -269,6 +269,34 @@ export const trimRangeSchema = endGteStart(
"startSec",
);
+/**
+ * Time the film does NOT have: the pause an added word needs so a synthesized voice will
+ * have somewhere to speak. The film holds the frame at `atSec` for `durationSec`, screen
+ * and webcam together, and everything after it shifts.
+ *
+ * The exact inverse of a trim, and stored the same way and for the same reason. The first
+ * attempt created CLIPS for this; every other writer of `timeline.clips` — the duration
+ * probe, the recording import, resequencing — is entitled to disagree with a clip it did
+ * not make, and they did: a project came back split twice, both pauses gone, and the words
+ * they belonged to with them. A region is the shape this timeline already carries safely.
+ *
+ * `wordId` is what makes it derived-in-spirit while stored in fact: `document/transcript.ts`
+ * is the only writer, it creates the range with the word and drops it with the word, and
+ * `insertRangesMatchWords` is the invariant a test holds it to. Nothing else may write one.
+ */
+export const insertRangeSchema = z.object({
+ id: z.string().min(1),
+ assetId: z.string().min(1),
+ /** Source moment the film holds on. */
+ atSec: z.number().nonnegative(),
+ /** Timeline time created. Always positive — a pause of zero is simply not stored. */
+ durationSec: z.number().positive(),
+ /** The transcript word this pause exists for. */
+ wordId: z.string().min(1),
+ reason: z.string().default(""),
+ origin: z.enum(["system", "agent", "user"]),
+});
+
export const timelineSchema = z.preprocess(
// Back-compat: the field was renamed skipRanges → trimRanges. Old persisted
// documents (disk + browser-shim localStorage) still carry `skipRanges`;
@@ -286,6 +314,10 @@ export const timelineSchema = z.preprocess(
clips: z.array(clipSchema).default([]),
gaps: z.array(gapSchema).default([]),
trimRanges: z.array(trimRangeSchema).default([]),
+ // Additive, like every optional field before it: absent on every document written
+ // before this, so no schema bump — an older build simply drops the key on save, and
+ // the words it belonged to keep their text and lose only their pause.
+ insertRanges: z.array(insertRangeSchema).default([]),
muteRanges: z.array(rangeSchema).default([]),
speedRanges: z.array(rangeSchema).default([]),
captionRanges: z.array(rangeSchema).default([]),
@@ -514,6 +546,7 @@ const documentSchemaShape = z.object({
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -954,6 +987,7 @@ export type AxcutClip = z.infer;
export type AxcutClipCropRegion = z.infer;
export type AxcutGap = z.infer;
export type AxcutTrimRange = z.infer;
+export type AxcutInsertRange = z.infer;
export type AxcutTimeline = z.infer;
export type AxcutTimelineOperation = z.infer;
export type AxcutAnnotationRegion = z.infer;
diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts
index d7cfdfcf4..953a69fe6 100644
--- a/src/lib/ai-edition/store/editorSettings.test.ts
+++ b/src/lib/ai-edition/store/editorSettings.test.ts
@@ -23,6 +23,7 @@ const baseDoc: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts
index a46a6b535..4c2fbde39 100644
--- a/src/lib/ai-edition/store/projectStore.test.ts
+++ b/src/lib/ai-edition/store/projectStore.test.ts
@@ -56,6 +56,7 @@ const sampleDoc = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/undo.modalGuard.test.tsx b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
index f54b12389..806f765a7 100644
--- a/src/lib/ai-edition/store/undo.modalGuard.test.tsx
+++ b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
@@ -29,6 +29,7 @@ function doc(title: string): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useCaptions.test.ts b/src/lib/ai-edition/store/useCaptions.test.ts
index b4063d68c..aa4c8d536 100644
--- a/src/lib/ai-edition/store/useCaptions.test.ts
+++ b/src/lib/ai-edition/store/useCaptions.test.ts
@@ -65,6 +65,7 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useEditorSettings.test.ts b/src/lib/ai-edition/store/useEditorSettings.test.ts
index bd47cb452..f9f645eb7 100644
--- a/src/lib/ai-edition/store/useEditorSettings.test.ts
+++ b/src/lib/ai-edition/store/useEditorSettings.test.ts
@@ -68,6 +68,7 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts
index 78854e817..587f5d697 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -104,6 +104,7 @@ const sampleDoc: AxcutDocument = {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
new file mode 100644
index 000000000..4dd217209
--- /dev/null
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -0,0 +1,157 @@
+// The ruler arithmetic behind an added word's pause.
+//
+// The one thing these have to pin: stored raw seconds and the seconds the user scrubs stop
+// being the same number the moment a pause exists, and every reader that confuses the two
+// puts a region, a playhead or a caption in the wrong place. The pair is an inverse
+// everywhere except inside a pause — which is not a gap in the model, it is the pause.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
+import {
+ collapseRawSec,
+ expandRawSec,
+ type RulerInsert,
+ rulerInserts,
+ totalInsertedSec,
+} from "./inserted-time";
+
+function clip(overrides: Partial & Pick): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...overrides,
+ };
+}
+
+function insert(overrides: Partial = {}): AxcutInsertRange {
+ return {
+ id: "ins_1",
+ assetId: "a1",
+ atSec: 4,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user",
+ ...overrides,
+ };
+}
+
+describe("rulerInserts", () => {
+ it("projects a pause through the clip that plays its moment", () => {
+ // The clip plays source 4–10 starting at ruler 20, so source 6 is ruler 22.
+ const clips = [clip({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 })];
+ expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([
+ { id: "ins_1", wordId: "synth_1", atRawSec: 22, durationSec: 0.5 },
+ ]);
+ });
+
+ // The word is not on the timeline, so its pause has no place on the ruler and adds
+ // nothing — the same rule a caption line follows when no clip covers it.
+ it("drops a pause no clip plays", () => {
+ const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 })];
+ expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([]);
+ });
+
+ it("counts a pause sitting exactly on a clip's edge", () => {
+ // A pause sits at the END of the word it follows, which is routinely the boundary.
+ const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 })];
+ expect(rulerInserts([insert({ atSec: 4 })], clips)).toHaveLength(1);
+ });
+
+ it("returns them in ruler order, whatever order they were stored in", () => {
+ const clips = [clip({ id: "c1" })];
+ const placed = rulerInserts(
+ [insert({ id: "b", atSec: 8 }), insert({ id: "a", atSec: 2 })],
+ clips,
+ );
+ expect(placed.map((p) => p.id)).toEqual(["a", "b"]);
+ });
+
+ it("places a pause only once when two clips could play its moment", () => {
+ const clips = [
+ clip({ id: "c1" }),
+ clip({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
+ ];
+ expect(rulerInserts([insert()], clips)).toHaveLength(1);
+ });
+});
+
+describe("the expanded ruler", () => {
+ const INSERTS: RulerInsert[] = [
+ { id: "a", wordId: "w_a", atRawSec: 2, durationSec: 0.5 },
+ { id: "b", wordId: "w_b", atRawSec: 6, durationSec: 1 },
+ ];
+
+ it("leaves everything before the first pause where it was", () => {
+ expect(expandRawSec(0, INSERTS)).toBe(0);
+ expect(expandRawSec(1.9, INSERTS)).toBe(1.9);
+ });
+
+ // The frame about to be held keeps its own instant; the pause opens after it.
+ it("keeps the held moment itself in place", () => {
+ expect(expandRawSec(2, INSERTS)).toBe(2);
+ });
+
+ it("shifts everything after a pause by what it added", () => {
+ expect(expandRawSec(3, INSERTS)).toBe(3.5);
+ expect(expandRawSec(6, INSERTS)).toBe(6.5);
+ expect(expandRawSec(7, INSERTS)).toBe(8.5);
+ });
+
+ it("grows the ruler by the pauses' total", () => {
+ expect(totalInsertedSec(INSERTS)).toBe(1.5);
+ expect(expandRawSec(10, INSERTS)).toBe(10 + totalInsertedSec(INSERTS));
+ });
+
+ it("round-trips every moment that is not inside a pause", () => {
+ for (const sec of [0, 1.9, 3, 5.99, 7, 10]) {
+ const back = collapseRawSec(expandRawSec(sec, INSERTS), INSERTS);
+ expect(back.sec).toBeCloseTo(sec, 9);
+ expect(back.heldBy).toBeNull();
+ }
+ });
+
+ // The held moment is the one place the pair is not a clean inverse, and it is not
+ // meant to be: source 2 occupies the WHOLE of ruler [2, 2.5) — it is what the pause
+ // shows. Expanding picks the start of that stretch; collapsing it back answers with
+ // the same source moment and says it is being held, which is the honest reading of a
+ // moment that is on screen for half a second.
+ it("says the held moment is held, and still names the right source moment", () => {
+ const back = collapseRawSec(expandRawSec(2, INSERTS), INSERTS);
+ expect(back.sec).toBe(2);
+ expect(back.heldBy?.id).toBe("a");
+ });
+
+ // Not a gap in the model — this IS the pause. A stretch of ruler stands for one held
+ // source moment, and the caller is told which pause is holding it so it parks the
+ // decoder instead of seeking through content that belongs after.
+ it("collapses a moment inside a pause onto the frame being held", () => {
+ for (const sec of [2.01, 2.25, 2.49]) {
+ const back = collapseRawSec(sec, INSERTS);
+ expect(back.sec).toBe(2);
+ expect(back.heldBy?.id).toBe("a");
+ }
+ });
+
+ it("resumes on the far side of a pause", () => {
+ const back = collapseRawSec(2.5, INSERTS);
+ expect(back.sec).toBe(2);
+ expect(back.heldBy).toBeNull();
+ });
+
+ it("counts every earlier pause when collapsing a later moment", () => {
+ // Ruler 8.5 is source 7: 0.5s from the first pause and 1s from the second.
+ expect(collapseRawSec(8.5, INSERTS)).toEqual({ sec: 7, heldBy: null });
+ });
+
+ it("is the identity when there are no pauses", () => {
+ expect(expandRawSec(4, [])).toBe(4);
+ expect(collapseRawSec(4, [])).toEqual({ sec: 4, heldBy: null });
+ });
+});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
new file mode 100644
index 000000000..a7afcf27b
--- /dev/null
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -0,0 +1,108 @@
+// Time the film does not have.
+//
+// An added word needs somewhere to be spoken. Where the transcript has free silence it
+// borrows it; where it does not, the film holds its frame and everything after it moves
+// along the ruler. That created time is stored as an `AxcutInsertRange` — the inverse of a
+// trim, and deliberately the same shape, because a region is what this timeline already
+// carries safely from end to end. (An earlier attempt made CLIPS for it; see the schema's
+// note on `insertRangeSchema` for how that ended.)
+//
+// This module is the arithmetic, and nothing else: pure, no document, no React. It answers
+// two questions.
+//
+// • Where does a pause land on the RULER? A range is anchored in SOURCE time, so it has
+// to be projected through whichever clip plays that moment — `rulerInserts`.
+// • What does the ruler look like once the pauses are counted? Stored raw seconds and
+// the seconds the user actually scrubs are no longer the same number, and
+// `expandRawSec` / `collapseRawSec` are the one place that difference is resolved.
+//
+// The two are inverses everywhere except INSIDE a pause, where they cannot be: a stretch
+// of ruler maps to the single source moment being held. `collapseRawSec` returns that
+// moment, which is exactly what a decoder parked on a held frame should be told.
+
+import type { AxcutClip, AxcutInsertRange } from "../schema";
+
+/** A pause placed on the raw ruler, ready to be counted. */
+export interface RulerInsert {
+ id: string;
+ wordId: string;
+ /** Where the pause begins, in STORED raw seconds — before any pause is counted. */
+ atRawSec: number;
+ durationSec: number;
+}
+
+/**
+ * Project each insert onto the raw ruler through the clip that plays its source moment.
+ *
+ * A range whose moment no clip plays yields nothing: the pause exists for a word that is
+ * not on the timeline, so there is no ruler position for it and nothing to add. Same rule
+ * the captions follow for a line no clip covers.
+ *
+ * Ordered by ruler position, which is what lets the accumulation below be a single pass.
+ */
+export function rulerInserts(
+ inserts: readonly AxcutInsertRange[],
+ clips: readonly AxcutClip[],
+): RulerInsert[] {
+ const placed: RulerInsert[] = [];
+ for (const insert of inserts) {
+ for (const clip of clips) {
+ if (clip.assetId !== insert.assetId) continue;
+ const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
+ // Inclusive at both edges: a pause sits at the END of the word it follows, which
+ // is routinely a clip's own boundary.
+ if (insert.atSec < clip.sourceStartSec || insert.atSec > sourceEnd) continue;
+ placed.push({
+ id: insert.id,
+ wordId: insert.wordId,
+ atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec),
+ durationSec: insert.durationSec,
+ });
+ break;
+ }
+ }
+ return placed.sort((a, b) => a.atRawSec - b.atRawSec);
+}
+
+/** How much time the pauses add in total — what the ruler grows by. */
+export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
+ return inserts.reduce((sum, insert) => sum + insert.durationSec, 0);
+}
+
+/**
+ * Stored raw seconds → the ruler the user sees.
+ *
+ * Monotone and total: every stored moment has exactly one place on the expanded ruler.
+ * A moment sitting exactly ON a pause maps to where the pause BEGINS, so the frame that
+ * is about to be held keeps its own instant and the pause opens after it.
+ */
+export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): number {
+ let out = sec;
+ for (const insert of inserts) {
+ if (insert.atRawSec < sec) out += insert.durationSec;
+ }
+ return out;
+}
+
+/**
+ * The ruler the user sees → stored raw seconds.
+ *
+ * The inverse of {@link expandRawSec} outside a pause. Inside one it cannot be an inverse
+ * — a whole stretch of ruler stands for a single held moment — and it returns that moment,
+ * flagged, so a caller driving a decoder knows to hold rather than to seek.
+ */
+export function collapseRawSec(
+ sec: number,
+ inserts: readonly RulerInsert[],
+): { sec: number; heldBy: RulerInsert | null } {
+ let offset = 0;
+ for (const insert of inserts) {
+ const startsAt = insert.atRawSec + offset;
+ if (sec < startsAt) break;
+ if (sec < startsAt + insert.durationSec) {
+ return { sec: insert.atRawSec, heldBy: insert };
+ }
+ offset += insert.durationSec;
+ }
+ return { sec: sec - offset, heldBy: null };
+}
diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts
index 00261712e..9f3e7cdee 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -270,6 +270,7 @@ describe("transcriptRelevantAssetIds", () => {
})),
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index 4fc088e86..34e026578 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -84,6 +84,7 @@ function makeDoc(
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
From 0ea2f384a70ff66753e557473d7747a517357c32 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Tue, 1 Sep 2026 10:17:26 +0200
Subject: [PATCH 62/84] feat(editor): the readers count the pause an added word
bought
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The record and the arithmetic existed; nothing consulted them, so the added time
was real in the document and invisible everywhere else. Now every reader that
measures the timeline measures the same expanded ruler.
Playback holds the frame. `resolvePlaybackSegments` cuts the kept span open at
each pause's moment and puts a held segment between the halves — the stream
plays up to that frame, stays on it, then carries on, which is what makes the
film longer. It carries `heldSec` on `PlaybackSegment`, a type that exists only
on the derived shape: nothing can write the field to a stored clip, which is the
whole structural difference from the attempt that made clips for this. A pause
whose moment a trim removed is never emitted — that moment is not in the film
any more, so neither is its pause.
The decoder holds with it. `resolveNativePosition` clamps the source clock
inside a held segment, and the transport pauses the native side for its
duration; free-running would play what comes after while the app clock, which
does traverse the pause, re-seeks on the drift and stutters. Screen and webcam
hold together because both derive from the one asset source clock the pause
stops advancing.
The ruler counts it. Total, ticks, clip boxes, lane pills and the playhead are
all placed through `expandRawSec`, so nothing drifts from anything else by the
added time. The amber mark on the clip becomes a BAND exactly as wide as the
time it bought — a word that fitted in silence already there adds nothing and
stays the hairline it was. Stored clip geometry is never rewritten for any of
this; only what is drawn moves.
The captions follow. Expanding both ends of a line's ruler span does the whole
job: a line after a pause slides along by it, and a line covering the held
moment has only its end pushed out, so it stays on screen through the pause
instead of going dark over the one moment an added word exists for.
---
src/components/ai-edition/NewEditorShell.tsx | 1 +
src/components/ai-edition/Preview.tsx | 4 +
src/components/ai-edition/PreviewCanvas.tsx | 2 +
src/components/ai-edition/VirtualPreview.tsx | 14 ++-
src/components/ai-edition/v4/V4Timeline.tsx | 101 ++++++++++++++-----
src/lib/ai-edition/captions/captions.test.ts | 49 +++++++++
src/lib/ai-edition/captions/cues.ts | 23 ++++-
src/lib/ai-edition/document/timeline.test.ts | 87 ++++++++++++++++
src/lib/ai-edition/document/timeline.ts | 97 +++++++++++++++---
src/lib/ai-edition/store/useTimeline.ts | 3 +
src/lib/ai-edition/timeline/timelineMap.ts | 22 +++-
src/native/sceneDescription.ts | 6 +-
src/native/useNativePlaybackSync.ts | 22 +++-
13 files changed, 378 insertions(+), 53 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index ee8f66b66..2973e9c9b 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -1315,6 +1315,7 @@ export function NewEditorShell() {
speedRegions={tl.speedRegions}
cameraFullscreenRegions={tl.cameraFullscreenRegions}
trimRanges={tl.trimRanges}
+ insertRanges={document?.timeline?.insertRanges ?? []}
selectedZoomRegionId={tl.selection?.kind === "zoom" ? tl.selection.id : null}
onZoomFocusChange={tl.updateZoomFocusLive}
onZoomFocusCommit={() => void tl.commitZoomFocus()}
diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx
index aaf04f2db..64e199514 100644
--- a/src/components/ai-edition/Preview.tsx
+++ b/src/components/ai-edition/Preview.tsx
@@ -4,6 +4,7 @@ import { useScopedT } from "@/contexts/I18nContext";
import type {
AxcutAnnotationRegion,
AxcutClip,
+ AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -26,6 +27,7 @@ interface PreviewProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
+ insertRanges?: AxcutInsertRange[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
@@ -57,6 +59,7 @@ export function Preview({
speedRegions,
cameraFullscreenRegions,
trimRanges,
+ insertRanges,
selectedZoomRegionId,
onZoomFocusChange,
onZoomFocusCommit,
@@ -183,6 +186,7 @@ export function Preview({
speedRegions={speedRegions}
cameraFullscreenRegions={cameraFullscreenRegions}
trimRanges={trimRanges}
+ insertRanges={insertRanges}
selectedZoomRegionId={selectedZoomRegionId}
onZoomFocusChange={onZoomFocusChange}
onZoomFocusCommit={onZoomFocusCommit}
diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx
index 6f59e066f..53abadbb6 100644
--- a/src/components/ai-edition/PreviewCanvas.tsx
+++ b/src/components/ai-edition/PreviewCanvas.tsx
@@ -35,6 +35,7 @@ import { resolveAspectRatioValue } from "@/lib/ai-edition/document/outputFormat"
import type {
AxcutAnnotationRegion,
AxcutClip,
+ AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -70,6 +71,7 @@ interface PreviewCanvasProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
+ insertRanges?: AxcutInsertRange[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 804cfa5a5..077cb62c8 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -5,7 +5,12 @@ import {
MAX_NATIVE_PLAYBACK_RATE,
} from "@/components/video-editor/types";
import { resolvePlaybackSegments } from "@/lib/ai-edition/document/timeline";
-import type { AxcutClip, AxcutTrimRange, AxcutZoomRegion } from "@/lib/ai-edition/schema";
+import type {
+ AxcutClip,
+ AxcutInsertRange,
+ AxcutTrimRange,
+ AxcutZoomRegion,
+} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
@@ -116,6 +121,8 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
+ /** The pauses added words created — they lengthen playback, they do not cut it. */
+ insertRanges?: AxcutInsertRange[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
@@ -160,6 +167,7 @@ export function VirtualPreview({
zoomRegions = [],
speedRegions = [],
trimRanges = [],
+ insertRanges = [],
seekTarget,
onTimeChange,
onLoadedMetadata,
@@ -411,8 +419,8 @@ export function VirtualPreview({
// source time to a RAW virtual time that jumps discontinuously by exactly the trim's
// width the moment the video itself jumps — matching the marker's own pixel span.
const playbackClips = useMemo(
- () => resolvePlaybackSegments(clips, trimRanges),
- [clips, trimRanges],
+ () => resolvePlaybackSegments(clips, trimRanges, insertRanges),
+ [clips, trimRanges, insertRanges],
);
const playbackClipsRef = useRef(playbackClips);
playbackClipsRef.current = playbackClips;
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 901863ad0..131ab310d 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -38,6 +38,12 @@ import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
+import {
+ expandRawSec,
+ type RulerInsert,
+ rulerInserts,
+ totalInsertedSec,
+} from "@/lib/ai-edition/timeline/inserted-time";
import {
newRegionDurationSec,
setTimelineScale,
@@ -198,6 +204,10 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
+ /** The pauses added words created. `currentTimeSec` is a STORED second; the ruler it is
+ * drawn on counts the pauses, so it has to be placed through them or it drifts from
+ * the clips by the whole added time. */
+ inserts: readonly RulerInsert[];
/** Live scrub position, when a drag is in flight. Takes precedence over the store. */
overrideTimeSec: number | null;
canvasStyle: React.CSSProperties;
@@ -223,13 +233,14 @@ interface PlayheadOverlayProps {
*/
const PlayheadOverlay = memo(function PlayheadOverlay({
totalSec,
+ inserts,
overrideTimeSec,
canvasStyle,
onPointerDown,
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const pct = ((overrideTimeSec ?? storeTimeSec) / totalSec) * 100;
+ const pct = (expandRawSec(overrideTimeSec ?? storeTimeSec, inserts) / totalSec) * 100;
return (
@@ -439,15 +450,26 @@ export function V4Timeline({
// clicked instead of looking like it worked. Same question, same helper as the Layout
// pane: is a camera attached anywhere on this timeline?
const hasAnyCamera = useMemo(() => hasAnyClipWithCamera(tl.assets, clips), [tl.assets, clips]);
+ // The pauses added words created, placed on the ruler. Everything below measures the
+ // EXPANDED ruler — stored clip geometry plus the time those pauses add — because that
+ // is the film's real length and the one the playhead runs along. Stored geometry is
+ // never rewritten for this: only what is drawn moves.
+ // `?? []` because the key is additive: a document written before it has no pauses.
+ const inserts = useMemo(
+ () => rulerInserts(tl.insertRanges ?? [], clips),
+ [tl.insertRanges, clips],
+ );
const total = useMemo(
() =>
Math.max(
1,
- clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0),
+ clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0) + totalInsertedSec(inserts),
),
- [clips],
+ [clips, inserts],
);
const pctOf = useCallback((sec: number) => (sec / total) * 100, [total]);
+ /** Stored raw seconds → a percentage of the expanded ruler. */
+ const pctAt = useCallback((sec: number) => pctOf(expandRawSec(sec, inserts)), [pctOf, inserts]);
const showLanes = variant === "edit";
// The visible fraction of the timeline, and what one second is worth on screen
@@ -1168,8 +1190,10 @@ export function V4Timeline({
compact ? ` ${styles.lanePillCompact}` : ""
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
- left: `${pctOf(seg.segStart)}%`,
- width: `${pctOf(durSec)}%`,
+ left: `${pctAt(seg.segStart)}%`,
+ // Measured on the expanded ruler at BOTH ends: a region straddling a pause
+ // covers it, so its box has to grow by that pause and not merely slide.
+ width: `${pctOf(expandRawSec(seg.segEnd, inserts) - expandRawSec(seg.segStart, inserts))}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
? undefined
@@ -1469,7 +1493,7 @@ export function V4Timeline({
{tick.major ? (
{fmtTick(tick.sec, rulerTicks.step)}
@@ -1529,6 +1553,12 @@ export function V4Timeline({
>
{clips.map((c, i) => {
const dur = c.timelineEndSec - c.timelineStartSec;
+ // On the expanded ruler the box also carries whatever pauses fall
+ // inside it — the film really does stay on this clip's frame for
+ // them, so they belong to its box rather than between boxes.
+ const boxStart = expandRawSec(c.timelineStartSec, inserts);
+ const boxEnd = expandRawSec(c.timelineEndSec, inserts);
+ const boxLen = boxEnd - boxStart;
const asset = tl.assets.find((a) => a.id === c.assetId);
const clipVideoUrl = videoSources.find((v) => v.id === c.assetId)?.src;
const selected = tl.clipSelection === c.id;
@@ -1556,12 +1586,12 @@ export function V4Timeline({
dragging ? ` ${styles.tlClipDragging}` : ""
}`}
style={{
- left: `${pctOf(c.timelineStartSec)}%`,
+ left: `${pctOf(boxStart)}%`,
// Minus the gutter that separates two cards (it used to be the
// flex row's `gap`). A clip shorter than the gutter lands on
// .tlClip's 1px min-width instead of collapsing — same rule as
// the lane pills above.
- width: `calc(${pctOf(dur)}% - ${CLIP_GUTTER_PX}px)`,
+ width: `calc(${pctOf(boxLen)}% - ${CLIP_GUTTER_PX}px)`,
transform: clipTransform,
}}
onPointerDown={(e) => startClipDrag(e, c)}
@@ -1604,25 +1634,41 @@ export function V4Timeline({
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId}
- {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => (
- e.stopPropagation()}
- onClick={(e) => {
- // Jump to the moment the added text sits on. The clip box
- // underneath would otherwise take this as a selection.
- e.stopPropagation();
- setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
- }}
- />
- ))}
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => {
+ // A word whose pause the film actually holds gets a BAND as wide
+ // as the time it adds — that width is the added time, drawn. One
+ // that fitted in silence already there adds nothing and stays the
+ // hairline it was: there is nothing to show.
+ const pause = inserts.find((ins) => ins.wordId === word.id);
+ const left = pause
+ ? ((expandRawSec(pause.atRawSec, inserts) - boxStart) / boxLen) * 100
+ : atPct;
+ const width = pause ? (pause.durationSec / boxLen) * 100 : 0;
+ return (
+ 0
+ ? { left: `${left}%`, width: `${width}%`, marginLeft: 0 }
+ : { left: `${left}%` }
+ }
+ title={t("toolbar.addedWord", { word: word.text })}
+ aria-label={t("toolbar.addedWord", { word: word.text })}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => {
+ // Jump to the moment the added text sits on. The clip box
+ // underneath would otherwise take this as a selection.
+ e.stopPropagation();
+ setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
+ }}
+ />
+ );
+ })}
{selected ? (
{
);
});
});
+
+// ─── Captions across an added word's pause ───────────────────────
+// The pause lengthens the ruler, so every line after it slides — and the line the pause
+// exists FOR has to stay on screen through it rather than going dark over the one moment
+// an added word is there for.
+
+describe("captions and a pause", () => {
+ function withPause(): AxcutDocument {
+ const base = doc();
+ return {
+ ...base,
+ timeline: {
+ ...base.timeline,
+ insertRanges: [
+ {
+ id: "ins_1",
+ assetId: "asset-1",
+ // Inside "hello there friend" (0–2s), so the line covers it.
+ atSec: 1.2,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user" as const,
+ },
+ ],
+ },
+ };
+ }
+
+ it("keeps the covering line up through the pause instead of cutting it short", () => {
+ const before = deriveCaptionCues(doc(), ON, {});
+ const after = deriveCaptionCues(withPause(), ON, {});
+ const line = (cues: typeof before) => cues.find((cue) => cue.text.includes("hello"));
+ expect(line(after)?.startMs).toBe(line(before)?.startMs);
+ // Half a second longer: exactly the pause it now spans.
+ expect((line(after)?.endMs ?? 0) - (line(before)?.endMs ?? 0)).toBe(500);
+ });
+
+ it("slides everything after the pause along by it", () => {
+ const before = deriveCaptionCues(doc(), ON, {});
+ const after = deriveCaptionCues(withPause(), ON, {});
+ const later = (cues: typeof before) => cues.find((cue) => cue.text.includes("goodbye"));
+ expect((later(after)?.startMs ?? 0) - (later(before)?.startMs ?? 0)).toBe(500);
+ });
+
+ it("is unchanged when the project has no pauses", () => {
+ expect(deriveCaptionCues(doc(), ON, {})).toEqual(deriveCaptionCues(doc(), ON, {}));
+ });
+});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index c3287ebcb..b4dfa7677 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -23,6 +23,7 @@ import {
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
import type { AxcutClip, AxcutDocument, AxcutTranscript } from "../schema";
+import { expandRawSec, type RulerInsert, rulerInserts } from "../timeline/inserted-time";
import {
type CaptionAnchorV,
type CaptionSettings,
@@ -157,6 +158,7 @@ export function sourceSpanToTimelineSpans(
startSec: number,
endSec: number,
clips: AxcutClip[],
+ inserts: readonly RulerInsert[] = [],
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -170,7 +172,15 @@ export function sourceSpanToTimelineSpans(
endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
});
}
- return out;
+ // Onto the ruler the viewer actually sees. Expanding BOTH ends does the whole job:
+ // a line after a pause slides along by it, and a line that covers the held moment
+ // has only its end pushed out — so it stays on screen through the pause instead of
+ // going dark over the one moment an added word exists for.
+ if (inserts.length === 0) return out;
+ return out.map((span) => ({
+ startSec: expandRawSec(span.startSec, inserts),
+ endSec: expandRawSec(span.endSec, inserts),
+ }));
}
/**
@@ -189,6 +199,9 @@ export function deriveCaptionCues(
if (clips.length === 0) return [];
const transcripts = new Map(document.transcripts.map((t) => [t.assetId, t]));
+ // `?? []` because the key is additive: a document written before it — or a hand-built
+ // one that never went through the schema — simply has no pauses.
+ const inserts = rulerInserts(document.timeline.insertRanges ?? [], clips);
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
const linesByAsset = new Map();
@@ -205,7 +218,13 @@ export function deriveCaptionCues(
for (const line of lines) {
const text = line.text.trim();
if (!text) continue;
- for (const span of sourceSpanToTimelineSpans(assetId, line.startSec, line.endSec, clips)) {
+ for (const span of sourceSpanToTimelineSpans(
+ assetId,
+ line.startSec,
+ line.endSec,
+ clips,
+ inserts,
+ )) {
const startMs = Math.round(span.startSec * 1000);
const endMs = Math.max(Math.round(span.endSec * 1000), startMs + 1);
cues.push({ id: `caption-${n++}`, startMs, endMs, text });
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index fcc43c56f..af5e3b3cf 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
type AxcutClip,
type AxcutDocument,
+ type AxcutInsertRange,
type AxcutTrimRange,
axcutSchemaVersion,
} from "../schema";
@@ -1606,3 +1607,89 @@ describe("a malformed legacyEditor envelope", () => {
expect(next.legacyEditor).toEqual({ speedRegions: null, cameraFullscreenRegions: 42 });
});
});
+
+// ─── The pause an added word bought ──────────────────────────────
+// Created time only exists once playback honours it. These pin the one thing the record
+// is for: the stream really does stay on the held frame, and the film really is longer.
+
+describe("resolvePlaybackSegments with insert ranges", () => {
+ const CLIPS: AxcutClip[] = [
+ {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ];
+ const insert = (overrides: Partial = {}): AxcutInsertRange => ({
+ id: "ins_1",
+ assetId: "a1",
+ atSec: 10,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "held",
+ origin: "user",
+ ...overrides,
+ });
+
+ it("holds the frame where the pause sits, and lengthens the stream by it", () => {
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
+ expect(segments).toHaveLength(2);
+ expect(segments[1]).toMatchObject({
+ sourceStartSec: 10,
+ sourceEndSec: 10,
+ heldSec: 0.5,
+ timelineStartSec: 10,
+ timelineEndSec: 10.5,
+ });
+ });
+
+ it("changes nothing when there is no pause", () => {
+ expect(resolvePlaybackSegments(CLIPS, [], [])).toHaveLength(1);
+ });
+
+ // The usual case, and the one the first cut of this missed: a pause sits at the end of
+ // the word it follows, which is almost never a boundary a trim happened to leave.
+ it("cuts the clip open where a pause falls in the MIDDLE of it", () => {
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert({ atSec: 2.5 })]);
+ expect(segments.map((s) => [s.sourceStartSec, s.sourceEndSec, s.heldSec])).toEqual([
+ [0, 2.5, undefined],
+ [2.5, 2.5, 0.5],
+ [2.5, 10, undefined],
+ ]);
+ // 10s of film plus half a second of held frame.
+ expect(segments[2].timelineEndSec).toBeCloseTo(10.5, 5);
+ });
+
+ // The moment the pause holds is not in the film any more, so neither is the pause.
+ it("drops a pause whose moment a trim removed", () => {
+ const trims: AxcutTrimRange[] = [
+ { id: "t1", assetId: "a1", startSec: 4, endSec: 10, origin: "user", reason: "" },
+ ];
+ const segments = resolvePlaybackSegments(CLIPS, trims, [insert()]);
+ expect(segments.some((s) => s.heldSec !== undefined)).toBe(false);
+ });
+
+ it("places a pause inside a clip between the halves a trim left", () => {
+ const trims: AxcutTrimRange[] = [
+ { id: "t1", assetId: "a1", startSec: 4, endSec: 6, origin: "user", reason: "" },
+ ];
+ const segments = resolvePlaybackSegments(CLIPS, trims, [insert({ atSec: 4 })]);
+ expect(segments.map((s) => s.heldSec)).toEqual([undefined, 0.5, undefined]);
+ // The stream is the kept film plus the pause: 4s + 0.5s + 4s.
+ expect(segments[segments.length - 1].timelineEndSec).toBeCloseTo(8.5, 5);
+ });
+
+ it("never writes the held flag onto a stored clip", () => {
+ // The field lives on the derived segment only; that is the whole difference from
+ // the attempt that made clips for it.
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
+ expect(CLIPS[0]).not.toHaveProperty("heldSec");
+ expect(segments[0]).not.toHaveProperty("heldSec");
+ });
+});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 232bcb6ee..cc0c53bfe 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -3,7 +3,24 @@
// (store, exporter, agent) feeds an AxcutDocument and gets back intervals
// or a new document with updated clips.
-import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "../schema";
+import type {
+ AxcutClip,
+ AxcutDocument,
+ AxcutInsertRange,
+ AxcutTranscript,
+ AxcutTrimRange,
+} from "../schema";
+
+/**
+ * What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film, plus the
+ * one thing a stored clip can never carry — `heldSec`, the pause an added word created.
+ *
+ * A held segment's source window is the single frame it shows; its LENGTH is `heldSec`.
+ * The field lives only on this derived shape, never on `clipSchema`, so nothing can write
+ * one to disk — which is the whole difference from the attempt that made clips for it.
+ */
+export type PlaybackSegment = AxcutClip & { heldSec?: number };
+
import {
anchoredToRawSpanSec,
anchorRegionsWithDerivedMs,
@@ -163,10 +180,32 @@ export function subtractInterval(intervals: Interval[], cut: Interval): Interval
export function resolvePlaybackSegments(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
-): AxcutClip[] {
+ insertRanges: readonly AxcutInsertRange[] = [],
+): PlaybackSegment[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
- const result: AxcutClip[] = [];
+ const result: PlaybackSegment[] = [];
let timelineCursor = 0;
+ // The pauses added words need, in the order they will be met. Consumed as the walk
+ // passes each one's moment, so a pause inside a span a trim removed is never reached —
+ // which is right: the moment it holds is not in the film any more.
+ const pending = [...insertRanges].sort((a, b) => a.atSec - b.atSec);
+ const holdAt = (clip: AxcutClip, atSec: number): PlaybackSegment | null => {
+ const insert = pending.find(
+ (range) => range.assetId === clip.assetId && Math.abs(range.atSec - atSec) < 1e-6,
+ );
+ if (!insert) return null;
+ pending.splice(pending.indexOf(insert), 1);
+ return {
+ ...clip,
+ id: `${clip.id}__hold_${insert.id}`,
+ sourceStartSec: atSec,
+ sourceEndSec: atSec,
+ timelineStartSec: 0,
+ timelineEndSec: 0,
+ heldSec: insert.durationSec,
+ reason: insert.reason,
+ };
+ };
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
@@ -185,18 +224,52 @@ export function resolvePlaybackSegments(
if (!trimAppliesToClip(trim, clip)) continue;
kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
}
- kept.forEach((iv, i) => {
- const dur = iv.endSec - iv.startSec;
- if (dur <= 0) return;
+ // A pause sits at the END of the word it follows, which is almost never a boundary a
+ // trim happened to leave. So each kept span is cut at the moments it holds, and the
+ // held frame goes between the halves: the stream plays up to that frame, stays on it
+ // for the pause, then carries on — which is what makes the film longer.
+ const pieces: Array<{ startSec: number; endSec: number; holdAtEnd: boolean }> = [];
+ for (const iv of kept) {
+ const moments = pending
+ .filter(
+ (range) =>
+ range.assetId === clip.assetId &&
+ range.atSec > iv.startSec + 1e-6 &&
+ range.atSec <= iv.endSec + 1e-6,
+ )
+ .map((range) => range.atSec)
+ .sort((a, b) => a - b);
+ let from = iv.startSec;
+ for (const at of moments) {
+ pieces.push({ startSec: from, endSec: Math.min(at, iv.endSec), holdAtEnd: true });
+ from = Math.min(at, iv.endSec);
+ }
+ if (iv.endSec - from > 1e-6 || pieces.length === 0) {
+ pieces.push({ startSec: from, endSec: iv.endSec, holdAtEnd: false });
+ }
+ }
+ pieces.forEach((piece, i) => {
+ const dur = piece.endSec - piece.startSec;
+ if (dur > 0) {
+ result.push({
+ ...clip,
+ id: pieces.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
+ sourceStartSec: piece.startSec,
+ sourceEndSec: piece.endSec,
+ timelineStartSec: timelineCursor,
+ timelineEndSec: timelineCursor + dur,
+ });
+ timelineCursor += dur;
+ }
+ if (!piece.holdAtEnd) return;
+ const hold = holdAt(clip, piece.endSec);
+ if (!hold) return;
result.push({
- ...clip,
- id: kept.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
- sourceStartSec: iv.startSec,
- sourceEndSec: iv.endSec,
+ ...hold,
timelineStartSec: timelineCursor,
- timelineEndSec: timelineCursor + dur,
+ timelineEndSec: timelineCursor + (hold.heldSec ?? 0),
});
- timelineCursor += dur;
+ timelineCursor += hold.heldSec ?? 0;
});
}
return result;
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 518e655cc..c93483d2d 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -1176,6 +1176,9 @@ export function useTimeline() {
return {
zoomRegions: document?.zoomRanges ?? [],
trimRanges: document?.timeline.trimRanges ?? [],
+ // The pauses added words created. The ruler counts them; nothing else in the
+ // timeline store writes them (see `document/transcript.ts`).
+ insertRanges: document?.timeline.insertRanges ?? [],
annotationRegions: (document?.annotations ?? []) as unknown as AnnotationRegion[],
speedRegions,
cameraFullscreenRegions,
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index b98601a03..507126f5c 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -17,6 +17,7 @@
// against the COMPRESSED segment layout, which slips every region after a trim
// forward by the trimmed duration.
+import type { PlaybackSegment } from "../document/timeline";
import type { AxcutClip } from "../schema";
import { ventilateSpanAcrossClips } from "./region-ventilation";
import { findRawClipForSegment, getRawVirtualStartTime } from "./virtual-preview";
@@ -399,11 +400,15 @@ export function anchorRegionsWithDerivedMs<
* the gap.
*/
export function segmentRawSpanSec(
- segment: AxcutClip,
+ segment: PlaybackSegment,
rawClips: AxcutClip[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips);
- const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ // A held segment's source window is the single frame it shows, so its source length
+ // is zero — its RAW span is the pause it carries. Without this the playhead could
+ // never be inside it and would step straight over the pause.
+ const lenSec =
+ segment.heldSec ?? (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -559,7 +564,7 @@ export function projectRegionsToSource<
T extends { id: string; startMs: number; endMs: number } & RegionClipAnchor,
>(
regions: T[],
- visibleSegments: AxcutClip[],
+ visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
makeId: () => string,
): (T & { clipIndex?: number; underTrim?: boolean })[] {
@@ -639,7 +644,7 @@ export function projectRegionsToSource<
export interface NativePosition {
/** The trim-narrowed playback segment (from `visibleSegments`) that is active. */
- clip: AxcutClip;
+ clip: PlaybackSegment;
/** Its index in `visibleSegments`, matching `SceneDescription.clips` / native `clip_index`. */
clipIndex: number;
/** Screen-source seconds the native decoder should present for this segment. */
@@ -675,7 +680,7 @@ const NATIVE_EOF_MARGIN_SEC = 0.033;
*/
export function resolveNativePosition(
rawSec: number,
- visibleSegments: AxcutClip[],
+ visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
@@ -689,6 +694,13 @@ export function resolveNativePosition(
if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips);
const seg = visibleSegments[index];
+ // Inside a pause the source clock does not advance: the whole point of the segment
+ // is created time over one held frame. Clamping here is what stops the raw-playhead
+ // delta — which DOES advance through the pause — from pushing the decoder past the
+ // held frame into the content that belongs after it.
+ if (seg.heldSec !== undefined) {
+ return { clip: seg, clipIndex: index, sourceTimeSec: seg.sourceStartSec };
+ }
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 376a8580a..55161d7d4 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -474,7 +474,11 @@ function parseWallpaper(wallpaper: string) {
*/
export function resolveVisibleClips(document: AxcutDocument): AxcutClip[] {
const assetById = new Map(document.assets.map((a) => [a.id, a]));
- return resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)
+ return resolvePlaybackSegments(
+ document.timeline.clips,
+ document.timeline.trimRanges,
+ document.timeline.insertRanges,
+ )
.sort((a, b) => a.timelineStartSec - b.timelineStartSec)
.filter((clip) => assetById.get(clip.assetId)?.originalPath);
}
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index 64e877b63..f1ab884ed 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -41,6 +41,12 @@ export function useNativePlaybackSync(
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
+ // A pause holds ONE frame for its whole length. Free-running the decoder through it
+ // would play what comes after instead, and the app clock — which does traverse the
+ // pause — would then re-seek on the drift and stutter. Pausing the decoder is what
+ // makes the pause a pause; the webcam holds with the screen because both derive
+ // from the one asset source clock the pause stops advancing.
+ const held = activePosition?.clip.heldSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -54,8 +60,8 @@ export function useNativePlaybackSync(
if (!active) {
return;
}
- setNativePlaying(playing);
- }, [active, playing]);
+ setNativePlaying(playing && !held);
+ }, [active, playing, held]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -68,6 +74,16 @@ export function useNativePlaybackSync(
}
const now = performance.now();
+ // Inside a pause while playing: the decoder is parked on the held frame (see the
+ // transport effect). Refresh the drift refs every run so the check never reads a
+ // correctly-frozen source clock as divergence and fights itself with seeks.
+ if (playing && held) {
+ setNativeTime(sourceTimeSec);
+ lastSyncedSourceTimeRef.current = sourceTimeSec;
+ lastSyncedWallTimeRef.current = now;
+ return;
+ }
+
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -95,5 +111,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, activeClipId, sourceTimeSec]);
+ }, [active, playing, held, activeClipId, sourceTimeSec]);
}
From d0aa9d3efef5c352c7470caff42e7892d8da9aa2 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Wed, 2 Sep 2026 18:36:06 +0200
Subject: [PATCH 63/84] feat(timeline): one answer to whether a raw moment is
in the film
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Everything that reads the timeline answered that question its own way, and the
answers disagreed. The transcript pane asked it by IDENTITY — does a trim name
this clip — which is a question a voiceover placement can never answer yes to:
it carries an audio fragment id and an audio asset, while every trim carries a
video clip. So the voiceover lane read every word as kept, including words whose
moment had been cut out of the film, and a cut authored from that lane removed
nothing at all.
The fix is not a better identity test. A trim is a removed span of the RAW
RULER, and both lanes lie on that one ruler: a word is removed if and only if
the raw moment it occupies is. `programme-time.ts` is that reading —
`keptRawSpans` / `removedRawSpans` / `removalAt` / `subtractRemoved`, derived and
never stored. Storage does not move: a trim stays source-anchored to a clip.
`keptRawSpans` is LIFTED out of `projectRawTimelineSecToPlayback`, which now
calls it, rather than reimplemented beside it — so agreement with playback is by
construction. Deriving it from `trimToTimelineSpan` instead would have been the
trap: that function's un-anchored branch resolves a pre-v7 trim through the
first clip whose source range contains its START, while the playback walk cuts on
OVERLAP across every clip of the asset. A primitive built on it would have left
the second clip's words reading kept over film that is gone — a new bug on the
lane that is correct today. There is a test named after exactly that.
Two boundaries do not follow from the definition and are pinned:
- The programme ends at the last CLIP's raw end, not the last KEPT span's. A
trimmed tail is genuinely removed; raw time PAST every clip is not removed
but unfilmed, because the projection is the identity there. That is what
lets a voiceover hang off the end and keep playing.
- A gap between clips is removed with NO trim ids. Nothing plays there, so a
word over it is not in the film — but there is no pill to restore, and a
caller offering that affordance must key it on `trimIds` being non-empty.
`Interval` and `subtractInterval` move to `timeline/intervals.ts` and are
re-exported, because the dependency runs document/ → timeline/ and importing
back would close a cycle. A second copy of those twelve lines was the
alternative, and two implementations of "what survives a cut" is the shape of
bug this change exists to remove.
The randomised fixture checks the SUM against `resolvePlaybackSegments`, which is
blind to order — so it also projects every span's head and expects the output
length of everything before it, which only holds if the walk yields them in
playback order. Reversing the output fails three assertions.
Refs #560. Step 1 of 7.
---
src/lib/ai-edition/document/timeline.ts | 76 ++---
src/lib/ai-edition/timeline/intervals.ts | 37 +++
.../timeline/programme-time.test.ts | 276 ++++++++++++++++++
src/lib/ai-edition/timeline/programme-time.ts | 225 ++++++++++++++
4 files changed, 557 insertions(+), 57 deletions(-)
create mode 100644 src/lib/ai-edition/timeline/intervals.ts
create mode 100644 src/lib/ai-edition/timeline/programme-time.test.ts
create mode 100644 src/lib/ai-edition/timeline/programme-time.ts
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index ee46a6cc2..87670128c 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -21,6 +21,8 @@ import type {
*/
export type PlaybackSegment = AxcutClip & { heldSec?: number };
+import { type Interval, subtractInterval } from "../timeline/intervals";
+import { keptRawSpans } from "../timeline/programme-time";
import {
anchoredToRawSpanSec,
anchorRegionsWithDerivedMs,
@@ -46,10 +48,10 @@ export function byStart(a: { startSec: number }, b: { startSec: number }): numbe
return a.startSec - b.startSec;
}
-export interface Interval {
- startSec: number;
- endSec: number;
-}
+// Re-exported, not redefined: `programme-time.ts` needs the same subtraction and cannot
+// import it from here without closing a dependency cycle (this module already imports from
+// `../timeline`). Callers of `Interval` / `subtractInterval` from this module are unaffected.
+export { type Interval, subtractInterval } from "../timeline/intervals";
export function normalizeIntervals(durationSec: number, intervals: Interval[]): Interval[] {
const bounded = intervals
@@ -145,23 +147,6 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
});
}
-export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
- const output: Interval[] = [];
- for (const interval of intervals) {
- if (cut.endSec <= interval.startSec || cut.startSec >= interval.endSec) {
- output.push(interval);
- continue;
- }
- if (cut.startSec > interval.startSec) {
- output.push({ startSec: interval.startSec, endSec: cut.startSec });
- }
- if (cut.endSec < interval.endSec) {
- output.push({ startSec: cut.endSec, endSec: interval.endSec });
- }
- }
- return output;
-}
-
/**
* Derived, ephemeral clip list for playback/native/export — never written back to
* `document.timeline.clips`. Each clip's own `[sourceStartSec, sourceEndSec]` (its media
@@ -358,43 +343,20 @@ export function projectRawTimelineSecToPlayback(
let lastRawEnd = 0; // raw end of the last kept segment, for the trailing overhang
let landed: number | null = null; // output(rawSec), once it falls in/before a kept segment
- // Each kept segment as a raw extent `{ rawStart, dur }`. Trims only REMOVE, so a kept
- // segment's raw length survives here; how long it takes to PLAY is a separate question
- // that `outputDurationOfRawSpan` answers, because a speed region scales it.
- const keptSegments = (clip: AxcutClip): Array<{ rawStart: number; dur: number }> => {
- const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
- if (sourceEnd <= clip.sourceStartSec) {
- // Duration not probed yet — the whole raw clip passes through unnarrowed, matching
- // `resolvePlaybackSegments`' own pass-through branch.
- return [
- { rawStart: clip.timelineStartSec, dur: clip.timelineEndSec - clip.timelineStartSec },
- ];
- }
- let ivs: Interval[] = [{ startSec: clip.sourceStartSec, endSec: sourceEnd }];
- for (const trim of trimRanges) {
- if (!trimAppliesToClip(trim, clip)) continue;
- ivs = subtractInterval(ivs, { startSec: trim.startSec, endSec: trim.endSec });
- }
- // Source time `s` sits at `timelineStartSec + (s − sourceStartSec)` on the raw ruler.
- return ivs.map((iv) => ({
- rawStart: clip.timelineStartSec + (iv.startSec - clip.sourceStartSec),
- dur: iv.endSec - iv.startSec,
- }));
- };
-
- for (const clip of ordered) {
- for (const seg of keptSegments(clip)) {
- if (seg.dur <= 0) continue;
- const rawEnd = seg.rawStart + seg.dur;
- if (landed === null && rawSec < rawEnd) {
- // `rawSec` is inside this segment, or before it in a trimmed/gap region (then
- // the span clamps to nothing → the output edge just before the gap).
- const within = Math.min(Math.max(rawSec, seg.rawStart), rawEnd);
- landed = outCursor + outputDurationOfRawSpan(seg.rawStart, within, speedRegions);
- }
- outCursor += outputDurationOfRawSpan(seg.rawStart, rawEnd, speedRegions);
- lastRawEnd = rawEnd;
+ // The kept stretches come from `keptRawSpans`, which is this walk — it was lifted out of
+ // here so the transcript lanes and the audio mix could ask the same question and get the
+ // same answer (issue #560). Trims only REMOVE, so a kept span's RAW length is what
+ // survives; how long it takes to PLAY is a separate question `outputDurationOfRawSpan`
+ // answers, because a speed region scales it.
+ for (const seg of keptRawSpans(ordered, trimRanges)) {
+ if (landed === null && rawSec < seg.endSec) {
+ // `rawSec` is inside this segment, or before it in a trimmed/gap region (then
+ // the span clamps to nothing → the output edge just before the gap).
+ const within = Math.min(Math.max(rawSec, seg.startSec), seg.endSec);
+ landed = outCursor + outputDurationOfRawSpan(seg.startSec, within, speedRegions);
}
+ outCursor += outputDurationOfRawSpan(seg.startSec, seg.endSec, speedRegions);
+ lastRawEnd = seg.endSec;
}
// Past every kept frame: programme end plus whatever raw time hangs off the end (identity when
// there are no clips at all). A value ≥ programme length just means the mixer skips the track.
diff --git a/src/lib/ai-edition/timeline/intervals.ts b/src/lib/ai-edition/timeline/intervals.ts
new file mode 100644
index 000000000..ecd6daad2
--- /dev/null
+++ b/src/lib/ai-edition/timeline/intervals.ts
@@ -0,0 +1,37 @@
+// Interval arithmetic, with no opinion about what the numbers mean.
+//
+// Extracted from `document/timeline.ts` so `programme-time.ts` can reuse the very
+// subtraction that `resolvePlaybackSegments` runs. It could not import it from there:
+// the dependency runs `document/` → `timeline/` (document/timeline.ts already imports
+// `trimAppliesToClip` from this layer), so importing back would close a cycle. A second
+// copy of the same twelve lines was the alternative, and two implementations of "what
+// survives a cut" is exactly the shape of bug this whole change exists to remove.
+//
+// `document/timeline.ts` re-exports both names, so its existing callers are unaffected.
+
+export interface Interval {
+ startSec: number;
+ endSec: number;
+}
+
+/**
+ * `intervals` minus `cut`. An interval straddling the cut splits in two; one wholly
+ * inside it disappears. Inputs are not required to be sorted or disjoint, and the
+ * output preserves the order it was given.
+ */
+export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
+ const output: Interval[] = [];
+ for (const interval of intervals) {
+ if (cut.endSec <= interval.startSec || cut.startSec >= interval.endSec) {
+ output.push(interval);
+ continue;
+ }
+ if (cut.startSec > interval.startSec) {
+ output.push({ startSec: interval.startSec, endSec: cut.startSec });
+ }
+ if (cut.endSec < interval.endSec) {
+ output.push({ startSec: cut.endSec, endSec: interval.endSec });
+ }
+ }
+ return output;
+}
diff --git a/src/lib/ai-edition/timeline/programme-time.test.ts b/src/lib/ai-edition/timeline/programme-time.test.ts
new file mode 100644
index 000000000..5d6faa4ef
--- /dev/null
+++ b/src/lib/ai-edition/timeline/programme-time.test.ts
@@ -0,0 +1,276 @@
+// Issue #560. These hold the one claim the whole change rests on: that
+// `programme-time.ts` and `resolvePlaybackSegments` answer "is this raw moment in the
+// film" the same way. They are the same walk now, so the interesting assertions are the
+// ones that would catch it drifting apart again — and the two boundary rules that do NOT
+// follow from the definition (a trimmed tail is removed, unfilmed time past the last clip
+// is not).
+
+import { describe, expect, it } from "vitest";
+import { projectRawTimelineSecToPlayback, resolvePlaybackSegments } from "../document/timeline";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
+import { keptRawSpans, removalAt, removedRawSpans, subtractRemoved } from "./programme-time";
+
+function clip(over: Partial & { id: string }): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...over,
+ } as AxcutClip;
+}
+
+function trim(over: Partial & { id: string }): AxcutTrimRange {
+ return {
+ assetId: "a1",
+ startSec: 0,
+ endSec: 1,
+ origin: "user",
+ reason: "",
+ ...over,
+ } as AxcutTrimRange;
+}
+
+/** Two clips laid end to end over one 20s asset, cut at source 10. */
+function twoClips(): AxcutClip[] {
+ return [
+ clip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ }),
+ clip({
+ id: "c2",
+ sourceStartSec: 10,
+ sourceEndSec: 20,
+ timelineStartSec: 10,
+ timelineEndSec: 20,
+ }),
+ ];
+}
+
+const total = (spans: Array<{ startSec: number; endSec: number }>) =>
+ spans.reduce((sum, s) => sum + (s.endSec - s.startSec), 0);
+
+/** Deterministic LCG — a failure here has to be reproducible, so no Math.random. */
+function lcg(seed: number) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 4294967296;
+ };
+}
+
+describe("keptRawSpans agrees with playback", () => {
+ it("keeps exactly what resolvePlaybackSegments plays, over randomised fixtures", () => {
+ for (let seed = 1; seed <= 40; seed++) {
+ const rand = lcg(seed);
+ const clipCount = 1 + Math.floor(rand() * 3);
+ const clips: AxcutClip[] = [];
+ let cursor = 0;
+ for (let i = 0; i < clipCount; i++) {
+ const len = 4 + Math.floor(rand() * 8);
+ const sourceStart = Math.floor(rand() * 5);
+ clips.push(
+ clip({
+ id: `c${i}`,
+ // Two clips over one asset on purpose: it is the case that separates a
+ // per-clip walk from a per-asset one.
+ assetId: rand() < 0.5 ? "a1" : "a2",
+ sourceStartSec: sourceStart,
+ sourceEndSec: sourceStart + len,
+ timelineStartSec: cursor,
+ timelineEndSec: cursor + len,
+ }),
+ );
+ // Sometimes a gap before the next clip.
+ cursor += len + (rand() < 0.3 ? 1 + Math.floor(rand() * 3) : 0);
+ }
+ const trims: AxcutTrimRange[] = [];
+ const trimCount = Math.floor(rand() * 4);
+ for (let i = 0; i < trimCount; i++) {
+ const host = clips[Math.floor(rand() * clips.length)];
+ const start = host.sourceStartSec + rand() * 4;
+ trims.push(
+ trim({
+ id: `t${i}`,
+ assetId: host.assetId,
+ // Half anchored, half pre-v7 style, so both branches of
+ // `trimAppliesToClip` are exercised.
+ ...(rand() < 0.5 ? { clipId: host.id } : {}),
+ startSec: start,
+ endSec: start + 0.5 + rand() * 3,
+ }),
+ );
+ }
+
+ const played = resolvePlaybackSegments(clips, trims).reduce(
+ (sum, seg) => sum + ((seg.sourceEndSec ?? seg.sourceStartSec) - seg.sourceStartSec),
+ 0,
+ );
+ const kept = keptRawSpans(clips, trims);
+ expect(total(kept), `seed ${seed} total`).toBeCloseTo(played, 6);
+
+ // The sum alone is blind to ORDER, and order is the whole reason this walk was
+ // lifted rather than reimplemented: `projectRawTimelineSecToPlayback` accumulates
+ // one output cursor across the spans in the order they arrive. So check each
+ // span's head projects to the output length of everything before it — which is
+ // only true if the walk yields them in playback order.
+ let before = 0;
+ for (const [i, span] of kept.entries()) {
+ expect(
+ projectRawTimelineSecToPlayback(clips, trims, span.startSec),
+ `seed ${seed} span ${i}`,
+ ).toBeCloseTo(before, 6);
+ before += span.endSec - span.startSec;
+ }
+ }
+ });
+
+ it("is caught out when the spans arrive in the wrong order", () => {
+ // Guards the guard: if `keptRawSpans` ever returned globally sorted spans instead of
+ // playback-ordered ones, the assertion above has to fail. Two clips whose ruler order
+ // is the reverse of their array order make the two orderings differ.
+ const clips = [
+ clip({
+ id: "late",
+ sourceStartSec: 0,
+ sourceEndSec: 4,
+ timelineStartSec: 6,
+ timelineEndSec: 10,
+ }),
+ clip({
+ id: "early",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ }),
+ ];
+ expect(keptRawSpans(clips, []).map((s) => s.startSec)).toEqual([0, 6]);
+ });
+
+ it("leaves the projection identical to what it produced before the lift", () => {
+ const clips = twoClips();
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 4 })];
+ // Raw 2..4 is gone, so everything after it plays 2s earlier; inside the cut the
+ // playhead lands on the output edge just before it.
+ expect(projectRawTimelineSecToPlayback(clips, trims, 1)).toBeCloseTo(1, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 3)).toBeCloseTo(2, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 6)).toBeCloseTo(4, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 20)).toBeCloseTo(18, 6);
+ // Past the programme the projection is the identity, which is what lets a voiceover
+ // hang off the end and keep playing.
+ expect(projectRawTimelineSecToPlayback(clips, trims, 25)).toBeCloseTo(23, 6);
+ });
+});
+
+describe("removedRawSpans", () => {
+ it("partitions the programme with no overlap and no hole", () => {
+ const clips = twoClips();
+ const trims = [
+ trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 4 }),
+ trim({ id: "t2", clipId: "c2", startSec: 15, endSec: 16 }),
+ ];
+ const kept = [...keptRawSpans(clips, trims)].sort((a, b) => a.startSec - b.startSec);
+ const removed = removedRawSpans(clips, trims);
+ const all = [...kept, ...removed].sort((a, b) => a.startSec - b.startSec);
+
+ let cursor = 0;
+ for (const span of all) {
+ expect(span.startSec).toBeCloseTo(cursor, 6); // no hole, no overlap
+ cursor = span.endSec;
+ }
+ expect(cursor).toBeCloseTo(20, 6); // the last clip's raw end
+ });
+
+ it("reports an inter-clip gap as removed by nothing", () => {
+ const clips = [
+ twoClips()[0],
+ clip({
+ id: "c2",
+ sourceStartSec: 10,
+ sourceEndSec: 20,
+ timelineStartSec: 13,
+ timelineEndSec: 23,
+ }),
+ ];
+ const gap = removedRawSpans(clips, []).find((s) => s.startSec === 10);
+ expect(gap).toMatchObject({ startSec: 10, endSec: 13 });
+ // No trim took it, so the pane must not offer a restore.
+ expect(gap?.trimIds).toEqual([]);
+ });
+
+ it("removes a trimmed tail of the last clip but never the time past it", () => {
+ const clips = [twoClips()[0]];
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 8, endSec: 10 })];
+ const removed = removedRawSpans(clips, trims);
+ expect(removed).toEqual([{ startSec: 8, endSec: 10, trimIds: ["t1"] }]);
+ // Raw 12 is unfilmed, not removed — the distinction a voiceover overhanging the
+ // programme depends on.
+ expect(removalAt(removed, 12)).toBeNull();
+ expect(removalAt(removed, 9)).toMatchObject({ trimIds: ["t1"] });
+ });
+
+ it("covers BOTH clips of an asset for a pre-v7 un-anchored trim", () => {
+ // The regression guard. `trimToTimelineSpan`'s un-anchored branch resolves such a
+ // trim through the FIRST clip whose source range contains its start, so a primitive
+ // built on it would leave c2's words reading kept over film that is gone. The
+ // playback walk cuts on overlap, per clip, and this must match it.
+ const clips = twoClips();
+ const trims = [trim({ id: "t1", startSec: 5, endSec: 15 })]; // no clipId
+ const removed = removedRawSpans(clips, trims);
+ expect(removalAt(removed, 6)).toMatchObject({ trimIds: ["t1"] }); // inside c1
+ expect(removalAt(removed, 12)).toMatchObject({ trimIds: ["t1"] }); // inside c2
+ expect(removalAt(removed, 2)).toBeNull();
+ expect(removalAt(removed, 18)).toBeNull();
+ });
+
+ it("names every overlapping trim that took a stretch", () => {
+ const clips = [twoClips()[0]];
+ const trims = [
+ trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 5 }),
+ trim({ id: "t2", clipId: "c1", startSec: 4, endSec: 7 }),
+ ];
+ // `subtractInterval` merges the two into one hole; both ids come with it, so
+ // restoring from the pane can drop the whole pill.
+ expect(removedRawSpans(clips, trims)).toEqual([
+ { startSec: 2, endSec: 7, trimIds: ["t1", "t2"] },
+ ]);
+ });
+
+ it("returns nothing for a document with no clips", () => {
+ expect(removedRawSpans([], [trim({ id: "t1" })])).toEqual([]);
+ });
+});
+
+describe("subtractRemoved", () => {
+ it("splits a span that crosses a cut into the pieces that survive", () => {
+ const clips = twoClips();
+ const removed = removedRawSpans(clips, [
+ trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 5 }),
+ ]);
+ // A voiceover from raw 1 to raw 8 plays as two pieces, not as one take cut short.
+ expect(subtractRemoved(1, 8, removed)).toEqual([
+ { startSec: 1, endSec: 3 },
+ { startSec: 5, endSec: 8 },
+ ]);
+ });
+
+ it("yields nothing for a span buried inside a cut, and the whole span when untouched", () => {
+ const clips = twoClips();
+ const removed = removedRawSpans(clips, [
+ trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 8 }),
+ ]);
+ expect(subtractRemoved(4, 6, removed)).toEqual([]);
+ expect(subtractRemoved(10, 14, removed)).toEqual([{ startSec: 10, endSec: 14 }]);
+ // Past the programme is not removed, so an overhanging take keeps its tail.
+ expect(subtractRemoved(18, 25, removed)).toEqual([{ startSec: 18, endSec: 25 }]);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/programme-time.ts b/src/lib/ai-edition/timeline/programme-time.ts
new file mode 100644
index 000000000..60c23f032
--- /dev/null
+++ b/src/lib/ai-edition/timeline/programme-time.ts
@@ -0,0 +1,225 @@
+// One answer to "is this raw ruler moment in the film" (issue #560).
+//
+// Everything that reads the timeline used to answer that question its own way, and the
+// answers disagreed. The transcript pane asked it by IDENTITY — does a trim name this
+// clip — which is a question a voiceover placement can never answer yes to, since it
+// carries an audio fragment id and an audio asset while every trim carries a video clip.
+// So the voiceover lane read every word as kept, including words whose moment had been
+// cut out of the film, and a cut authored from that lane removed nothing at all.
+//
+// The fix is not a better identity test. It is to stop asking about identity: a trim is a
+// removed span of the RAW RULER, and both lanes lie on that one ruler. A word — from the
+// recording or from a voiceover — is removed if and only if the raw moment it occupies is.
+//
+// `keptRawSpans` is therefore lifted verbatim out of `projectRawTimelineSecToPlayback`,
+// which now calls it, rather than reimplemented beside it. Agreement with playback is by
+// construction; `programme-time.test.ts` holds the two to it on randomised fixtures.
+//
+// Storage does not change: a trim stays source-time anchored to a clip. This is the
+// derived READING of those rows, computed on demand and never written back.
+
+import type { AxcutClip, AxcutTrimRange } from "../schema";
+import { type Interval, subtractInterval } from "./intervals";
+import { trimAppliesToClip } from "./trim-mapping";
+
+/** A stretch of the raw ruler, in seconds. */
+export interface RawSpan {
+ startSec: number;
+ endSec: number;
+}
+
+/** A stretch the film does not contain, and the trims that took it away. */
+export interface RemovedRawSpan extends RawSpan {
+ /**
+ * The trims covering this stretch — several when they overlap, and EMPTY for a gap
+ * between two clips, which is missing from the film without anything having removed
+ * it. Callers offering a restore affordance must key it on this being non-empty:
+ * there is no pill to click for a gap.
+ */
+ trimIds: string[];
+}
+
+/**
+ * The clip's own extent on the raw ruler.
+ *
+ * Source second `s` sits at `timelineStartSec + (s − sourceStartSec)`, so the extent runs
+ * to the source length past the head. An UNPROBED clip (no real `sourceEndSec` yet) has no
+ * source length to measure, and falls back to the ruler geometry it was given — matching
+ * the pass-through branch `resolvePlaybackSegments` takes for the same clips.
+ */
+function clipRawExtent(clip: AxcutClip): RawSpan {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) {
+ return { startSec: clip.timelineStartSec, endSec: clip.timelineEndSec };
+ }
+ return {
+ startSec: clip.timelineStartSec,
+ endSec: clip.timelineStartSec + (sourceEnd - clip.sourceStartSec),
+ };
+}
+
+/** Source interval → raw, through the clip that carries it. */
+function sourceToRaw(clip: AxcutClip, interval: Interval): RawSpan {
+ return {
+ startSec: clip.timelineStartSec + (interval.startSec - clip.sourceStartSec),
+ endSec: clip.timelineStartSec + (interval.endSec - clip.sourceStartSec),
+ };
+}
+
+/** What survives the trims inside one clip, in source order. */
+function keptSourceIntervals(clip: AxcutClip, trimRanges: AxcutTrimRange[]): Interval[] {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) return [];
+ let ivs: Interval[] = [{ startSec: clip.sourceStartSec, endSec: sourceEnd }];
+ for (const trim of trimRanges) {
+ if (!trimAppliesToClip(trim, clip)) continue;
+ ivs = subtractInterval(ivs, { startSec: trim.startSec, endSec: trim.endSec });
+ }
+ return ivs;
+}
+
+/**
+ * Every stretch of raw ruler the film actually contains, in PLAYBACK ORDER — clips by
+ * `timelineStartSec`, and within a clip by source time.
+ *
+ * Not globally sorted, on purpose: `projectRawTimelineSecToPlayback` walks these with a
+ * single output cursor, so the order has to be the order they play. Two clips that overlap
+ * on the ruler (which the model does not produce, but nothing forbids) therefore come back
+ * interleaved rather than merged, exactly as the projection has always treated them.
+ *
+ * Zero-length spans are dropped, so a caller can trust `endSec > startSec`.
+ */
+export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]): RawSpan[] {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ const spans: RawSpan[] = [];
+ for (const clip of ordered) {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) {
+ // Duration not probed yet — the whole raw clip passes through unnarrowed.
+ const extent = clipRawExtent(clip);
+ if (extent.endSec > extent.startSec) spans.push(extent);
+ continue;
+ }
+ for (const iv of keptSourceIntervals(clip, trimRanges)) {
+ const span = sourceToRaw(clip, iv);
+ if (span.endSec > span.startSec) spans.push(span);
+ }
+ }
+ return spans;
+}
+
+/**
+ * The complement of {@link keptRawSpans} over `[0, lastClipRawEnd]`, sorted, each stretch
+ * carrying the ids of the trims that took it.
+ *
+ * Two boundaries decide what this does and do not follow from the definition:
+ *
+ * It stops at the last CLIP's raw end, not the last KEPT span's. A trimmed tail of the
+ * last clip is inside the programme's extent and so is genuinely removed; raw time PAST
+ * every clip is not removed but simply unfilmed, because `projectRawTimelineSecToPlayback`
+ * is the identity there. That is what lets a voiceover hang off the end of the programme
+ * and keep playing, its words still reading kept, instead of being silently swallowed.
+ *
+ * Gaps count as removed, with no trim ids. Nothing plays there, so a word over a gap is
+ * not in the film — but there is no trim to restore, and the pane must not offer one.
+ */
+export function removedRawSpans(
+ clips: AxcutClip[],
+ trimRanges: AxcutTrimRange[],
+): RemovedRawSpan[] {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ if (ordered.length === 0) return [];
+
+ const removed: RemovedRawSpan[] = [];
+ let cursor = 0; // raw end of the programme walked so far
+
+ for (const clip of ordered) {
+ const extent = clipRawExtent(clip);
+ // The unfilmed stretch before this clip. `max` rather than a bare subtraction so
+ // two clips overlapping on the ruler contribute no negative gap.
+ if (extent.startSec > cursor) {
+ removed.push({ startSec: cursor, endSec: extent.startSec, trimIds: [] });
+ }
+ cursor = Math.max(cursor, extent.endSec);
+
+ if (extent.endSec <= extent.startSec) continue;
+ const kept = keptSourceIntervals(clip, trimRanges);
+ // An unprobed clip has no source interval to cut, and passes through whole.
+ if (kept.length === 0 && (clip.sourceEndSec ?? clip.sourceStartSec) <= clip.sourceStartSec) {
+ continue;
+ }
+
+ // The trims that reach this clip, in raw, so a removed piece can name them.
+ const applicable = trimRanges
+ .filter((trim) => trimAppliesToClip(trim, clip))
+ .map((trim) => ({
+ id: trim.id,
+ ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }),
+ }));
+
+ let holeStart = extent.startSec;
+ for (const iv of kept) {
+ const span = sourceToRaw(clip, iv);
+ if (span.startSec > holeStart) {
+ removed.push(taggedHole(holeStart, span.startSec, applicable));
+ }
+ holeStart = Math.max(holeStart, span.endSec);
+ }
+ if (extent.endSec > holeStart) {
+ removed.push(taggedHole(holeStart, extent.endSec, applicable));
+ }
+ }
+
+ return removed.sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec);
+}
+
+function taggedHole(
+ startSec: number,
+ endSec: number,
+ applicable: Array<{ id: string; startSec: number; endSec: number }>,
+): RemovedRawSpan {
+ return {
+ startSec,
+ endSec,
+ trimIds: applicable
+ .filter((trim) => trim.endSec > startSec && trim.startSec < endSec)
+ .map((trim) => trim.id),
+ };
+}
+
+/**
+ * The stretch removing `rawSec`, or null when the moment is in the film.
+ *
+ * Half-open: a moment exactly on a removed span's end belongs to what follows, so a word
+ * whose centre lands on the far edge of a cut reads as kept.
+ */
+export function removalAt(removed: RemovedRawSpan[], rawSec: number): RemovedRawSpan | null {
+ for (const span of removed) {
+ if (rawSec < span.startSec) break; // sorted, so nothing later can contain it
+ if (rawSec < span.endSec) return span;
+ }
+ return null;
+}
+
+/**
+ * `[startSec, endSec]` with every removed stretch taken out — the pieces of a span that
+ * survive into the film, in order.
+ *
+ * This is what turns one audio track into the several mix entries a cut underneath it
+ * demands: a voiceover crossing a trim plays as two pieces, not as one take shortened at
+ * the tail.
+ */
+export function subtractRemoved(
+ startSec: number,
+ endSec: number,
+ removed: RemovedRawSpan[],
+): RawSpan[] {
+ if (endSec <= startSec) return [];
+ let pieces: Interval[] = [{ startSec, endSec }];
+ for (const span of removed) {
+ if (span.startSec >= endSec) break; // sorted; nothing later overlaps
+ if (span.endSec <= startSec) continue;
+ pieces = subtractInterval(pieces, { startSec: span.startSec, endSec: span.endSec });
+ }
+ return pieces.filter((piece) => piece.endSec > piece.startSec);
+}
From d602d54b4d6157ad000ef808067c1c9b559eabb6 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Wed, 2 Sep 2026 21:58:05 +0200
Subject: [PATCH 64/84] feat(editor): decide a word by the ruler, so both lanes
agree
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The transcript pane decided kept-or-removed by asking whether a trim NAMED the
placement — `trimAppliesToClip`, matching a clip id, else an asset id. A
voiceover placement carries an audio fragment id and an audio asset; every trim
carries a video clip. They never matched, so the voiceover lane read every word
as kept, including words whose moment had been cut out of the film.
`buildClipSection` now takes the removed set from `removedRawSpans` instead of
the trim rows, and tags each word by the RAW moment its centre occupies. Centre,
not overlap — mirroring the rule the identity filter used, so the recording
lane's answers do not shift. Both lanes read one set, so a cut on either greys
the other, and the existing shared-media guarantees are unchanged: the same
per-clip walk decides both.
`ClipWord.trimId` and `TrimRun.trimId` become `trimIds: string[]`. Two
consequences worth naming:
- Overlapping trims merge into one hole carrying both ids, where they used to
split into one run per attributed trim. Restoring still takes one click per
trim, as before — step 4 replaces the single-id op with one that drops the
whole pill.
- A gap between clips is removed with an EMPTY set. It is missing from the
film, so its words are struck through; nothing took it, so there is no bin
icon to click. The affordance is keyed on the set being non-empty.
`findCueWordId` takes a raw second instead of a clip id plus a source second. A
clip id is something only the recording lane has, so the voiceover lane never
highlighted anything at all. Raw time also settles the case the clip id was
introduced for — two clips over one media have identical source ranges but
different raw extents — and `locateVirtualPosition` drops out of the pane.
A LOOPING voiceover now contributes no placement. `anchorAudioTrackFragments`
deliberately does not advance `offsetMs` across a looping track's fragments, so
their words map to raw moments the words do not occupy: the lane would read
kept-or-removed on false evidence and, after step 4, author a cut in the wrong
place.
Forcing `removalAt` to answer null fails 8 assertions across the two aggregator
suites, so the fixtures are exercising the rule rather than agreeing with it by
construction.
Refs #560. Step 2 of 7.
---
src/components/ai-edition/RightPanes.tsx | 57 +++--
.../aggregated-transcript.lanes.test.ts | 160 ++++++++++++++
.../timeline/aggregated-transcript.test.ts | 196 ++++++++++++------
.../timeline/aggregated-transcript.ts | 169 ++++++++-------
.../timeline/sharedMediaTrim.test.ts | 3 +-
5 files changed, 416 insertions(+), 169 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index ecc15bc09..d850bba90 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -74,7 +74,7 @@ import {
} from "@/lib/ai-edition/timeline/aggregated-transcript";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatMs } from "@/lib/ai-edition/timeline/format";
-import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import type { TranscriptGateReason } from "@/lib/ai-edition/transcription/status";
import { getAssetPath } from "@/lib/assetPath";
import { resolveWebcamLayoutPreset, supportsWebcamReactiveZoom } from "@/lib/compositeLayout";
@@ -858,29 +858,24 @@ export function TranscriptPane({
lane === "voiceover" && voiceover.length === 0 ? "recording" : lane;
const placements = activeLane === "voiceover" ? voiceover : clips;
+ // From the RECORDING clips and the whole trim set, never from `placements`: the
+ // programme is one thing, and the voiceover lane is asking whether the film still
+ // contains a moment — not whether some trim happens to name an audio fragment.
+ const removed = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
const sections = useMemo(
- () => buildAggregatedSections(placements, transcripts, assets, trimRanges),
- [placements, transcripts, assets, trimRanges],
+ () => buildAggregatedSections(placements, transcripts, assets, removed),
+ [placements, transcripts, assets, removed],
);
- // the cue position is the playback head's location in the current clip's source time.
// `currentTimeSec` is the RAW/document timeline (same referential as the ruler, see
- // NewEditorShell) — looked up against the raw `clips`, matching that referential.
- // `clipId` is what `findCueWordId` keys on — do NOT drop it as unused: source time is
- // per asset, so without it the resolver falls back to the first section of the asset
- // and the cue tracks clip 1 forever on a timeline that plays one media twice.
- const cue = useMemo(() => {
- if (clips.length === 0) return null;
- const position = locateVirtualPosition(clips, currentTimeSec);
- if (!position) return null;
- return {
- assetId: position.clip.assetId,
- clipId: position.clip.id,
- sourceTimeSec: position.sourceTimeSec,
- };
- }, [clips, currentTimeSec]);
-
- const cueWordId = useMemo(() => findCueWordId(sections, cue), [sections, cue]);
+ // NewEditorShell), which is exactly what `findCueWordId` now takes. It used to be
+ // resolved through `locateVirtualPosition` into a clip id + source second, and a clip
+ // id is something only the recording lane has — so the voiceover lane never
+ // highlighted. Raw seconds are the coordinate both lanes share.
+ const cueWordId = useMemo(
+ () => findCueWordId(sections, currentTimeSec),
+ [sections, currentTimeSec],
+ );
const laneSwitch =
voiceover.length > 0 ? : null;
@@ -1123,8 +1118,12 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
const removeTrimRun = useCallback(
(run: TrimRun) => {
- if (busy || !run.trimId) return;
- onRemoveTrimRange(run.trimId);
+ // An empty set is a gap between clips: removed from the film, but by nothing
+ // there is a pill for. Step 4 of #560 replaces this with a call that drops every
+ // row of the pill at once; today's op takes one id, so overlapping cuts still
+ // need a second click, exactly as before.
+ if (busy || run.trimIds.length === 0) return;
+ onRemoveTrimRange(run.trimIds[0]);
},
[busy, onRemoveTrimRange],
);
@@ -1569,7 +1568,7 @@ const TranscriptWord = memo(function TranscriptWord({
onClick={(e) => {
e.stopPropagation();
onRestore({
- trimId: cw.trimId ?? "",
+ trimIds: cw.trimIds,
assetId: "",
startWordIndex: 0,
endWordIndex: 0,
@@ -1696,7 +1695,7 @@ const TranscriptWord = memo(function TranscriptWord({
data-start-sec={cw.word.startSec}
data-end-sec={cw.word.endSec}
data-inserted="true"
- data-skip-id={cw.trimId ?? undefined}
+ data-skip-id={cw.trimIds[0] ?? undefined}
style={{ display: "inline", opacity: removed ? 0.6 : 1 }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
@@ -1786,7 +1785,7 @@ const TranscriptWord = memo(function TranscriptWord({
data-word-id={cw.id}
data-start-sec={cw.word.startSec}
data-end-sec={cw.word.endSec}
- data-skip-id={cw.trimId ?? undefined}
+ data-skip-id={cw.trimIds[0] ?? undefined}
data-corrected={corrected ? "true" : undefined}
data-cue={isCue ? "true" : undefined}
title={corrected ? ts("transcript.correctedWord", { original }) : undefined}
@@ -1819,7 +1818,7 @@ const TranscriptWord = memo(function TranscriptWord({
the LLM is the only place that names a word a filler (via the
filler_or_hesitation reason when generating suggestions). */}
{cw.word.text}{" "}
- {removed && hover && cw.trimId ? (
+ {removed && hover && cw.trimIds.length > 0 ? (
{
e.stopPropagation();
- // build a minimal TrimRun stub — only trimId is
+ // build a minimal TrimRun stub — only the ids are
// read by onRestore.
onRestore({
- trimId: cw.trimId ?? "",
+ trimIds: cw.trimIds,
assetId: "",
startWordIndex: 0,
endWordIndex: 0,
@@ -2012,7 +2011,7 @@ function findCollapsedDeletionWordId(
): string | null {
// read the kept/skip state from the words array, not the
// DOM's data-skip-id. The DOM may be lagging a render behind (its
- // trimId is only set on the next React commit), so a DOM check would
+ // skip id is only set on the next React commit), so a DOM check would
// re-trim an already-trimmed word. The words array is the React state
// captured at the call site — always current.
const skippedIds = new Set(words.filter((w) => !w.kept).map((w) => w.id));
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
index 81db5c0eb..0a458c36a 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
@@ -7,9 +7,12 @@ import { describe, expect, it } from "vitest";
import type { AxcutAudioTrack } from "../schema";
import {
buildAggregatedSections,
+ findCueWordId,
lanePlacements,
+ placementRawSec,
voiceoverPlacements,
} from "./aggregated-transcript";
+import { removedRawSpans } from "./programme-time";
function track(over: Partial & { id: string }): AxcutAudioTrack {
return {
@@ -121,3 +124,160 @@ describe("lanePlacements", () => {
expect(sections[0].words[0].id.startsWith("t1:")).toBe(true);
});
});
+
+// ─── The bug this parameterisation shipped with ──────────────────────────────
+// `b9e0f1ff` decided kept-or-removed by asking whether a trim NAMED the placement. A
+// voiceover placement carries an audio fragment id and an audio asset; every trim carries
+// a video clip. They never matched, so the voiceover lane read every word as kept — over
+// film that had been cut away — and a cut authored from it removed nothing at all. These
+// hold the ruler-based answer that replaced it.
+
+describe("one programme, two lanes", () => {
+ const CLIPS_2 = [
+ {
+ id: "clip_1",
+ assetId: "asset_rec",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ {
+ id: "clip_2",
+ assetId: "asset_rec",
+ sourceStartSec: 6,
+ sourceEndSec: 12,
+ timelineStartSec: 6,
+ timelineEndSec: 12,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ];
+
+ /** A cut over raw 2..4, anchored the way the transcript pane writes one. */
+ const TRIM = {
+ id: "trim_1",
+ assetId: "asset_rec",
+ clipId: "clip_1",
+ startSec: 2,
+ endSec: 4,
+ origin: "user" as const,
+ reason: "",
+ };
+
+ /** Words at one per second, so a word's index is its second. */
+ function secondsTranscript(assetId: string, count: number, from = 0) {
+ return {
+ assetId,
+ language: "en",
+ segments: [],
+ words: Array.from({ length: count }, (_, i) => ({
+ id: `w${from + i}`,
+ segmentId: "s",
+ text: `w${from + i}`,
+ startSec: from + i + 0.1,
+ endSec: from + i + 0.9,
+ })),
+ };
+ }
+
+ /** A voiceover laid over the whole programme, reading its own file from the head. */
+ const VO = track({ id: "vo_1", startMs: 0, endMs: 12000, offsetMs: 0, durationSec: 12 });
+
+ function lanes(trims: (typeof TRIM)[]) {
+ const removed = removedRawSpans(CLIPS_2, trims);
+ const transcripts = [secondsTranscript("asset_rec", 12), secondsTranscript("asset_vo", 12)];
+ const build = (lane: "recording" | "voiceover") =>
+ buildAggregatedSections(
+ lanePlacements(lane, CLIPS_2, [VO]),
+ // biome-ignore lint/suspicious/noExplicitAny: fixtures, not a schema exercise
+ transcripts as any,
+ [],
+ removed,
+ );
+ return { recording: build("recording"), voiceover: build("voiceover") };
+ }
+
+ const cutWords = (sections: ReturnType["recording"]) =>
+ sections
+ .flatMap((s) => s.words)
+ .filter((w) => !w.kept && !w.word.id.startsWith("silence_"))
+ .map((w) => w.word.text);
+
+ it("marks a voiceover word removed when the film under it was cut", () => {
+ // THE bug. Before this, the voiceover lane returned every word kept.
+ const { voiceover } = lanes([TRIM]);
+ expect(cutWords(voiceover)).toEqual(["w2", "w3"]);
+ const w2 = voiceover.flatMap((s) => s.words).find((w) => w.word.id === "w2");
+ expect(w2?.trimIds).toEqual(["trim_1"]);
+ });
+
+ it("greys the same moment on whichever lane you read", () => {
+ const { recording, voiceover } = lanes([TRIM]);
+ expect(cutWords(recording)).toEqual(["w2", "w3"]);
+ expect(cutWords(voiceover)).toEqual(cutWords(recording));
+ });
+
+ it("leaves both lanes whole when nothing is cut", () => {
+ const { recording, voiceover } = lanes([]);
+ expect(cutWords(recording)).toEqual([]);
+ expect(cutWords(voiceover)).toEqual([]);
+ });
+
+ it("removes a word over an inter-clip gap, with nothing to restore", () => {
+ const gapped = [CLIPS_2[0], { ...CLIPS_2[1], timelineStartSec: 8, timelineEndSec: 14 }];
+ const removed = removedRawSpans(gapped, []);
+ const sections = buildAggregatedSections(
+ voiceoverPlacements([track({ id: "vo_1", startMs: 0, endMs: 14000, durationSec: 14 })]),
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ [secondsTranscript("asset_vo", 14)] as any,
+ [],
+ removed,
+ );
+ const w6 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w6"); // raw 6..7
+ expect(w6?.kept).toBe(false);
+ // Nothing took it, so the pane must offer no bin: a gap is not a pill.
+ expect(w6?.trimIds).toEqual([]);
+ const run = sections.flatMap((s) => s.trimRuns).find((r) => r.trimIds.length === 0);
+ expect(run).toBeDefined();
+ });
+
+ it("keeps a word that hangs past the end of the programme", () => {
+ // The projection is the identity there, so the narration still plays.
+ const over = track({ id: "vo_1", startMs: 0, endMs: 20000, durationSec: 20 });
+ const sections = buildAggregatedSections(
+ voiceoverPlacements([over]),
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ [secondsTranscript("asset_vo", 20)] as any,
+ [],
+ removedRawSpans(CLIPS_2, []),
+ );
+ const w15 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w15");
+ expect(w15?.kept).toBe(true);
+ });
+
+ it("highlights the voiceover lane from a raw second", () => {
+ // The cue used to be resolved into a clip id, which only the recording lane has —
+ // so this returned null for every moment of every voiceover.
+ const { voiceover } = lanes([]);
+ expect(findCueWordId(voiceover, 4.5)).toBe("vo_1:w4");
+ expect(findCueWordId(voiceover, 0.5)).toBe("vo_1:w0");
+ });
+
+ it("reads a word's raw moment through its own placement", () => {
+ // A take starting 3s along the ruler, 5s into its file: its source 6 is raw 4.
+ const placement = { id: "p", assetId: "a", sourceStartSec: 5, timelineStartSec: 3 };
+ expect(placementRawSec(placement, 6)).toBe(4);
+ });
+
+ it("contributes no placement for a looping take", () => {
+ // `anchorAudioTrackFragments` does not advance `offsetMs` under loop, so a looping
+ // take's later fragments map their words to raw moments the words do not occupy.
+ expect(voiceoverPlacements([{ ...VO, loop: true }])).toEqual([]);
+ expect(voiceoverPlacements([VO])).toHaveLength(1);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index adc70b6cb..1a62d7e4c 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -7,6 +7,7 @@ import {
findCueWordId,
isSilenceWord,
} from "./aggregated-transcript";
+import { removedRawSpans } from "./programme-time";
function makeClip(overrides: Partial = {}): AxcutClip {
return {
@@ -78,18 +79,23 @@ describe("buildClipSection", () => {
]);
const trim = makeTrim({ id: "trim_a", startSec: 1, endSec: 4 });
- const section = buildClipSection(clip, transcript, makeAsset(), [trim]);
+ const section = buildClipSection(
+ clip,
+ transcript,
+ makeAsset(),
+ removedRawSpans([clip], [trim]),
+ );
expect(section.words.map((cw) => cw.kept)).toEqual([true, false, false, false, true]);
- expect(section.words.map((cw) => cw.trimId)).toEqual([
- null,
- "trim_a",
- "trim_a",
- "trim_a",
- null,
+ expect(section.words.map((cw) => cw.trimIds)).toEqual([
+ [],
+ ["trim_a"],
+ ["trim_a"],
+ ["trim_a"],
+ [],
]);
expect(section.trimRuns).toHaveLength(1);
expect(section.trimRuns[0]).toMatchObject({
- trimId: "trim_a",
+ trimIds: ["trim_a"],
startWordIndex: 1,
endWordIndex: 3,
durationSec: 3,
@@ -111,15 +117,15 @@ describe("buildClipSection", () => {
makeTrim({ id: "trim_b", startSec: 3, endSec: 4 }),
];
- const section = buildClipSection(clip, transcript, makeAsset(), trims);
+ const section = buildClipSection(clip, transcript, makeAsset(), removedRawSpans([clip], trims));
expect(section.trimRuns).toHaveLength(2);
expect(section.trimRuns[0]).toMatchObject({
- trimId: "trim_a",
+ trimIds: ["trim_a"],
startWordIndex: 1,
endWordIndex: 1,
});
expect(section.trimRuns[1]).toMatchObject({
- trimId: "trim_b",
+ trimIds: ["trim_b"],
startWordIndex: 3,
endWordIndex: 3,
});
@@ -146,29 +152,31 @@ describe("buildClipSection", () => {
it("marks the words removed only in the clip the trim is anchored to", () => {
const trim = makeTrim({ id: "trim_c2", clipId: "clip_2", startSec: 1, endSec: 2 });
+ const clips = [clip1(), clip2()];
const sections = buildAggregatedSections(
- [clip1(), clip2()],
+ clips,
[makeTranscript(words())],
[makeAsset()],
- [trim],
+ removedRawSpans(clips, [trim]),
);
expect(sections[0].words.map((cw) => cw.kept)).toEqual([true, true, true]);
expect(sections[0].trimRuns).toHaveLength(0);
expect(sections[1].words.map((cw) => cw.kept)).toEqual([true, false, true]);
expect(sections[1].trimRuns).toHaveLength(1);
- expect(sections[1].trimRuns[0]).toMatchObject({ trimId: "trim_c2", startWordIndex: 1 });
+ expect(sections[1].trimRuns[0]).toMatchObject({ trimIds: ["trim_c2"], startWordIndex: 1 });
});
it("still marks both clips for a pre-v7 trim that names no clip", () => {
// Back-compat: an un-anchored row keeps the asset-wide meaning it had, so an
// existing document reads exactly as it did before the anchor was introduced.
const trim = makeTrim({ id: "trim_legacy", startSec: 1, endSec: 2 });
+ const clips = [clip1(), clip2()];
const sections = buildAggregatedSections(
- [clip1(), clip2()],
+ clips,
[makeTranscript(words())],
[makeAsset()],
- [trim],
+ removedRawSpans(clips, [trim]),
);
expect(sections[0].trimRuns).toHaveLength(1);
expect(sections[1].trimRuns).toHaveLength(1);
@@ -183,7 +191,12 @@ describe("buildClipSection", () => {
]);
const trim = makeTrim({ id: "trim_x", assetId: "asset_2", startSec: 0.5, endSec: 2.5 });
- const section = buildClipSection(clip, transcript, makeAsset(), [trim]);
+ const section = buildClipSection(
+ clip,
+ transcript,
+ makeAsset(),
+ removedRawSpans([clip], [trim]),
+ );
// Trailing gap 2s→3s is a silence — the different-asset trim doesn't
// cover any of the three entries, so all stay kept.
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
@@ -201,7 +214,7 @@ describe("buildClipSection", () => {
// ponytail: the LLM (not the renderer) decides what is a filler. Every
// word renders as plain text in the right pane.
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
- expect(section.words.map((cw) => cw.trimId)).toEqual([null, null, null]);
+ expect(section.words.map((cw) => cw.trimIds)).toEqual([[], [], []]);
});
it("returns an empty words list when the clip has no matching transcript", () => {
@@ -261,10 +274,15 @@ describe("silence gaps", () => {
]);
const trim = makeTrim({ id: "trim_silence", startSec: 1, endSec: 2 });
- const section = buildClipSection(clip, transcript, makeAsset(), [trim]);
+ const section = buildClipSection(
+ clip,
+ transcript,
+ makeAsset(),
+ removedRawSpans([clip], [trim]),
+ );
const silence = section.words.find((cw) => isSilenceWord(cw.word));
expect(silence?.kept).toBe(false);
- expect(silence?.trimId).toBe("trim_silence");
+ expect(silence?.trimIds).toEqual(["trim_silence"]);
});
});
@@ -323,114 +341,164 @@ describe("buildAggregatedSections", () => {
});
describe("findCueWordId", () => {
+ // Takes a RAW ruler second. It used to take a clip id plus a source second, which only
+ // the recording lane could ever produce — so the voiceover lane never highlighted a
+ // word at all. Raw time is the coordinate both lanes share, and it settles the
+ // duplicated-clip case the clip id was introduced for: two sections over one media
+ // have identical source ranges but different raw extents.
function makeSection(
clipId: string,
assetId: string,
wordTimes: Array<[string, number, number]>,
+ clipOverrides: Partial = {},
) {
return {
- clip: makeClip({ id: clipId, assetId, sourceStartSec: 0, sourceEndSec: 100 }),
+ clip: makeClip({
+ id: clipId,
+ assetId,
+ sourceStartSec: 0,
+ sourceEndSec: 100,
+ timelineStartSec: 0,
+ timelineEndSec: 100,
+ ...clipOverrides,
+ }),
asset: makeAsset({ id: assetId }),
transcript: null,
words: wordTimes.map(([id, start, end]) => ({
id: clipWordId(clipId, id),
word: { id, segmentId: "s1", startSec: start, endSec: end, text: id },
kept: true,
- trimId: null,
+ trimIds: [],
})),
trimRuns: [],
};
}
- it("returns null when cue is null", () => {
+ it("returns null when there is no playhead", () => {
const section = makeSection("c1", "asset_1", [["w1", 0, 1]]);
expect(findCueWordId([section], null)).toBeNull();
});
- it("returns null when no section matches the cue asset", () => {
- const section = makeSection("c1", "asset_1", [["w1", 0, 1]]);
- const cue = { assetId: "asset_2", sourceTimeSec: 0.5 };
- expect(findCueWordId([section], cue)).toBeNull();
+ it("returns null when the head is before every section", () => {
+ const section = makeSection("c1", "asset_1", [["w1", 0, 1]], {
+ timelineStartSec: 10,
+ timelineEndSec: 110,
+ });
+ expect(findCueWordId([section], 2)).toBeNull();
});
- it("returns the word containing the cue time", () => {
+ it("returns the word containing the head", () => {
const section = makeSection("c1", "asset_1", [
["w1", 0, 1],
["w2", 1, 2],
["w3", 2, 3],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 1.5 })).toBe("c1:w2");
+ expect(findCueWordId([section], 1.5)).toBe("c1:w2");
});
- it("returns the previous word when the cue is between two words", () => {
+ it("returns the previous word when the head is between two words", () => {
const section = makeSection("c1", "asset_1", [
["w1", 0, 1],
["w2", 2, 3],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 1.5 })).toBe("c1:w1");
+ expect(findCueWordId([section], 1.5)).toBe("c1:w1");
});
- it("returns the previous word when the cue is before the first word", () => {
+ it("returns null when the head is before the first word", () => {
const section = makeSection("c1", "asset_1", [
["w1", 5, 6],
["w2", 7, 8],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 0.5 })).toBeNull();
+ expect(findCueWordId([section], 0.5)).toBeNull();
});
- it("returns the last word when the cue is after the last word", () => {
+ it("returns the last word when the head is past the last word", () => {
const section = makeSection("c1", "asset_1", [
["w1", 0, 1],
["w2", 1, 2],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 99 })).toBe("c1:w2");
+ expect(findCueWordId([section], 99)).toBe("c1:w2");
+ });
+
+ it("reads the head through the section's own source clock", () => {
+ // A clip that starts 20s along the ruler and 5s into its media: raw 22 is source 7.
+ const section = makeSection("c1", "asset_1", [["w1", 6, 8]], {
+ sourceStartSec: 5,
+ sourceEndSec: 15,
+ timelineStartSec: 20,
+ timelineEndSec: 30,
+ });
+ expect(findCueWordId([section], 22)).toBe("c1:w1");
+ expect(findCueWordId([section], 2)).toBeNull();
});
// Two clips over the same media project the SAME transcript words twice, so the cue
- // has to be resolved against the clip that is actually playing. Matching on assetId
- // alone always returned the first section — the highlight tracked clip 1 forever.
+ // has to be resolved against the one that is actually playing.
describe("two clips over the same media", () => {
const sections = () => [
- makeSection("c1", "asset_1", [
- ["w1", 0, 1],
- ["w2", 1, 2],
- ]),
- makeSection("c2", "asset_1", [
- ["w1", 0, 1],
- ["w2", 1, 2],
- ]),
+ makeSection(
+ "c1",
+ "asset_1",
+ [
+ ["w1", 0, 1],
+ ["w2", 1, 2],
+ ],
+ { sourceEndSec: 3, timelineStartSec: 0, timelineEndSec: 3 },
+ ),
+ makeSection(
+ "c2",
+ "asset_1",
+ [
+ ["w1", 0, 1],
+ ["w2", 1, 2],
+ ],
+ { sourceEndSec: 3, timelineStartSec: 3, timelineEndSec: 6 },
+ ),
];
- it("resolves the cue against the clip that is playing", () => {
- expect(
- findCueWordId(sections(), { assetId: "asset_1", clipId: "c2", sourceTimeSec: 1.5 }),
- ).toBe("c2:w2");
- expect(
- findCueWordId(sections(), { assetId: "asset_1", clipId: "c1", sourceTimeSec: 1.5 }),
- ).toBe("c1:w2");
+ it("resolves the head against the clip that is playing", () => {
+ // Source 1.5 in both, but raw 4.5 is only inside c2.
+ expect(findCueWordId(sections(), 4.5)).toBe("c2:w2");
+ expect(findCueWordId(sections(), 1.5)).toBe("c1:w2");
});
it("returns an id that cannot match the other clip's copy of the same word", () => {
- const cue = findCueWordId(sections(), {
- assetId: "asset_1",
- clipId: "c2",
- sourceTimeSec: 1.5,
- });
+ const cue = findCueWordId(sections(), 4.5);
// The whole point: `word.id` is "w2" in BOTH sections, so a bare word id lit up
// both blocks. Exactly one rendered word may claim the cue.
const claiming = sections().flatMap((s) => s.words.filter((cw) => cw.id === cue));
expect(claiming).toHaveLength(1);
});
- it("falls back to the asset when the caller names no clip", () => {
- expect(findCueWordId(sections(), { assetId: "asset_1", sourceTimeSec: 1.5 })).toBe("c1:w2");
+ it("returns null rather than another clip's words when the playing clip has none", () => {
+ const withEmptyC2 = [
+ sections()[0],
+ makeSection("c2", "asset_1", [], {
+ sourceEndSec: 3,
+ timelineStartSec: 3,
+ timelineEndSec: 6,
+ }),
+ ];
+ // c2 has no words, and borrowing c1's would point at the wrong text.
+ expect(findCueWordId(withEmptyC2, 4.5)).toBeNull();
});
- it("returns null rather than another clip's words when the playing clip has none", () => {
- const withEmptyC2 = [sections()[0], makeSection("c2", "asset_1", [])];
- expect(
- findCueWordId(withEmptyC2, { assetId: "asset_1", clipId: "c2", sourceTimeSec: 1.5 }),
- ).toBeNull();
+ it("runs an open-ended placement up to the next one", () => {
+ // An unprobed clip has no raw extent of its own; it ends where the next begins.
+ const open = [
+ makeSection("c1", "asset_1", [["w1", 0, 1]], {
+ sourceEndSec: undefined,
+ timelineStartSec: 0,
+ timelineEndSec: 3,
+ }),
+ makeSection("c2", "asset_1", [["w1", 0, 1]], {
+ sourceEndSec: 3,
+ timelineStartSec: 3,
+ timelineEndSec: 6,
+ }),
+ ];
+ expect(findCueWordId(open, 2)).toBe("c1:w1");
+ expect(findCueWordId(open, 3.5)).toBe("c2:w1");
});
});
});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 9b59f2bd6..9ecc0d5b5 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -14,15 +14,8 @@
// names a word a filler. The transcript view shows plain text for every
// kept word; the user or the LLM decides what to mark as skipped.
-import type {
- AxcutAsset,
- AxcutAudioTrack,
- AxcutClip,
- AxcutTranscript,
- AxcutTrimRange,
- AxcutWord,
-} from "../schema";
-import { trimAppliesToClip } from "./trim-mapping";
+import type { AxcutAsset, AxcutAudioTrack, AxcutClip, AxcutTranscript, AxcutWord } from "../schema";
+import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
/**
* The unit the aggregation actually runs over: one stretch of ONE asset's source
@@ -53,6 +46,26 @@ export interface TranscriptPlacement {
/** Which lane's speech the transcript tab is reading. */
export type TranscriptLane = "recording" | "voiceover";
+/**
+ * A source second of this placement's asset, as a moment on the RAW ruler.
+ *
+ * The one coordinate both lanes share. Source time is per asset, so it cannot say
+ * whether two things coincide; raw time can, which is why kept-or-removed is asked here
+ * and not in source time (issue #560).
+ */
+export function placementRawSec(placement: TranscriptPlacement, sourceSec: number): number {
+ return placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
+}
+
+/** The placement's own stretch of raw ruler, or null when it runs open-ended. */
+export function placementRawExtent(placement: TranscriptPlacement): RawSpan | null {
+ if (placement.sourceEndSec === undefined) return null;
+ return {
+ startSec: placement.timelineStartSec,
+ endSec: placementRawSec(placement, placement.sourceEndSec),
+ };
+}
+
/** Gaps between words at least this long are surfaced as a `[silence]` token. */
export const SILENCE_THRESHOLD_SEC = 0.2;
@@ -116,8 +129,13 @@ function withSilenceGaps(
/** A contiguous run of removed words inside one clip's source range. */
export interface TrimRun {
- /** Id of the trim range this run came from (used by the bin-icon restore). */
- trimId: string;
+ /**
+ * The trims that took this run — SEVERAL when they overlap, and EMPTY when the run
+ * sits in a gap between clips, which is missing from the film without anything having
+ * removed it. A restore affordance must be keyed on this being non-empty: there is no
+ * pill to click for a gap.
+ */
+ trimIds: string[];
/** Index of the first removed word in `words`. */
startWordIndex: number;
/** Inclusive index of the last removed word in `words`. */
@@ -148,10 +166,10 @@ export interface ClipWord {
/** {@link clipWordId} — the word's identity *in this clip*, unique across the pane. */
id: string;
word: AxcutWord;
- /** Whether the word is inside a trimRange for this clip's asset. */
+ /** Whether the raw moment this word occupies is still in the film. */
kept: boolean;
- /** Id of the trim range that removed this word, if any. */
- trimId: string | null;
+ /** The trims that took it — empty when kept, and empty for a word over a gap. */
+ trimIds: string[];
}
/** One placement's contribution to the aggregated flow. */
@@ -176,37 +194,25 @@ function wordsInRange(transcript: AxcutTranscript, startSec: number, endSec: num
);
}
-/** Find the trim range covering this word's center (returns the deepest match). */
-function findCoveringTrim(word: AxcutWord, trimRanges: AxcutTrimRange[]): AxcutTrimRange | null {
- const center = (word.startSec + word.endSec) / 2;
- for (const trim of trimRanges) {
- if (center >= trim.startSec && center <= trim.endSec) return trim;
- }
- return null;
-}
-
/**
- * Build one clip section. Words inside the clip's source range that fall
- * inside any trim range for the same asset are marked removed; the rest
- * are kept. Contiguous removed words from the same trim range group into
- * one `TrimRun` (for the trim-duration pill + bin-icon restore).
+ * Build one placement's section. A word is removed when the RAW moment it occupies is not
+ * in the film; the rest are kept. Contiguous removed words taken by the same trims group
+ * into one `TrimRun` (for the trim-duration pill + bin-icon restore).
+ *
+ * Takes the precomputed removed set, not the trim rows. Filtering rows by identity —
+ * `trimAppliesToClip`, which is what this did — is a question a voiceover placement can
+ * never answer yes to: it carries an audio fragment id and an audio asset, while every
+ * trim carries a video clip. That is what left the voiceover lane reading every word as
+ * kept over film that had been cut away (issue #560). Asking the ruler instead makes both
+ * lanes agree by construction, and keeps the recording lane's answers identical: the same
+ * per-clip walk decides both.
*/
export function buildClipSection(
clip: TranscriptPlacement,
transcript: AxcutTranscript | null,
asset: AxcutAsset | null,
- trimRanges: AxcutTrimRange[],
+ removed: RemovedRawSpan[],
): ClipSection {
- // `trimAppliesToClip` — not a bare `assetId` match — is what keeps a cut on the
- // second of two clips over the same media from also greying out the first one's
- // words. Same media, same source range: only the clip anchor tells them apart.
- const clipTrims = trimRanges.filter(
- (trim) =>
- trimAppliesToClip(trim, clip) &&
- trim.endSec > clip.sourceStartSec &&
- trim.startSec < (clip.sourceEndSec ?? Infinity),
- );
-
const words = transcript
? withSilenceGaps(
wordsInRange(transcript, clip.sourceStartSec, clip.sourceEndSec ?? Infinity),
@@ -215,25 +221,27 @@ export function buildClipSection(
)
: [];
const tagged: ClipWord[] = words.map((word) => {
- const covering = findCoveringTrim(word, clipTrims);
+ // The word's CENTRE, mirroring the rule the identity filter used, so the recording
+ // lane's tagging does not shift under this change.
+ const covering = removalAt(removed, placementRawSec(clip, (word.startSec + word.endSec) / 2));
return {
id: clipWordId(clip.id, word.id),
word,
kept: covering === null,
- trimId: covering?.id ?? null,
+ trimIds: covering?.trimIds ?? [],
};
});
const trimRuns: TrimRun[] = [];
let runStart = -1;
let runEnd = -1;
- let runTrimId = "";
+ let runTrimIds: string[] = [];
let runMinStart = 0;
let runMaxEnd = 0;
const flush = () => {
if (runStart >= 0) {
trimRuns.push({
- trimId: runTrimId,
+ trimIds: runTrimIds,
assetId: clip.assetId,
startWordIndex: runStart,
endWordIndex: runEnd,
@@ -242,23 +250,26 @@ export function buildClipSection(
}
runStart = -1;
runEnd = -1;
- runTrimId = "";
+ runTrimIds = [];
runMinStart = 0;
runMaxEnd = 0;
};
+ const key = (ids: string[]) => ids.join("|");
tagged.forEach((cw, i) => {
if (cw.kept) {
flush();
return;
}
- // Split the run if the trim range id changes (overlapping trims).
- if (runStart >= 0 && cw.trimId !== runTrimId) {
+ // Split the run when the SET of trims changes, so two cuts meeting at a word
+ // boundary stay two pills. A run whose set is empty is a gap between clips: still
+ // removed, still one run, but with nothing to restore.
+ if (runStart >= 0 && key(cw.trimIds) !== key(runTrimIds)) {
flush();
}
if (runStart < 0) {
runStart = i;
runMinStart = cw.word.startSec;
- runTrimId = cw.trimId ?? "";
+ runTrimIds = cw.trimIds;
}
runEnd = i;
runMaxEnd = Math.max(runMaxEnd, cw.word.endSec);
@@ -277,7 +288,7 @@ export function buildAggregatedSections(
clips: TranscriptPlacement[],
transcripts: AxcutTranscript[],
assets: AxcutAsset[],
- trimRanges: AxcutTrimRange[],
+ removed: RemovedRawSpan[],
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
@@ -286,7 +297,7 @@ export function buildAggregatedSections(
clip,
transcriptById.get(clip.assetId) ?? null,
assetById.get(clip.assetId) ?? null,
- trimRanges,
+ removed,
),
);
}
@@ -304,13 +315,14 @@ export function buildAggregatedSections(
* already carry exactly the source windows this needs, and collapsing them back
* into one pill here would re-read the file from its head at every cut.
*
- * `loop` is ignored on purpose. A looping voiceover would repeat its words, and a
- * transcript that says the same sentence three times is not a transcript of
- * anything — the source window is what was said, however many times it plays.
+ * A LOOPING take contributes nothing at all. `anchorAudioTrackFragments` deliberately
+ * does not advance `offsetMs` across the fragments of a looping track, so their words map
+ * to raw moments the words do not occupy — a placement built from them would read
+ * kept-or-removed on false evidence, and would author a cut in the wrong place.
*/
export function voiceoverPlacements(audioTracks: AxcutAudioTrack[]): TranscriptPlacement[] {
return audioTracks
- .filter((track) => track.kind === "voiceover")
+ .filter((track) => track.kind === "voiceover" && !track.loop)
.slice()
.sort((a, b) => a.startMs - b.startMs || a.id.localeCompare(b.id))
.map((track) => {
@@ -334,16 +346,6 @@ export function lanePlacements(
return lane === "voiceover" ? voiceoverPlacements(audioTracks) : clips;
}
-/** Where the playback head currently is, in source time. */
-export interface CuePosition {
- assetId: string;
- /** Which clip is playing — the primary selector for the cue's section. Source time is
- * per asset, so `assetId` cannot separate two clips over one media; pass this whenever
- * the caller knows it (the transcript pane always does). */
- clipId?: string;
- sourceTimeSec: number;
-}
-
/**
* Find the word in `sections` that the playback head is currently inside, as a
* {@link clipWordId} — NOT a bare `word.id`, which would name the same moment in every
@@ -356,22 +358,39 @@ export interface CuePosition {
* - Silence tokens (id starts with `silence_`) are skipped over so a
* long pause doesn't surface a fake cue word.
*
- * The section is chosen by `cue.clipId` when the caller knows which clip is playing.
- * Matching on `assetId` alone always resolved to the FIRST section of that asset, so with
- * a clip duplicated on the timeline the cue tracked clip 1 while clip 2 played. `assetId`
- * stays as the fallback for callers that have no clip in hand.
+ * Takes a RAW ruler second. It used to take a clip id resolved from the playhead, which
+ * only ever named a video clip — so the voiceover lane never highlighted anything at all.
+ * Raw time is what both lanes have in common, and it also settles the case the clip id was
+ * introduced for: with one clip duplicated on the timeline, the two sections occupy
+ * different raw extents even though their source ranges are identical.
+ *
+ * The section is the one whose raw extent contains the head. An open-ended placement (a
+ * clip whose media has not been probed) has no extent of its own and runs to the next
+ * section's head, then to the end of time.
*/
-export function findCueWordId(sections: ClipSection[], cue: CuePosition | null): string | null {
- if (!cue) return null;
- const withWords = sections.filter((s) => s.words.length > 0);
- // No fallback when `clipId` is given but that clip has no transcript: the playing clip
- // simply has no cue word, and borrowing another clip's would point at the wrong text.
- const match = cue.clipId
- ? withWords.find((s) => s.clip.id === cue.clipId)
- : withWords.find((s) => s.clip.assetId === cue.assetId);
+export function findCueWordId(sections: ClipSection[], rawSec: number | null): string | null {
+ if (rawSec === null || !Number.isFinite(rawSec)) return null;
+ // No fallback to a neighbouring section: a placement with no transcript simply has no
+ // cue word, and borrowing another's would point at the wrong text.
+ const withWords = sections
+ .filter((s) => s.words.length > 0)
+ .sort((a, b) => a.clip.timelineStartSec - b.clip.timelineStartSec);
+
+ let match: ClipSection | null = null;
+ for (const [i, section] of withWords.entries()) {
+ if (rawSec < section.clip.timelineStartSec) break;
+ const extent = placementRawExtent(section.clip);
+ const endSec =
+ extent?.endSec ?? withWords[i + 1]?.clip.timelineStartSec ?? Number.POSITIVE_INFINITY;
+ if (rawSec < endSec) {
+ match = section;
+ break;
+ }
+ }
if (!match) return null;
- const t = cue.sourceTimeSec;
+ // Back to the placement's own source clock, which is what the words are stamped in.
+ const t = match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
let previous: string | null = null;
for (const cw of match.words) {
if (isSilenceWord(cw.word)) continue;
diff --git a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
index 2789dd5e5..5bbe7ac44 100644
--- a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
+++ b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
@@ -12,6 +12,7 @@ import { applyTimelineOperation } from "@/lib/ai-edition/document/operations";
import { resolvePlaybackSegments } from "@/lib/ai-edition/document/timeline";
import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema";
import { buildAggregatedSections } from "@/lib/ai-edition/timeline/aggregated-transcript";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { coalescedTrimGroups } from "@/lib/ai-edition/timeline/trim-mapping";
function doc(): AxcutDocument {
@@ -73,7 +74,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
next.timeline.clips,
next.transcripts,
next.assets,
- next.timeline.trimRanges,
+ removedRawSpans(next.timeline.clips, next.timeline.trimRanges),
);
expect(sections[0].trimRuns).toHaveLength(0);
expect(sections[1].trimRuns).toHaveLength(1);
From 456beeb6bf133e77a9e229836ad411a07b0068a7 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Wed, 2 Sep 2026 23:37:30 +0200
Subject: [PATCH 65/84] feat(audio): a cut under a voiceover takes the words,
not the take's tail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 2 made the transcript pane strike through a voiceover word whose moment the
film had lost. The mix went on playing it anyway — shifted earlier, because an
audio track is laid on the finished programme as one contiguous block — so the
red was a lie in both the preview and the export.
A voiceover is now SLICED by the cuts: `subtractRemoved` over its raw span gives
one mix entry per surviving piece, each reading the source seconds it actually
covers, so the two seconds a cut took are never heard. Fades stay on the TAKE's
outer edges rather than reappearing at every piece.
Music deliberately keeps the old path, byte for byte. A bed plays through a cut
and ends early — slicing it at every edit is a musical regression the current
code avoids on purpose (see the comment it already carries), and a bed has no
words whose redness has to be true. This kind-dependence is the one place the
design departs from a single shared projection, and it is the reason the
departure is worth it.
A LOOPING voiceover also keeps the old path. Step 6 refuses that combination
outright, and inventing semantics for something about to be banned would be the
worse answer.
`resolveVoiceoverPlayback` is extracted next to `resolveTimelineAudioPlayback`
rather than left inline in the rAF, because a decision that has to agree with the
export is a decision worth testing. It asks the question in RAW seconds, which is
both simpler and exact: no projection to invert, and the same question
`removedRawSpans` answers for the words themselves, so the two cannot drift.
Preview and export are held to each other by walking the take frame by frame and
collecting the contiguous runs of source time the preview would play, then
asserting they are the entries the scene description emits, piece for piece.
Letting the take play through the cut fails two of those assertions.
Every existing audio fixture is `kind: "music"`, which is why none of them moved.
Refs #560. Step 3 of 7.
---
.../ai-edition/VirtualPreview.audio.test.ts | 97 +++++++++++++++
src/components/ai-edition/VirtualPreview.tsx | 58 ++++++++-
src/native/sceneDescription.test.ts | 113 ++++++++++++++++++
src/native/sceneDescription.ts | 42 +++++++
4 files changed, 304 insertions(+), 6 deletions(-)
diff --git a/src/components/ai-edition/VirtualPreview.audio.test.ts b/src/components/ai-edition/VirtualPreview.audio.test.ts
index e1d6fec6d..39fea8528 100644
--- a/src/components/ai-edition/VirtualPreview.audio.test.ts
+++ b/src/components/ai-edition/VirtualPreview.audio.test.ts
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import { projectRawTimelineSecToPlayback } from "@/lib/ai-edition/document/timeline";
import type { AxcutAudioTrack, AxcutClip, AxcutTrimRange } from "@/lib/ai-edition/schema";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import {
applyPreviewAudioSettings,
type PreviewAudioGraph,
resolveAudioTrackPlayback,
resolveTimelineAudioPlayback,
+ resolveVoiceoverPlayback,
timelineAudioFadeAt,
} from "./VirtualPreview";
@@ -308,3 +310,98 @@ describe("timelineAudioFadeAt", () => {
expect(timelineAudioFadeAt(long, 2, 2)).toBe(1);
});
});
+
+// ─── A cut under a voiceover ──────────────────────────────────────────────────
+// Issue #560. The preview and the export have to agree about this, or a word the
+// transcript pane shows struck through is still audible in one of them.
+
+describe("resolveVoiceoverPlayback", () => {
+ const CLIPS = [
+ {
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ];
+ /** Raw 4..6 is out of the film. */
+ const TRIMS = [
+ {
+ id: "t1",
+ assetId: "scr",
+ clipId: "c1",
+ startSec: 4,
+ endSec: 6,
+ reason: "",
+ origin: "user" as const,
+ },
+ ];
+ const removed = removedRawSpans(CLIPS, TRIMS);
+
+ const voice = {
+ id: "vo",
+ assetId: "aud",
+ kind: "voiceover" as const,
+ startMs: 0,
+ endMs: 10_000,
+ durationSec: 30,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user" as const,
+ } as unknown as AxcutAudioTrack;
+
+ it("goes silent exactly where the film lost its moment", () => {
+ expect(resolveVoiceoverPlayback(voice, 3.9, removed).shouldPlay).toBe(true);
+ expect(resolveVoiceoverPlayback(voice, 4.5, removed).shouldPlay).toBe(false);
+ expect(resolveVoiceoverPlayback(voice, 6.1, removed).shouldPlay).toBe(true);
+ });
+
+ it("keeps the take's own clock running through the cut", () => {
+ // It does NOT rewind or skip: raw 7 is second 7 of the take either way, which is
+ // what makes the words after the cut still line up with the ones on screen.
+ expect(resolveVoiceoverPlayback(voice, 7, removed).targetTimeSec).toBeCloseTo(7, 6);
+ expect(
+ resolveVoiceoverPlayback({ ...voice, offsetMs: 2000 }, 7, removed).targetTimeSec,
+ ).toBeCloseTo(9, 6);
+ });
+
+ it("stays silent outside its own span, and past the end of its file", () => {
+ expect(resolveVoiceoverPlayback({ ...voice, startMs: 2000 }, 1, removed).shouldPlay).toBe(
+ false,
+ );
+ expect(resolveVoiceoverPlayback(voice, 11, removed).shouldPlay).toBe(false);
+ // A 3s file under a 10s span: silent after its own end rather than seeking past it.
+ const short = { ...voice, durationSec: 3 } as AxcutAudioTrack;
+ expect(resolveVoiceoverPlayback(short, 2.5, removed).shouldPlay).toBe(true);
+ expect(resolveVoiceoverPlayback(short, 3.5, removed).shouldPlay).toBe(false);
+ });
+
+ it("schedules the same source seconds the export writes into the mix", () => {
+ // Walk the take frame by frame and collect the contiguous runs of source time the
+ // preview would play; they must be the export's entries, piece for piece.
+ const runs: Array<{ from: number; to: number }> = [];
+ for (let raw = 0; raw < 10; raw += 0.05) {
+ const at = resolveVoiceoverPlayback(voice, raw, removed);
+ if (!at.shouldPlay) continue;
+ const last = runs.at(-1);
+ if (last && Math.abs(at.targetTimeSec - last.to) < 0.06) last.to = at.targetTimeSec;
+ else runs.push({ from: at.targetTimeSec, to: at.targetTimeSec });
+ }
+ expect(runs).toHaveLength(2);
+ expect(runs[0].from).toBeCloseTo(0, 1);
+ expect(runs[0].to).toBeCloseTo(4, 1);
+ // The second run resumes at source 6 — the two seconds the cut took are never heard.
+ expect(runs[1].from).toBeCloseTo(6, 1);
+ expect(runs[1].to).toBeCloseTo(10, 1);
+ });
+});
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index aed97cc4c..075446e01 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -19,6 +19,11 @@ import type {
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
+import {
+ type RemovedRawSpan,
+ removalAt,
+ removedRawSpans,
+} from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
import {
clampVirtualTime,
@@ -127,6 +132,42 @@ export function resolveTimelineAudioPlayback(
};
}
+/**
+ * Where a VOICEOVER should be, asked in RAW ruler seconds (issue #560).
+ *
+ * A cut under a voiceover removes the words that were said there, not the tail of the
+ * take: the transcript pane has already struck those words through, so a mix that went on
+ * playing them — shifted earlier, as a contiguous block does — would make the red a lie.
+ *
+ * Raw is the simpler question and the exact one. `resolveTimelineAudioPlayback` works in
+ * output seconds because a bed is one contiguous block laid on the finished programme, and
+ * getting there means projecting the playhead through the trims. A sliced take needs no
+ * projection at all: its source position is its own offset plus however far raw time has
+ * carried it, and it falls silent wherever the film did. That is the same question
+ * `removedRawSpans` answers for the words themselves, so the two cannot drift.
+ *
+ * Music does NOT come through here. A bed plays through a cut and ends early, on purpose.
+ */
+export function resolveVoiceoverPlayback(
+ track: AxcutAudioTrack,
+ rawSec: number,
+ removed: RemovedRawSpan[],
+) {
+ const startSec = track.startMs / 1000;
+ const offset = Math.max(0, track.offsetMs / 1000);
+ const sourceEnd = track.durationSec > 0 ? track.durationSec : Number.POSITIVE_INFINITY;
+ const local = rawSec - startSec;
+ const targetTimeSec = Math.min(Math.max(offset, offset + local), sourceEnd);
+ return {
+ targetTimeSec,
+ shouldPlay:
+ rawSec >= startSec &&
+ rawSec < track.endMs / 1000 &&
+ offset + local < sourceEnd &&
+ removalAt(removed, rawSec) === null,
+ };
+}
+
/** Fraction 0..1 of a track's volume `localSec` into its span, applying the
* ramps. Shares `resolveFadeSecs` with the export so a fade too long for its
* span is reduced the same way on both sides. */
@@ -547,6 +588,10 @@ export function VirtualPreview({
// must see the live trims, not the set captured when the loop was created.
const trimRangesRef = useRef(trimRanges);
trimRangesRef.current = trimRanges;
+ // What the film no longer contains, recomputed only when the cuts move — the rAF asks
+ // it once per voiceover per frame, and walking every trim there would be wasteful.
+ const removedRef = useRef(removedRawSpans(clips, trimRanges));
+ removedRef.current = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
// Trim-narrowed (`resolvePlaybackSegments`) — used ONLY to detect "has the
- {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => {
- // A word whose pause the film actually holds gets a BAND as wide
- // as the time it adds — that width is the added time, drawn. One
- // that fitted in silence already there adds nothing and stays the
- // hairline it was: there is nothing to show.
- const pause = inserts.find((ins) => ins.wordId === word.id);
- const left = pause
- ? ((expandRawSec(pause.atRawSec, inserts) - boxStart) / boxLen) * 100
- : atPct;
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ wordId, text, atRawSec }) => {
+ // A word whose pause the film holds gets a BAND as wide as the time
+ // it adds — that width IS the added time, drawn. One that fitted in
+ // silence already there adds nothing and stays a hairline.
+ //
+ // Both ends on ONE clock. The mark used to place a paused word on
+ // the expanded ruler and an unpaused one at a fraction of the clip's
+ // SOURCE span, in the same ternary — two clocks, one of which the
+ // box is not drawn in.
+ const pause = inserts.find((ins) => ins.wordId === wordId);
+ const left = ((expandRawSec(atRawSec, inserts) - boxStart) / boxLen) * 100;
const width = pause ? (pause.durationSec / boxLen) * 100 : 0;
return (
e.stopPropagation()}
onClick={(e) => {
// Jump to the moment the added text sits on. The clip box
// underneath would otherwise take this as a selection.
e.stopPropagation();
- setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
+ setCurrentTime(atRawSec);
}}
/>
);
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index ce5a642f1..0285abfaf 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -273,7 +273,6 @@
"help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
"helpInsert": "اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو.",
"insertAria": "كلمة جديدة",
- "insertRecordingOnly": "لا يمكن إضافة الكلمات إلا على التسجيل — فالوقفة تُجمّد إطارًا من الفيلم.",
"insertedWord": "أضفتها بنفسك — لا صوت خلفها",
"laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
"laneLabel": "اقرأ النص من",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index a0e7634a4..637f1b993 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -291,7 +291,6 @@
"help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
"helpInsert": "Type between two words to add one, in amber: it reaches the captions and leaves the film alone.",
"insertAria": "New word",
- "insertRecordingOnly": "Words can only be added on the recording — a pause holds a frame of film.",
"insertedWord": "Added by you — no audio behind it",
"laneFeedsCaptions": "Captions are burnt from this lane.",
"laneLabel": "Read the transcript from",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index ba2a8db7e..768d8135a 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -273,7 +273,6 @@
"help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
"helpInsert": "Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo.",
"insertAria": "Palabra nueva",
- "insertRecordingOnly": "Solo se pueden añadir palabras en la grabación: una pausa congela un fotograma.",
"insertedWord": "Añadida por ti: no hay audio detrás",
"laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
"laneLabel": "Leer la transcripción desde",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 211e8eadf..6eb4d3a7a 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -273,7 +273,6 @@
"help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
"helpInsert": "Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film.",
"insertAria": "Nouveau mot",
- "insertRecordingOnly": "On ne peut ajouter un mot que sur l’enregistrement : une pause fige une image du film.",
"insertedWord": "Ajouté par vous — aucun son derrière",
"laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
"laneLabel": "Lire la transcription depuis",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 84d18bff7..08639e6a5 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -273,7 +273,6 @@
"help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
"helpInsert": "Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video.",
"insertAria": "Nuova parola",
- "insertRecordingOnly": "Le parole si possono aggiungere solo sulla registrazione: una pausa congela un fotogramma.",
"insertedWord": "Aggiunta da te — nessun audio dietro",
"laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
"laneLabel": "Leggi la trascrizione da",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index b38f278c4..0d97f9eaa 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -273,7 +273,6 @@
"help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
"helpInsert": "単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。",
"insertAria": "新しい単語",
- "insertRecordingOnly": "単語を追加できるのは録画だけです。ポーズは映像の 1 コマを保持します。",
"insertedWord": "あなたが追加した単語 — 音声はありません",
"laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
"laneLabel": "文字起こしの読み込み元",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 2249f339d..5420fd044 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -273,7 +273,6 @@
"help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
"helpInsert": "두 단어 사이에 입력하면 호박색 단어가 추가됩니다.",
"insertAria": "새 단어",
- "insertRecordingOnly": "단어는 녹화에만 추가할 수 있습니다 — 일시 정지는 영상의 한 프레임을 붙듭니다.",
"insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
"laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
"laneLabel": "전사본을 읽어올 소스",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index b4bdcb35c..ae2943c21 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -273,7 +273,6 @@
"help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
"helpInsert": "Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo.",
"insertAria": "Nova palavra",
- "insertRecordingOnly": "Só é possível adicionar palavras na gravação — uma pausa congela um quadro do filme.",
"insertedWord": "Adicionada por você — sem áudio por trás",
"laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
"laneLabel": "Ler a transcrição de",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index d848088f6..f60dff088 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -273,7 +273,6 @@
"help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
"helpInsert": "Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео.",
"insertAria": "Новое слово",
- "insertRecordingOnly": "Слова можно добавлять только к записи: пауза удерживает кадр фильма.",
"insertedWord": "Добавлено вами — за ним нет звука",
"laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
"laneLabel": "Читать расшифровку из",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 1645ae03c..0afc9ec22 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -273,7 +273,6 @@
"help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
"helpInsert": "İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz.",
"insertAria": "Yeni kelime",
- "insertRecordingOnly": "Kelimeler yalnızca kayda eklenebilir — bir duraklama filmden bir kareyi dondurur.",
"insertedWord": "Sizin eklediğiniz — arkasında ses yok",
"laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
"laneLabel": "Deşifreyi şuradan oku",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index d38663b11..ebf020c6e 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -273,7 +273,6 @@
"help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
"helpInsert": "Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video.",
"insertAria": "Từ mới",
- "insertRecordingOnly": "Chỉ có thể thêm từ trên bản ghi — một khoảng dừng giữ lại một khung hình.",
"insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
"laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
"laneLabel": "Đọc bản chép lời từ",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 438a8f126..7a6cacefe 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -273,7 +273,6 @@
"help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
"helpInsert": "在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。",
"insertAria": "新词",
- "insertRecordingOnly": "只能在录制上添加词语——停顿会定格一帧画面。",
"insertedWord": "你添加的词 — 背后没有声音",
"laneFeedsCaptions": "字幕从这条轨道烧录。",
"laneLabel": "转写文本读取自",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 0b2dcaf06..a38e8f5c6 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -274,7 +274,6 @@
"help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
"helpInsert": "在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。",
"insertAria": "新字詞",
- "insertRecordingOnly": "只能在錄影上新增字詞——停頓會定格一格畫面。",
"insertedWord": "你加入的字詞 — 背後沒有聲音",
"laneFeedsCaptions": "字幕從這條軌道燒錄。",
"laneLabel": "轉錄文字讀取自",
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 4dd217209..8cf37988d 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -6,10 +6,11 @@
// everywhere except inside a pause — which is not a gap in the model, it is the pause.
import { describe, expect, it } from "vitest";
-import type { AxcutClip, AxcutInsertRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
collapseRawSec,
expandRawSec,
+ insertedWordMarks,
type RulerInsert,
rulerInserts,
totalInsertedSec,
@@ -155,3 +156,77 @@ describe("the expanded ruler", () => {
expect(collapseRawSec(4, [])).toEqual({ sec: 4, heldBy: null });
});
});
+
+// ─── Where an added word's mark goes ─────────────────────────────────────────
+// Issue #560. Two defects lived in one ternary in V4Timeline: a word WITH a pause was
+// placed on the expanded ruler and one WITHOUT at a fraction of the clip's SOURCE span —
+// two clocks, and the clip box is drawn in neither of them consistently. And both edges
+// were inclusive, so a word whose pause sits on a split boundary painted twice.
+
+function markClip(over: Partial & { id: string }): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 0,
+ timelineEndSec: 5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...over,
+ } as AxcutClip;
+}
+
+const synth = (id: string, startSec: number): AxcutWord =>
+ ({ id, segmentId: "s", text: id, startSec, endSec: startSec, source: "synth" }) as AxcutWord;
+
+describe("insertedWordMarks", () => {
+ const split = [
+ markClip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 0,
+ timelineEndSec: 5,
+ }),
+ markClip({
+ id: "c2",
+ sourceStartSec: 5,
+ sourceEndSec: 10,
+ timelineStartSec: 5,
+ timelineEndSec: 10,
+ }),
+ ];
+
+ it("paints a word on a split boundary exactly once", () => {
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_edge", 5)] }], split);
+ expect(marks).toHaveLength(1);
+ expect(marks[0]).toMatchObject({ clipId: "c2", atRawSec: 5 });
+ });
+
+ it("places every mark in RAW seconds through its own clip", () => {
+ const marks = insertedWordMarks(
+ [{ assetId: "a1", words: [synth("early", 2), synth("late", 7)] }],
+ split,
+ );
+ expect(marks.map((m) => [m.clipId, m.atRawSec])).toEqual([
+ ["c1", 2],
+ ["c2", 7],
+ ]);
+ });
+
+ it("keeps a word at the very end of the last clip", () => {
+ // Half-open everywhere but the tail, or the final word of a project vanishes.
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_end", 10)] }], split);
+ expect(marks.map((m) => m.wordId)).toEqual(["w_end"]);
+ });
+
+ it("ignores words nobody added", () => {
+ const spoken = { id: "w1", segmentId: "s", text: "w1", startSec: 2, endSec: 3 } as AxcutWord;
+ expect(insertedWordMarks([{ assetId: "a1", words: [spoken] }], split)).toEqual([]);
+ });
+
+ it("ignores a transcript no clip draws on", () => {
+ expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split)).toEqual([]);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index a7afcf27b..f514a6ba9 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -20,7 +20,7 @@
// of ruler maps to the single source moment being held. `collapseRawSec` returns that
// moment, which is exactly what a decoder parked on a held frame should be told.
-import type { AxcutClip, AxcutInsertRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
/** A pause placed on the raw ruler, ready to be counted. */
export interface RulerInsert {
@@ -106,3 +106,56 @@ export function collapseRawSec(
}
return { sec: sec - offset, heldBy: null };
}
+
+/** An added word, placed on the raw ruler through the clip that carries it. */
+export interface InsertedWordMark {
+ clipId: string;
+ wordId: string;
+ text: string;
+ atRawSec: number;
+}
+
+/**
+ * Where each added word's mark belongs, one per word.
+ *
+ * Claimed once, and half-open at a clip's far edge except for the last: a pause sits at the
+ * END of the word it follows, which is routinely a split boundary, and testing both edges
+ * inclusively painted the same word in BOTH halves (issue #560).
+ *
+ * Returns RAW seconds. The caller expands them; it used to mix a raw-then-expanded position
+ * for a word with a pause and a fraction of the clip's SOURCE span for one without, in the
+ * same ternary — two clocks, and the clip box is not drawn in the second.
+ */
+export function insertedWordMarks(
+ transcripts: ReadonlyArray<{ assetId: string; words: ReadonlyArray }>,
+ clips: readonly AxcutClip[],
+): InsertedWordMark[] {
+ const byAsset = new Map>();
+ for (const transcript of transcripts) {
+ const added = transcript.words.filter((word) => word.source === "synth");
+ if (added.length > 0) byAsset.set(transcript.assetId, added);
+ }
+ if (byAsset.size === 0) return [];
+
+ const marks: InsertedWordMark[] = [];
+ const claimed = new Set();
+ clips.forEach((clip, index) => {
+ const words = byAsset.get(clip.assetId);
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (!words || sourceEnd <= clip.sourceStartSec) return;
+ const isLast = index === clips.length - 1;
+ for (const word of words) {
+ if (claimed.has(word.id)) continue;
+ if (word.startSec < clip.sourceStartSec) continue;
+ if (word.startSec > sourceEnd || (!isLast && word.startSec === sourceEnd)) continue;
+ claimed.add(word.id);
+ marks.push({
+ clipId: clip.id,
+ wordId: word.id,
+ text: word.text,
+ atRawSec: clip.timelineStartSec + (word.startSec - clip.sourceStartSec),
+ });
+ }
+ });
+ return marks;
+}
From 30e27e7bcd11c66ef2bc044e67c041cc499e6b21 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 19:25:21 +0200
Subject: [PATCH 77/84] feat(editor): cut the notch into the take, inside one
outline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A take that holds somewhere is drawn in pieces: one waveform per stretch it
plays, and a hatched amber column where the voice stops. Inside ONE outline, on
purpose — the take is still one take, one draggable and slippable object, and the
eye should read "the voice stops here", not "two takes".
Opposite polarity to the clip lane's band. That one means the picture freezes
here; this one means the voice stops here and the film runs on underneath, which
is the whole difference between the two insertion lanes.
Positioned from the same walk the preview and the export read, so a notch cannot
appear where the voice does not actually stop. A take with no insertion — and
every music bed, and every looping take — keeps the single unbroken waveform it
has always had, so the common pill is untouched.
The notch takes no hit target of its own: clicking still selects the take, and
the word is deleted from the transcript, which is where it was created.
Refs #560.
---
.../ai-edition/v4/EditorShellV4.module.css | 26 ++++++
src/components/ai-edition/v4/V4Timeline.tsx | 91 +++++++++++++++++--
.../timeline/take-programme.test.ts | 36 ++++++++
3 files changed, 144 insertions(+), 9 deletions(-)
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 7ed68d78c..15af76b3e 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -1562,6 +1562,32 @@
/* Alt is held: the next drag on this pill slides the file under it rather than
moving the pill. The cursor is the confirmation, not the lesson — the tooltip
carries the words. */
+/* A take that holds somewhere is drawn in pieces inside ONE outline: the notch is cut out
+ of the fill, not laid over it, so the pill still reads as one take — one draggable,
+ slippable object. Opposite polarity to the clip lane's band, which means "the picture
+ freezes here"; this one means "the voice stops here, and the film runs on underneath"
+ (issue #560). */
+.laneAudioPiece {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ overflow: hidden;
+ pointer-events: none;
+}
+.laneAudioNotch {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ min-width: 2px;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ -45deg,
+ color-mix(in srgb, var(--warn) 42%, transparent) 0 3px,
+ transparent 3px 6px
+ );
+ border-left: 1px solid color-mix(in srgb, var(--warn) 70%, transparent);
+ border-right: 1px solid color-mix(in srgb, var(--warn) 70%, transparent);
+}
.laneAudioSlip {
cursor: ew-resize;
}
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 7e9c9e796..9f6ce3930 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -62,7 +62,9 @@ import {
newRegionDurationSec,
setTimelineScale,
} from "@/lib/ai-edition/timeline/newRegionDuration";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { ventilateSpanAcrossClips } from "@/lib/ai-edition/timeline/region-ventilation";
+import { type TakePiece, takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap";
import {
coalescedTrimGroups,
@@ -397,6 +399,7 @@ const AudioLanePill = memo(function AudioLanePill({
slipArmed,
outputGain,
ghost,
+ pieces,
}: {
track: AxcutAudioTrack;
url: string | undefined;
@@ -437,8 +440,18 @@ const AudioLanePill = memo(function AudioLanePill({
sourceStartSec: number;
sourceEndSec: number;
} | null;
+ /** The take's own walk, when it has one. Absent for music, for a looping take, and for
+ * a take with no insertion — all of which draw one unbroken waveform, exactly as
+ * before. */
+ pieces?: readonly TakePiece[] | null;
}) {
const duration = assetDurationSec ?? track.durationSec;
+ // Only a take that actually holds somewhere is drawn in pieces. Everything else keeps
+ // the single waveform it has always had, so the common pill is untouched.
+ const notched = pieces?.some((piece) => piece.kind === "hold") ? pieces : null;
+ const pillRawStart = track.startMs / 1000;
+ const pillRawSpan = Math.max(1e-6, track.endMs / 1000 - pillRawStart);
+ const atPctOfPill = (rawSec: number) => ((rawSec - pillRawStart) / pillRawSpan) * 100;
return (
<>
{/* The rest of the tape, dimmed and unclickable, behind the pill — so the pill
@@ -493,15 +506,50 @@ const AudioLanePill = memo(function AudioLanePill({
style={{ left: 0 }}
onPointerDown={(e) => onStartDrag(e, track, "l")}
/>
-
+ {notched ? (
+ // A notch cut out of the fill, inside ONE outline. The take is still one
+ // take — one draggable, slippable object — and the eye should read "the
+ // voice stops here", not "two takes". The opposite polarity of the clip
+ // lane's band, which means "the picture freezes here" (issue #560).
+ notched.map((piece) => {
+ const left = atPctOfPill(piece.rawStartSec);
+ const width = atPctOfPill(piece.rawEndSec) - left;
+ return piece.kind === "hold" ? (
+
+ ) : (
+
+
+
+ );
+ })
+ ) : (
+
+ )}
{/* Where the file starts over, so a looping bed reads as one deliberate
repeat rather than a mystery. Only drawn when the pill actually
outruns its source — otherwise there is nothing to repeat. */}
@@ -1046,6 +1094,30 @@ export function V4Timeline({
// it keeps a document written before that rule legible instead of stacking its pills on
// top of each other. A kind with no tracks takes no row, so the common single-bed
// project stays exactly as tall as it was.
+ // One walk per take, for the lane to draw. Same inputs the preview and the export use,
+ // so a notch cannot appear where the voice does not actually stop.
+ const takePieces = useMemo(() => {
+ const clipAssetIds = new Set(clips.map((c) => c.assetId));
+ const removed = removedRawSpans(clips, tl.trimRanges);
+ const out = new Map();
+ for (const pill of audioPills) {
+ if (pill.kind !== "voiceover" || pill.loop) continue;
+ // A range naming an asset that is no clip's is a take's — the same test
+ // `resolveInsertPlacement` makes, available here without a document.
+ const inserts = (tl.insertRanges ?? [])
+ .filter((range) => range.assetId === pill.assetId && !clipAssetIds.has(range.assetId))
+ .map((range) => ({
+ id: range.id,
+ wordId: range.wordId,
+ atSourceSec: range.atSec,
+ durationSec: range.durationSec,
+ }));
+ if (inserts.length === 0) continue;
+ out.set(pill.id, takeProgramme(pill, removed, inserts));
+ }
+ return out;
+ }, [audioPills, clips, tl.trimRanges, tl.insertRanges]);
+
const audioRows = useMemo(() => {
const voice = audioPills.filter((p) => p.kind === "voiceover");
const music = audioPills.filter((p) => p.kind !== "voiceover");
@@ -2146,6 +2218,7 @@ export function V4Timeline({
slipHint={ts("audioTrack.slipHint")}
slipArmed={slipArmed}
outputGain={audioGainScalar(settings.audioGainDb)}
+ pieces={takePieces.get(track.id) ?? null}
ghost={((g) =>
g
? {
diff --git a/src/lib/ai-edition/timeline/take-programme.test.ts b/src/lib/ai-edition/timeline/take-programme.test.ts
index 4e157d18b..37c7d595a 100644
--- a/src/lib/ai-edition/timeline/take-programme.test.ts
+++ b/src/lib/ai-edition/timeline/take-programme.test.ts
@@ -236,3 +236,39 @@ describe("preview and export agree over a take with a cut and a pause", () => {
expect(resumed).toBeCloseTo(parked ?? -1, 1);
});
});
+
+// ─── What the lane has to draw ──────────────────────────────────────────────
+// The notch is positioned from the walk, as a fraction of the PILL's own raw span. These
+// pin the arithmetic the drawing does, so a notch cannot appear where the voice does not
+// actually stop.
+
+describe("the pieces a pill draws", () => {
+ const pctOfPill = (pieces: ReturnType, rawSec: number) =>
+ ((rawSec - TAKE.startMs / 1000) / (TAKE.endMs / 1000 - TAKE.startMs / 1000)) * 100;
+
+ it("cuts one notch, in the middle, at the width of the time it took", () => {
+ const pieces = takeProgramme(TAKE, [], [ins(4, 1)]);
+ const hold = pieces.find((p) => p.kind === "hold");
+ expect(hold).toBeDefined();
+ if (!hold) return;
+ expect(pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(40, 6);
+ expect(pctOfPill(pieces, hold.rawEndSec) - pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(
+ 10,
+ 6,
+ );
+ });
+
+ it("leaves a take with no insertion in one piece, so the pill draws as it always did", () => {
+ expect(takeProgramme(TAKE, [], []).some((p) => p.kind === "hold")).toBe(false);
+ });
+
+ it("covers the pill end to end, with no overlap and no hole", () => {
+ const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)]), [ins(4, 1)]);
+ let cursor = TAKE.startMs / 1000;
+ for (const piece of pieces) {
+ expect(piece.rawStartSec).toBeCloseTo(cursor, 6);
+ cursor = piece.rawEndSec;
+ }
+ expect(cursor).toBeCloseTo(TAKE.endMs / 1000, 6);
+ });
+});
From ae4d22ef538ef313134c86b88ab099a5cf45019c Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 19:40:49 +0200
Subject: [PATCH 78/84] fix(timeline): a scrub names a moment on the ruler, not
on the tape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pointer was measured against the EXPANDED ruler and the result written straight
into a store every consumer reads as a RAW second: the preview seek, the caption
lookup, the transcript cue, the audio mix. Past the first pause the playhead sat one
accumulated hold AHEAD of everything it pointed at — the right playhead over the wrong
subtitle.
`collapseRawSec` on the way in fixes the desync, but on its own it makes the scrub
unusable over a pause: a pause is zero raw seconds wide, so every ruler second inside
one collapses to the same raw moment and the playhead snapped back to the pause's left
edge the instant the pointer entered it. The drag therefore carries its ruler position
alongside, and the playhead prefers it — the only coordinate that can name a moment
INSIDE a pause.
The round trip is pinned in `inserted-time.test.ts`: expand→collapse returns the raw
second it started from on both sides of every pause, a pointer inside one lands on the
held moment and says which pause holds it, and a pointer past two counts both.
---
src/components/ai-edition/v4/V4Timeline.tsx | 37 +++++++++++++++++--
.../ai-edition/timeline/inserted-time.test.ts | 34 +++++++++++++++++
2 files changed, 67 insertions(+), 4 deletions(-)
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 9f6ce3930..1b1d094df 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -51,6 +51,7 @@ import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
import {
+ collapseRawSec,
expandRawSec,
type InsertedWordMark,
insertedWordMarks,
@@ -233,8 +234,11 @@ interface PlayheadOverlayProps {
* drawn on counts the pauses, so it has to be placed through them or it drifts from
* the clips by the whole added time. */
inserts: readonly RulerInsert[];
- /** Live scrub position, when a drag is in flight. Takes precedence over the store. */
+ /** Live scrub position in RAW seconds, when a drag is in flight. */
overrideTimeSec: number | null;
+ /** The same drag's RULER position. Preferred when present: it is the only coordinate
+ * that can name a moment INSIDE a pause, which is zero raw seconds wide. */
+ overrideRulerSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
playheadRef?: React.MutableRefObject;
@@ -260,12 +264,17 @@ const PlayheadOverlay = memo(function PlayheadOverlay({
totalSec,
inserts,
overrideTimeSec,
+ overrideRulerSec,
canvasStyle,
onPointerDown,
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const pct = (expandRawSec(overrideTimeSec ?? storeTimeSec, inserts) / totalSec) * 100;
+ // The scrub hands its RULER position straight through. Expanding the raw one instead
+ // would snap the playhead to a pause's left edge the moment the pointer entered it,
+ // because every ruler second inside a pause collapses to the same raw moment.
+ const pct =
+ ((overrideRulerSec ?? expandRawSec(overrideTimeSec ?? storeTimeSec, inserts)) / totalSec) * 100;
return (
@@ -830,6 +839,11 @@ export function V4Timeline({
// pointer for the frame the store hasn't caught up on yet. Handed down as an
// override to the two components that read the playhead from the store.
const [scrubbingTimeSec, setScrubbingTimeSec] = useState(null);
+ // The pointer's RULER position while scrubbing, kept apart from the raw one above.
+ // Two numbers because they mean different things: the timecode reads the raw clock,
+ // like the store, and the playhead has to be able to sit INSIDE a pause — which is
+ // zero raw seconds wide, so no raw value can address a moment within it.
+ const [scrubRulerSec, setScrubRulerSec] = useState(null);
const rafSeekRef = useRef(0);
const pendingSeekTimeRef = useRef(null);
@@ -846,7 +860,19 @@ export function V4Timeline({
if (!el) return;
const r = el.getBoundingClientRect();
const pct = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
- const targetTime = pct * total;
+ // `total` is the EXPANDED ruler, so `pct * total` is a ruler second — and
+ // `setCurrentTime` is read as a RAW one by every consumer: the preview seek, the
+ // caption lookup, the transcript cue, the audio mix. Writing the ruler value
+ // straight in put the playhead one accumulated pause AHEAD of everything it was
+ // supposed to be pointing at, which is what showed as the wrong subtitle under a
+ // correctly-placed playhead (issue #560).
+ //
+ // Collapsing lands on the held moment when the pointer is inside a pause, which
+ // is the honest answer: a pause is zero raw seconds, so there is no raw value
+ // inside it to seek to. The ruler position is kept separately below so the
+ // playhead still follows the pointer across it.
+ const rulerTime = pct * total;
+ const { sec: targetTime } = collapseRawSec(rulerTime, inserts);
// Direct DOM playhead update (0ms latency, zero React re-render overhead)
if (playheadElRef.current) {
@@ -855,6 +881,7 @@ export function V4Timeline({
// Optimistic local UI state update
setScrubbingTimeSec(targetTime);
+ setScrubRulerSec(rulerTime);
pendingSeekTimeRef.current = targetTime;
if (isImmediate) {
@@ -876,7 +903,7 @@ export function V4Timeline({
});
}
},
- [setCurrentTime, total],
+ [setCurrentTime, total, inserts],
);
// Mousedown anywhere on the empty timeline (ruler, lanes background, or
@@ -916,6 +943,7 @@ export function V4Timeline({
pendingSeekTimeRef.current = null;
}
setScrubbingTimeSec(null);
+ setScrubRulerSec(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
@@ -2418,6 +2446,7 @@ export function V4Timeline({
totalSec={total}
inserts={inserts}
overrideTimeSec={scrubbingTimeSec}
+ overrideRulerSec={scrubRulerSec}
canvasStyle={canvasStyle}
onPointerDown={startScrub}
playheadRef={playheadElRef}
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 8cf37988d..509050812 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -230,3 +230,37 @@ describe("insertedWordMarks", () => {
expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split)).toEqual([]);
});
});
+
+// ─── The scrub round-trip ───────────────────────────────────────────────────
+// The timeline measures the pointer against the EXPANDED ruler and writes the result into
+// a store every consumer reads as a RAW second — the preview seek, the caption lookup, the
+// transcript cue, the audio mix. Straight through, the playhead sat one accumulated pause
+// AHEAD of everything it pointed at: the right playhead, the wrong subtitle (issue #560).
+
+describe("a scrub survives the round trip", () => {
+ const marks: RulerInsert[] = [
+ { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
+ { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
+ ];
+
+ it("comes back to the raw second it started from, before and after each pause", () => {
+ for (const raw of [0, 1.5, 3.9, 6, 8.5, 12, 30]) {
+ expect(collapseRawSec(expandRawSec(raw, marks), marks).sec).toBeCloseTo(raw, 6);
+ }
+ });
+
+ it("lands on the held moment for a pointer inside a pause, and says so", () => {
+ // Every ruler second inside a pause is the same raw moment: the film is frozen
+ // there, so there is nothing else it could mean.
+ const inside = collapseRawSec(5, marks);
+ expect(inside.sec).toBeCloseTo(4, 6);
+ expect(inside.heldBy?.id).toBe("i1");
+ expect(collapseRawSec(5.9, marks).sec).toBeCloseTo(4, 6);
+ });
+
+ it("counts every pause before the pointer, not just the first", () => {
+ // Ruler 13 is past both: 13 − 2 − 1 = raw 10.
+ expect(collapseRawSec(13, marks).sec).toBeCloseTo(10, 6);
+ expect(collapseRawSec(13, marks).heldBy).toBeNull();
+ });
+});
From 92189a1894aab47193e179f2e8c181228d26ddb2 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 19:49:43 +0200
Subject: [PATCH 79/84] fix(preview): the picture spends the pause a word
bought
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A word added to a lane buys OUTPUT seconds, and the film has to spend them somewhere.
Three of the four clocks already did: the export holds a frame (`hold_sec`), the ruler
grows (`expandRawSec`), the programme clock steps over the pause
(`projectRawTimelineSecToPlayback`). The DOM preview spent nothing — the `` ran
straight through, because a pause has no frames of its own to decode. Past the first
pause the picture was D seconds BEHIND every imported track, which is positioned on the
programme clock, and the preview stopped being the same film as the export.
The element is paused and a wall clock runs the hold out. That is the honest model: the
frame is frozen, so MEDIA time is exactly what stops advancing while real time does not.
`heldElapsedSec` is added to `outputTimeSec` and nowhere else — the raw playhead stays
pinned to the held moment, which is what the caption lookup, the transcript cue and the
take walks should all read, because for the length of the pause the film really is at
that one instant.
`holdEnteredBetween` lifts the entry rule into `inserted-time` with the other ruler
arithmetic, because its half-open left edge is the whole reason the hold terminates: the
playhead is pinned to exactly `atRawSec`, so `>` is what refuses that same moment on the
way out. With `>=` the pause is re-entered the frame it ends and the film never gets
past it — pinned in the tests. A seek clears the hold outright, so scrubbing during a
pause cannot hand the film back to `play()` half a second later.
Known limit: the ruler playhead stands still for the length of the pause rather than
crawling across it. The store holds raw seconds, and a pause is zero raw seconds wide,
so naming a moment inside one needs a ruler channel the timeline does not have yet — the
same coordinate the scrub now carries in `overrideRulerSec`.
---
src/components/ai-edition/VirtualPreview.tsx | 96 +++++++++++++++++--
.../ai-edition/timeline/inserted-time.test.ts | 33 +++++++
src/lib/ai-edition/timeline/inserted-time.ts | 20 ++++
3 files changed, 140 insertions(+), 9 deletions(-)
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 60918a86c..e5b80f3dd 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -22,7 +22,7 @@ import type {
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
-import { rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
+import { holdEnteredBetween, rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
@@ -564,6 +564,28 @@ export function VirtualPreview({
// it once per voiceover per frame, and walking every trim there would be wasteful.
// The film's pauses, placed on the raw ruler once. The projection needs them or every
// track after a pause lands D seconds early — the bug this argument exists to close.
+ /** The pause the picture is currently sitting on, if any.
+ *
+ * A word added to a lane buys OUTPUT seconds, and the film has to spend them somewhere.
+ * The export spends them holding a frame (`hold_sec`, `walk_composited_timeline`); the
+ * ruler spends them by growing (`expandRawSec`); the programme clock spends them by
+ * stepping over them (`projectRawTimelineSecToPlayback`). The DOM preview did not spend
+ * them at all — the `` ran straight through, because a pause has no frames of its
+ * own to decode. So past the first pause the picture was D seconds BEHIND every track
+ * positioned on the programme clock, and the preview stopped being the same film as the
+ * export (issue #560).
+ *
+ * The element is paused and a WALL clock runs the hold out, which is the honest model:
+ * the frame is frozen, so media time is exactly what must stop advancing while real time
+ * does not. */
+ const holdRef = useRef<{
+ insertId: string;
+ rawSec: number;
+ durationSec: number;
+ startedAtMs: number;
+ } | null>(null);
+ /** Set when the hold interrupted actual playback, so the film resumes on its own. */
+ const resumeAfterHoldRef = useRef(false);
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
// One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
@@ -667,6 +689,27 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
+ // Run the pause out FIRST: everything below that is positioned on the programme
+ // clock — the imported tracks especially — is still advancing during a hold even
+ // though the picture is not, exactly as the render's output stream is.
+ let heldElapsedSec = 0;
+ const hold = holdRef.current;
+ if (hold) {
+ heldElapsedSec = Math.min(hold.durationSec, (performance.now() - hold.startedAtMs) / 1000);
+ if (heldElapsedSec >= hold.durationSec) {
+ holdRef.current = null;
+ heldElapsedSec = hold.durationSec;
+ if (resumeAfterHoldRef.current) {
+ resumeAfterHoldRef.current = false;
+ const resumed = v.play();
+ if (resumed) void resumed.catch(() => undefined);
+ }
+ } else if (!v.paused) {
+ // A `play()` from elsewhere (autoplay, a resume racing the hold) would
+ // otherwise let the picture walk out from under the pause.
+ v.pause();
+ }
+ }
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -703,13 +746,19 @@ export function VirtualPreview({
// was never given a faster `playbackRate`, but seeking it twice as fast
// amounts to the same thing. Dividing raw time by the rate turns that
// back into 1x wall-clock, which is what the render does too.
- const outputTimeSec = projectRawTimelineSecToPlayback(
- clipsRef.current,
- trimRangesRef.current,
- virtualTimeSecRef.current,
- filmInsertsRef.current,
- speedRegionsRef.current,
- );
+ // `+ heldElapsedSec`, and only here: the raw playhead is pinned to the held moment
+ // for the whole pause, and the projection of that moment is the pause's OPENING
+ // (`expandRawSec` and this walk both give the frame about to be held its own
+ // instant). Adding the wall-clock elapsed walks the programme through the pause,
+ // which is what the mixer downstream is doing over the same seconds.
+ const outputTimeSec =
+ projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ virtualTimeSecRef.current,
+ filmInsertsRef.current,
+ speedRegionsRef.current,
+ ) + heldElapsedSec;
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
@@ -980,7 +1029,28 @@ export function VirtualPreview({
seekToVirtualTimeRef.current?.(nextClip.timelineStartSec, true);
return;
}
- updateVirtualTime(clampVirtualTime(clipsRef.current, position.virtualTimeSec));
+ const nextRawTime = clampVirtualTime(clipsRef.current, position.virtualTimeSec);
+ // The first pause this frame stepped over — the rule, and why it is half-open,
+ // live with the other ruler arithmetic.
+ const entering = holdRef.current
+ ? undefined
+ : holdEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
+ if (entering) {
+ holdRef.current = {
+ insertId: entering.id,
+ rawSec: entering.atRawSec,
+ durationSec: entering.durationSec,
+ startedAtMs: performance.now(),
+ };
+ resumeAfterHoldRef.current = true;
+ v.pause();
+ // The held moment, not the frame we happened to land on: the transcript cue,
+ // the caption lookup and the audio mix all read this, and for the length of
+ // the pause the film really is at that one instant.
+ updateVirtualTime(entering.atRawSec);
+ return;
+ }
+ updateVirtualTime(nextRawTime);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
@@ -1079,6 +1149,14 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
+ // A seek ends any pause the picture was holding: the playhead is somewhere else
+ // now, so the frame we were frozen on is not the frame any more. Without this the
+ // hold's wall clock would run out under the new position and hand the film back to
+ // `play()` — the film starting itself again because the user scrubbed during a
+ // pause. The rAF's own seeks (clip advance, trim skip) are gated on `!paused` and
+ // so never reach here while a hold is in flight.
+ holdRef.current = null;
+ resumeAfterHoldRef.current = false;
const position = locateVirtualPosition(clips, nextVirtualTimeSec);
if (!position) {
videoRef.current?.pause();
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 509050812..6d4a32ada 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -10,6 +10,7 @@ import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
collapseRawSec,
expandRawSec,
+ holdEnteredBetween,
insertedWordMarks,
type RulerInsert,
rulerInserts,
@@ -264,3 +265,35 @@ describe("a scrub survives the round trip", () => {
expect(collapseRawSec(13, marks).heldBy).toBeNull();
});
});
+
+// ─── Entering a pause ───────────────────────────────────────────────────────
+// The preview holds the picture for a pause the way the export does (`hold_sec`). The
+// half-open rule below is what keeps that from becoming an infinite hold.
+
+describe("the pause a frame steps over", () => {
+ const marks: RulerInsert[] = [
+ { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
+ { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
+ ];
+
+ it("is found when the frame crosses it", () => {
+ expect(holdEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
+ expect(holdEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
+ });
+
+ it("is not found again from the moment it holds", () => {
+ // The hold pins the playhead to exactly 4. Coming out, the next frames must not
+ // re-enter — otherwise the film never gets past the pause.
+ expect(holdEnteredBetween(4, 4.02, marks)).toBeUndefined();
+ expect(holdEnteredBetween(4, 4.5, marks)).toBeUndefined();
+ });
+
+ it("takes the earliest of several in one frame, and none outside", () => {
+ expect(holdEnteredBetween(0, 20, marks)?.id).toBe("i1");
+ expect(holdEnteredBetween(5, 8, marks)).toBeUndefined();
+ });
+
+ it("holds a pause landing exactly on the frame boundary", () => {
+ expect(holdEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
+ });
+});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index f514a6ba9..6b6de359d 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -107,6 +107,26 @@ export function collapseRawSec(
return { sec: sec - offset, heldBy: null };
}
+/**
+ * The pause a frame of playback stepped over, if it stepped over one.
+ *
+ * Half-open on the LEFT, and that is the whole point: a player that holds pins its raw
+ * playhead to exactly `atRawSec` for the length of the pause, so `>` is what refuses that
+ * same moment on the way OUT. With `>=` the pause is re-entered the instant it ends and the
+ * film never gets past it. Closed on the right (with the frame epsilon) so a pause landing
+ * precisely on a frame boundary is held rather than skipped.
+ */
+export function holdEnteredBetween(
+ prevRawSec: number,
+ nextRawSec: number,
+ inserts: readonly RulerInsert[],
+ epsilonSec = 1e-6,
+): RulerInsert | undefined {
+ return inserts.find(
+ (insert) => insert.atRawSec > prevRawSec && insert.atRawSec <= nextRawSec + epsilonSec,
+ );
+}
+
/** An added word, placed on the raw ruler through the clip that carries it. */
export interface InsertedWordMark {
clipId: string;
From 43e73973689bdc0e344c004abc03b1b9c7401bfc Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 20:08:36 +0200
Subject: [PATCH 80/84] fix(preview): an insertion is media that plays, not a
pause
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two bugs, one wrong model. I had written the added word's media as a PAUSE — the film
stopping for a beat — and both symptoms follow directly from that.
**Playback stopped at an insertion.** Pausing the `` is how I "held" it, and the
store's `playing` flag mirrors the element's own `pause` event: the transport went to
stopped the moment an insertion began, and never came back. An insertion is a piece of
media on the timeline like any other, so playback runs THROUGH it. The element is now
PINNED (`currentTime` re-set to the insertion's source moment each frame, which is the
fixed frame standing in until there is a generator) and MUTED (the inserted audio is
silence today), never paused. The film keeps playing, because it is playing.
**Releasing a scrub over an insertion snapped back to its start.** The playhead had one
coordinate, `currentTimeSec`, and an insertion takes up ruler seconds while taking up
none of the recording — so every ruler second inside one collapses to the same raw
moment and no raw value can name a position within it. The store now carries
`currentRulerSec` beside it. Consumers that resolve MEDIA keep reading the raw second,
which is right: through an insertion the recording really is at that one instant. Only
what draws or measures the ruler reads the new one, and it is trusted only while it
still collapses back to the raw second, so a caller that predates insertions and writes
the same number for both is caught rather than believed.
That second coordinate is also what lets the playhead cross an insertion during
playback: the preview publishes raw and ruler together, the ruler one carrying how far
into the insertion its wall clock has run. Previously noted as a known limit; it is the
same fix.
Vocabulary swept through the ruler arithmetic, the projection, the timeline and the
preview: insertion, inserted media, fixed frame. "Pause" described the stand-in as
though it were the model, and the model is what the next reader builds on.
---
src/components/ai-edition/NewEditorShell.tsx | 4 +-
src/components/ai-edition/VirtualPreview.tsx | 153 ++++++++++--------
src/components/ai-edition/v4/V4Timeline.tsx | 77 +++++----
src/lib/ai-edition/document/timeline.ts | 30 ++--
src/lib/ai-edition/store/projectStore.ts | 24 ++-
.../ai-edition/timeline/inserted-time.test.ts | 37 ++---
src/lib/ai-edition/timeline/inserted-time.ts | 41 ++---
7 files changed, 217 insertions(+), 149 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 2a8761155..0beac701e 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -422,8 +422,8 @@ export function NewEditorShell() {
);
const handleTimeChange = useCallback(
- (timeSec: number) => {
- setCurrentTime(timeSec);
+ (timeSec: number, rulerSec?: number) => {
+ setCurrentTime(timeSec, rulerSec);
},
[setCurrentTime],
);
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index e5b80f3dd..7e3ba49b4 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -22,7 +22,11 @@ import type {
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
-import { holdEnteredBetween, rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
+import {
+ expandRawSec,
+ insertionEnteredBetween,
+ rulerInserts,
+} from "@/lib/ai-edition/timeline/inserted-time";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
@@ -215,10 +219,12 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
- /** The pauses added words created — they lengthen playback, they do not cut it. */
+ /** The media added words inserted — it lengthens playback, it does not cut it. */
insertRanges?: AxcutInsertRange[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
- onTimeChange?: (timeSec: number) => void;
+ /** `rulerSec` is the same moment measured on the ruler the user sees, which differs
+ * from `timeSec` as soon as an insertion sits before it, or under it. */
+ onTimeChange?: (timeSec: number, rulerSec: number) => void;
onLoadedMetadata?: (
durationSec: number,
assetId: string,
@@ -562,30 +568,33 @@ export function VirtualPreview({
trimRangesRef.current = trimRanges;
// What the film no longer contains, recomputed only when the cuts move — the rAF asks
// it once per voiceover per frame, and walking every trim there would be wasteful.
- // The film's pauses, placed on the raw ruler once. The projection needs them or every
- // track after a pause lands D seconds early — the bug this argument exists to close.
- /** The pause the picture is currently sitting on, if any.
+ // The film's insertions, placed on the raw ruler once. The projection needs them or every
+ // track after one lands D seconds early — the bug this argument exists to close.
+ /** The insertion currently playing, if any.
*
- * A word added to a lane buys OUTPUT seconds, and the film has to spend them somewhere.
- * The export spends them holding a frame (`hold_sec`, `walk_composited_timeline`); the
- * ruler spends them by growing (`expandRawSec`); the programme clock spends them by
- * stepping over them (`projectRawTimelineSecToPlayback`). The DOM preview did not spend
- * them at all — the `` ran straight through, because a pause has no frames of its
- * own to decode. So past the first pause the picture was D seconds BEHIND every track
- * positioned on the programme clock, and the preview stopped being the same film as the
- * export (issue #560).
+ * An added word inserts MEDIA inside the clip (issue #560). There is no generator for it
+ * yet, so the stand-in is a fixed frame and silence — but it is a piece of media on the
+ * timeline like any other, and playback runs THROUGH it rather than around it.
*
- * The element is paused and a WALL clock runs the hold out, which is the honest model:
- * the frame is frozen, so media time is exactly what must stop advancing while real time
- * does not. */
- const holdRef = useRef<{
+ * The `` cannot supply those seconds: they are not in the file. So for the
+ * insertion's duration the element is PINNED (`currentTime` held at its source moment,
+ * which is the fixed frame) and MUTED (the inserted audio is silence today). It is
+ * deliberately NOT `pause()`d — the film is playing, and pausing the element told the
+ * whole app otherwise: the store's `playing` flag mirrors the element's `pause` event,
+ * so the transport flipped to stopped the moment an insertion began. */
+ const insertionRef = useRef<{
insertId: string;
rawSec: number;
+ sourceSec: number;
durationSec: number;
startedAtMs: number;
} | null>(null);
- /** Set when the hold interrupted actual playback, so the film resumes on its own. */
- const resumeAfterHoldRef = useRef(false);
+ /** The element's own muted flag, to restore when the insertion ends. */
+ const mutedBeforeInsertionRef = useRef(false);
+ /** How far into the insertion currently playing we are, in ruler seconds. Read by
+ * `updateVirtualTime` so the position it publishes advances ACROSS the insertion while
+ * the raw second it also publishes stands still at the insertion's moment. */
+ const insertionElapsedRef = useRef(0);
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
// One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
@@ -689,27 +698,34 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
- // Run the pause out FIRST: everything below that is positioned on the programme
- // clock — the imported tracks especially — is still advancing during a hold even
- // though the picture is not, exactly as the render's output stream is.
- let heldElapsedSec = 0;
- const hold = holdRef.current;
- if (hold) {
- heldElapsedSec = Math.min(hold.durationSec, (performance.now() - hold.startedAtMs) / 1000);
- if (heldElapsedSec >= hold.durationSec) {
- holdRef.current = null;
- heldElapsedSec = hold.durationSec;
- if (resumeAfterHoldRef.current) {
- resumeAfterHoldRef.current = false;
- const resumed = v.play();
- if (resumed) void resumed.catch(() => undefined);
+ // Play the insertion FIRST: everything below is positioned against a clock that is
+ // running through it — the imported tracks especially, which sit on the programme
+ // clock exactly as they do in the render's output stream.
+ let insertionElapsedSec = 0;
+ const insertion = insertionRef.current;
+ if (insertion) {
+ insertionElapsedSec = Math.min(
+ insertion.durationSec,
+ (performance.now() - insertion.startedAtMs) / 1000,
+ );
+ if (insertionElapsedSec >= insertion.durationSec) {
+ insertionRef.current = null;
+ insertionElapsedSec = 0;
+ v.muted = mutedBeforeInsertionRef.current;
+ } else {
+ // Re-pinned every frame: the element is still playing, so left alone it
+ // would decode straight past the fixed frame the insertion stands for.
+ if (Math.abs(v.currentTime - insertion.sourceSec) > 0.01) {
+ try {
+ v.currentTime = insertion.sourceSec;
+ } catch {
+ // not seekable this instant; the next frame retries
+ }
}
- } else if (!v.paused) {
- // A `play()` from elsewhere (autoplay, a resume racing the hold) would
- // otherwise let the picture walk out from under the pause.
- v.pause();
+ v.muted = true;
}
}
+ insertionElapsedRef.current = insertionElapsedSec;
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -746,11 +762,12 @@ export function VirtualPreview({
// was never given a faster `playbackRate`, but seeking it twice as fast
// amounts to the same thing. Dividing raw time by the rate turns that
// back into 1x wall-clock, which is what the render does too.
- // `+ heldElapsedSec`, and only here: the raw playhead is pinned to the held moment
- // for the whole pause, and the projection of that moment is the pause's OPENING
- // (`expandRawSec` and this walk both give the frame about to be held its own
- // instant). Adding the wall-clock elapsed walks the programme through the pause,
- // which is what the mixer downstream is doing over the same seconds.
+ // `+ insertionElapsedSec`, and only here: the RAW playhead stands still at the
+ // insertion's moment for its whole duration — none of those seconds come from the
+ // recording — and the projection of that moment is where the insertion OPENS
+ // (`expandRawSec` and this walk both give the last recorded frame its own instant).
+ // Adding the elapsed walks the programme through the inserted media, which is what
+ // the mixer downstream is doing over the same seconds.
const outputTimeSec =
projectRawTimelineSecToPlayback(
clipsRef.current,
@@ -758,7 +775,7 @@ export function VirtualPreview({
virtualTimeSecRef.current,
filmInsertsRef.current,
speedRegionsRef.current,
- ) + heldElapsedSec;
+ ) + insertionElapsedSec;
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
@@ -1030,23 +1047,23 @@ export function VirtualPreview({
return;
}
const nextRawTime = clampVirtualTime(clipsRef.current, position.virtualTimeSec);
- // The first pause this frame stepped over — the rule, and why it is half-open,
- // live with the other ruler arithmetic.
- const entering = holdRef.current
+ // The first insertion this frame ran into — the rule, and why it is half-open,
+ // lives with the other ruler arithmetic.
+ const entering = insertionRef.current
? undefined
- : holdEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
+ : insertionEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
if (entering) {
- holdRef.current = {
+ mutedBeforeInsertionRef.current = v.muted;
+ insertionRef.current = {
insertId: entering.id,
rawSec: entering.atRawSec,
+ sourceSec: position.sourceTimeSec,
durationSec: entering.durationSec,
startedAtMs: performance.now(),
};
- resumeAfterHoldRef.current = true;
- v.pause();
- // The held moment, not the frame we happened to land on: the transcript cue,
- // the caption lookup and the audio mix all read this, and for the length of
- // the pause the film really is at that one instant.
+ // The insertion's own moment, not the frame we happened to land on: the
+ // transcript cue, the caption lookup and the audio mix all read this, and
+ // through the insertion the RECORDING really is at that one instant.
updateVirtualTime(entering.atRawSec);
return;
}
@@ -1071,7 +1088,14 @@ export function VirtualPreview({
const updateVirtualTime = useCallback(
(nextTimeSec: number) => {
setVirtualTimeSec(nextTimeSec);
- onTimeChange?.(nextTimeSec);
+ // Two coordinates, one publish. The raw second says where the RECORDING is; the
+ // ruler second says where on the timeline the playhead is, which is the only one
+ // that can move while an insertion plays — the recording is standing still at the
+ // insertion's moment for its whole duration.
+ onTimeChange?.(
+ nextTimeSec,
+ expandRawSec(nextTimeSec, filmInsertsRef.current) + insertionElapsedRef.current,
+ );
// ponytail: mirrors main's per-frame `video.playbackRate = ...`
// (videoEventHandlers.ts) — the browser does the actual time
// warping, so this is the only thing speed regions need. No
@@ -1149,14 +1173,17 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
- // A seek ends any pause the picture was holding: the playhead is somewhere else
- // now, so the frame we were frozen on is not the frame any more. Without this the
- // hold's wall clock would run out under the new position and hand the film back to
- // `play()` — the film starting itself again because the user scrubbed during a
- // pause. The rAF's own seeks (clip advance, trim skip) are gated on `!paused` and
- // so never reach here while a hold is in flight.
- holdRef.current = null;
- resumeAfterHoldRef.current = false;
+ // A seek AWAY ends the insertion that was playing: the playhead is somewhere else
+ // now, so the fixed frame it was pinning is not the frame any more. A seek TO the
+ // insertion's own moment is not "away" — it is the echo of the position this very
+ // tick published, and clearing on it would cut every insertion short the frame it
+ // began.
+ const playingInsertion = insertionRef.current;
+ if (playingInsertion && Math.abs(playingInsertion.rawSec - nextVirtualTimeSec) > 1e-3) {
+ insertionRef.current = null;
+ const el = videoRef.current;
+ if (el) el.muted = mutedBeforeInsertionRef.current;
+ }
const position = locateVirtualPosition(clips, nextVirtualTimeSec);
if (!position) {
videoRef.current?.pause();
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 1b1d094df..8b202fca8 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -230,14 +230,14 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
- /** The pauses added words created. `currentTimeSec` is a STORED second; the ruler it is
- * drawn on counts the pauses, so it has to be placed through them or it drifts from
+ /** The media added words inserted. `currentTimeSec` is a STORED second; the ruler it is
+ * drawn on counts the insertions, so it has to be placed through them or it drifts from
* the clips by the whole added time. */
inserts: readonly RulerInsert[];
/** Live scrub position in RAW seconds, when a drag is in flight. */
overrideTimeSec: number | null;
/** The same drag's RULER position. Preferred when present: it is the only coordinate
- * that can name a moment INSIDE a pause, which is zero raw seconds wide. */
+ * that can name a moment INSIDE an insertion, which is zero raw seconds wide. */
overrideRulerSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
@@ -270,11 +270,27 @@ const PlayheadOverlay = memo(function PlayheadOverlay({
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- // The scrub hands its RULER position straight through. Expanding the raw one instead
- // would snap the playhead to a pause's left edge the moment the pointer entered it,
- // because every ruler second inside a pause collapses to the same raw moment.
+ const storeRulerSec = useProjectStore((s) => s.currentRulerSec);
+ // The ruler position, from the most trustworthy source that has one.
+ //
+ // The raw second alone cannot draw this playhead: an insertion occupies ruler seconds
+ // and none of the recording, so every ruler second inside one collapses to the same raw
+ // moment and expanding it back puts the playhead on the insertion's near edge — where it
+ // visibly stalls through playback, and where it snaps back to after a scrub released over
+ // one (issue #560).
+ //
+ // The store's ruler second is trusted only when it still AGREES with the raw one: a
+ // caller that predates insertions writes the raw value for both, which is right until an
+ // insertion sits before it. Collapsing is the test, and expanding is the fallback.
+ const storeSec =
+ Math.abs(collapseRawSec(storeRulerSec, inserts).sec - storeTimeSec) < 1e-3
+ ? storeRulerSec
+ : expandRawSec(storeTimeSec, inserts);
const pct =
- ((overrideRulerSec ?? expandRawSec(overrideTimeSec ?? storeTimeSec, inserts)) / totalSec) * 100;
+ ((overrideRulerSec ??
+ (overrideTimeSec !== null ? expandRawSec(overrideTimeSec, inserts) : storeSec)) /
+ totalSec) *
+ 100;
return (
@@ -613,7 +629,9 @@ export function V4Timeline({
onAddVoiceover,
}: {
tl: TimelineApi;
- setCurrentTime: (sec: number) => void;
+ /** `rulerSec` is the same moment on the ruler the user sees; it differs from `sec` as
+ * soon as an insertion sits before it, or under it. */
+ setCurrentTime: (sec: number, rulerSec?: number) => void;
variant?: "edit" | "media";
onDropAsset?: (assetId: string) => Promise;
videoSources?: VideoSource[];
@@ -701,11 +719,11 @@ export function V4Timeline({
// clicked instead of looking like it worked. Same question, same helper as the Layout
// pane: is a camera attached anywhere on this timeline?
const hasAnyCamera = useMemo(() => hasAnyClipWithCamera(tl.assets, clips), [tl.assets, clips]);
- // The pauses added words created, placed on the ruler. Everything below measures the
- // EXPANDED ruler — stored clip geometry plus the time those pauses add — because that
+ // The media added words inserted, placed on the ruler. Everything below measures the
+ // EXPANDED ruler — stored clip geometry plus the time those insertions add — because that
// is the film's real length and the one the playhead runs along. Stored geometry is
// never rewritten for this: only what is drawn moves.
- // `?? []` because the key is additive: a document written before it has no pauses.
+ // `?? []` because the key is additive: a document written before it has no insertions.
const inserts = useMemo(
() => rulerInserts(tl.insertRanges ?? [], clips),
[tl.insertRanges, clips],
@@ -841,11 +859,12 @@ export function V4Timeline({
const [scrubbingTimeSec, setScrubbingTimeSec] = useState(null);
// The pointer's RULER position while scrubbing, kept apart from the raw one above.
// Two numbers because they mean different things: the timecode reads the raw clock,
- // like the store, and the playhead has to be able to sit INSIDE a pause — which is
- // zero raw seconds wide, so no raw value can address a moment within it.
+ // like the store, and the playhead has to be able to sit INSIDE an insertion — which
+ // takes up none of the recording, so no raw value can address a moment within it.
const [scrubRulerSec, setScrubRulerSec] = useState(null);
const rafSeekRef = useRef(0);
const pendingSeekTimeRef = useRef(null);
+ const pendingSeekRulerRef = useRef(null);
// ── interactions ────────────────────────────────────────────────
const playheadElRef = useRef(null);
@@ -863,14 +882,15 @@ export function V4Timeline({
// `total` is the EXPANDED ruler, so `pct * total` is a ruler second — and
// `setCurrentTime` is read as a RAW one by every consumer: the preview seek, the
// caption lookup, the transcript cue, the audio mix. Writing the ruler value
- // straight in put the playhead one accumulated pause AHEAD of everything it was
+ // straight in put the playhead one accumulated insertion AHEAD of everything it was
// supposed to be pointing at, which is what showed as the wrong subtitle under a
// correctly-placed playhead (issue #560).
//
- // Collapsing lands on the held moment when the pointer is inside a pause, which
- // is the honest answer: a pause is zero raw seconds, so there is no raw value
- // inside it to seek to. The ruler position is kept separately below so the
- // playhead still follows the pointer across it.
+ // Collapsing lands on the insertion's own moment when the pointer is inside one,
+ // which is the honest answer: an insertion takes up none of the recording, so
+ // there is no raw value inside it to seek the media to. The ruler position goes
+ // to the store ALONGSIDE it, which is what lets the playhead stay where it was
+ // released instead of snapping to the insertion's near edge.
const rulerTime = pct * total;
const { sec: targetTime } = collapseRawSec(rulerTime, inserts);
@@ -883,13 +903,14 @@ export function V4Timeline({
setScrubbingTimeSec(targetTime);
setScrubRulerSec(rulerTime);
pendingSeekTimeRef.current = targetTime;
+ pendingSeekRulerRef.current = rulerTime;
if (isImmediate) {
if (rafSeekRef.current !== 0) {
cancelAnimationFrame(rafSeekRef.current);
rafSeekRef.current = 0;
}
- setCurrentTime(targetTime);
+ setCurrentTime(targetTime, rulerTime);
return;
}
@@ -898,7 +919,7 @@ export function V4Timeline({
rafSeekRef.current = requestAnimationFrame(() => {
rafSeekRef.current = 0;
if (pendingSeekTimeRef.current !== null) {
- setCurrentTime(pendingSeekTimeRef.current);
+ setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
}
});
}
@@ -1726,8 +1747,8 @@ export function V4Timeline({
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
left: `${pctAt(seg.segStart)}%`,
- // Measured on the expanded ruler at BOTH ends: a region straddling a pause
- // covers it, so its box has to grow by that pause and not merely slide.
+ // Measured on the expanded ruler at BOTH ends: a region straddling an insertion
+ // covers it, so its box has to grow by that insertion and not merely slide.
width: `${pctOf(expandRawSec(seg.segEnd, inserts) - expandRawSec(seg.segStart, inserts))}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
@@ -2227,7 +2248,7 @@ export function V4Timeline({
assetDurationSec={duration}
// `pctAt`, not `pctOf`: the clip boxes are drawn on the EXPANDED
// ruler and the audio pills were drawn on the stored one, so any
- // pause in the film slid the two lanes apart. The take keeps its
+ // insertion in the film slid the two lanes apart. The take keeps its
// own length — only its head follows the ruler.
leftPct={pctAt(start)}
widthPct={pctOf(widthSec)}
@@ -2291,7 +2312,7 @@ export function V4Timeline({
>
{clips.map((c, i) => {
const dur = c.timelineEndSec - c.timelineStartSec;
- // On the expanded ruler the box also carries whatever pauses fall
+ // On the expanded ruler the box also carries whatever insertions fall
// inside it — the film really does stay on this clip's frame for
// them, so they belong to its box rather than between boxes.
const boxStart = expandRawSec(c.timelineStartSec, inserts);
@@ -2373,7 +2394,7 @@ export function V4Timeline({
{(insertedWordsByClip.get(c.id) ?? []).map(({ wordId, text, atRawSec }) => {
- // A word whose pause the film holds gets a BAND as wide as the time
+ // A word whose insertion the film plays gets a BAND as wide as the time
// it adds — that width IS the added time, drawn. One that fitted in
// silence already there adds nothing and stays a hairline.
//
@@ -2381,16 +2402,16 @@ export function V4Timeline({
// the expanded ruler and an unpaused one at a fraction of the clip's
// SOURCE span, in the same ternary — two clocks, one of which the
// box is not drawn in.
- const pause = inserts.find((ins) => ins.wordId === wordId);
+ const inserted = inserts.find((ins) => ins.wordId === wordId);
const left = ((expandRawSec(atRawSec, inserts) - boxStart) / boxLen) * 100;
- const width = pause ? (pause.durationSec / boxLen) * 100 : 0;
+ const width = inserted ? (inserted.durationSec / boxLen) * 100 : 0;
return (
0
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index c437a3638..d61ce3475 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -13,7 +13,7 @@ import type {
/**
* What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film, plus the
- * one thing a stored clip can never carry — `heldSec`, the pause an added word created.
+ * one thing a stored clip can never carry — `heldSec`, the media an added word inserted.
*
* A held segment's source window is the single frame it shows; its LENGTH is `heldSec`.
* The field lives only on this derived shape, never on `clipSchema`, so nothing can write
@@ -172,8 +172,8 @@ export function resolvePlaybackSegments(
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
const result: PlaybackSegment[] = [];
let timelineCursor = 0;
- // The pauses added words need, in the order they will be met. Consumed as the walk
- // passes each one's moment, so a pause inside a span a trim removed is never reached —
+ // The media added words insert, in the order they will be met. Consumed as the walk
+ // passes each one's moment, so an insertion inside a span a trim removed is never reached —
// which is right: the moment it holds is not in the film any more.
const pending = [...insertRanges].sort((a, b) => a.atSec - b.atSec);
const holdAt = (clip: AxcutClip, atSec: number): PlaybackSegment | null => {
@@ -211,10 +211,10 @@ export function resolvePlaybackSegments(
if (!trimAppliesToClip(trim, clip)) continue;
kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
}
- // A pause sits at the END of the word it follows, which is almost never a boundary a
+ // An insertion sits at the END of the word it follows, which is almost never a boundary a
// trim happened to leave. So each kept span is cut at the moments it holds, and the
// held frame goes between the halves: the stream plays up to that frame, stays on it
- // for the pause, then carries on — which is what makes the film longer.
+ // for the insertion, then carries on — which is what makes the film longer.
const pieces: Array<{ startSec: number; endSec: number; holdAtEnd: boolean }> = [];
for (const iv of kept) {
const moments = pending
@@ -331,9 +331,9 @@ function outputDurationOfRawSpan(
* The raw span that plays for `outSec` OUTPUT seconds starting at `fromRawSec` — the
* inverse of {@link outputDurationOfRawSpan}, and the identity when nothing is sped up.
*
- * A voice-over plays at 1x in the mix, so a pause for a spoken word is D seconds of the
+ * A voice-over plays at 1x in the mix, so an insertion for a spoken word is D seconds of the
* take's own clock. Under a 2x region that is 2 raw seconds, not 1, and getting it wrong
- * puts the resumed narration half a pause out of step with the picture.
+ * puts the resumed narration half an insertion out of step with the picture.
*/
export function rawSpanForOutDuration(
fromRawSec: number,
@@ -372,7 +372,7 @@ export function projectRawTimelineSecToPlayback(
trimRanges: AxcutTrimRange[],
rawSec: number,
/**
- * The recording lane's pauses, already placed on the raw ruler by `rulerInserts`.
+ * The recording lane's insertions, already placed on the raw ruler by `rulerInserts`.
*
* REQUIRED, not optional, and every call site was migrated with it. An optional
* parameter would silently keep the early-audio bug alive at every site that had not
@@ -393,10 +393,10 @@ export function projectRawTimelineSecToPlayback(
let lastRawEnd = 0; // raw end of the last kept segment, for the trailing overhang
let landed: number | null = null; // output(rawSec), once it falls in/before a kept segment
- // A pause the film holds occupies ZERO raw seconds and D OUTPUT seconds — it is the one
+ // An insertion occupies ZERO raw seconds and D OUTPUT seconds — it is the one
// thing a flat kept-interval list cannot express, which is why it was left out and why
- // every audio track after a pause has been landing D seconds early in both the preview
- // and the export. Interleaved here rather than added afterwards, because where the pause
+ // every audio track after an insertion has been landing D seconds early in both the preview
+ // and the export. Interleaved here rather than added afterwards, because where the insertion
// sits inside the kept span decides which side of it `rawSec` falls on.
const holds = [...filmInserts].sort((a, b) => a.atRawSec - b.atRawSec);
let nextHold = 0;
@@ -408,9 +408,9 @@ export function projectRawTimelineSecToPlayback(
// answers, because a speed region scales it.
for (const seg of keptRawSpans(ordered, trimRanges)) {
let from = seg.startSec;
- // Every pause this segment carries, in order. A pause whose moment a trim removed is
+ // Every insertion this segment carries, in order. One whose moment a trim removed is
// in no kept span at all and is never reached — the moment it holds is not in the
- // film any more, so neither is the pause.
+ // film any more, so neither is the insertion.
while (nextHold < holds.length && holds[nextHold].atRawSec < seg.startSec) nextHold++;
while (nextHold < holds.length && holds[nextHold].atRawSec < seg.endSec) {
const hold = holds[nextHold++];
@@ -421,8 +421,8 @@ export function projectRawTimelineSecToPlayback(
}
outCursor += outputDurationOfRawSpan(from, at, speedRegions);
from = at;
- // Strictly after the pause's own moment, matching `expandRawSec`: a track whose
- // head sits exactly there starts WITH the pause, not after it.
+ // Strictly after the insertion's own moment, matching `expandRawSec`: a track whose
+ // head sits exactly there starts WITH the insertion, not after it.
if (landed === null && rawSec <= at) landed = outCursor;
outCursor += hold.durationSec;
}
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index fd47da9cc..69d0cd340 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -60,6 +60,20 @@ export interface ProjectState {
error: string | null;
sourceDurationSec: number;
currentTimeSec: number;
+ /** The playhead's position on the RULER — the timeline the user sees and scrubs.
+ *
+ * `currentTimeSec` is the stored, RAW second: where the recording is. The two are the
+ * same number until an insertion exists, and then they permanently are not. An insertion
+ * is media added INSIDE a clip (issue #560), so it takes up ruler seconds while taking up
+ * none of the recording — every ruler second inside one names the same raw moment.
+ *
+ * Which means the raw second cannot say WHERE inside an insertion the playhead is, and a
+ * playhead that only had that number fell to the insertion's near edge the instant it
+ * entered: playback stalled visually there, and releasing a scrub over one snapped back
+ * to its start. Consumers that resolve MEDIA (seek, captions, transcript cue, mix) keep
+ * reading `currentTimeSec` — through an insertion the recording really is at that one
+ * instant. Only what draws or measures the ruler reads this. */
+ currentRulerSec: number;
/** The selected imported audio track (issue #350), or null. In the store — not
* `useTimeline`'s local selection — because the media panel (which imports the
* file) and the inspector (which edits it) sit in different component subtrees
@@ -146,7 +160,9 @@ export interface ProjectState {
opts: DocumentWriteOptions,
) => Promise;
setSourceDuration: (sec: number) => void;
- setCurrentTime: (sec: number) => void;
+ /** `rulerSec` defaults to `sec`, which is right everywhere no insertion is involved
+ * and is what every existing caller means. */
+ setCurrentTime: (sec: number, rulerSec?: number) => void;
setPlaying: (playing: boolean) => void;
markClean: () => void;
/**
@@ -197,6 +213,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
+ currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
@@ -549,8 +566,8 @@ export const useProjectStore = create((set, get) => ({
set({ sourceDurationSec: sec });
},
- setCurrentTime(sec) {
- set({ currentTimeSec: sec });
+ setCurrentTime(sec, rulerSec) {
+ set({ currentTimeSec: sec, currentRulerSec: rulerSec ?? sec });
},
setPlaying(playing) {
@@ -581,6 +598,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
+ currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 6d4a32ada..bd16a698c 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -10,8 +10,8 @@ import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
collapseRawSec,
expandRawSec,
- holdEnteredBetween,
insertedWordMarks,
+ insertionEnteredBetween,
type RulerInsert,
rulerInserts,
totalInsertedSec,
@@ -251,8 +251,8 @@ describe("a scrub survives the round trip", () => {
});
it("lands on the held moment for a pointer inside a pause, and says so", () => {
- // Every ruler second inside a pause is the same raw moment: the film is frozen
- // there, so there is nothing else it could mean.
+ // Every ruler second inside an insertion is the same raw moment: none of those
+ // seconds come from the recording, so there is nothing else it could mean.
const inside = collapseRawSec(5, marks);
expect(inside.sec).toBeCloseTo(4, 6);
expect(inside.heldBy?.id).toBe("i1");
@@ -266,34 +266,35 @@ describe("a scrub survives the round trip", () => {
});
});
-// ─── Entering a pause ───────────────────────────────────────────────────────
-// The preview holds the picture for a pause the way the export does (`hold_sec`). The
-// half-open rule below is what keeps that from becoming an infinite hold.
+// ─── Running into an insertion ──────────────────────────────────────────────
+// An added word inserts MEDIA inside the clip — a fixed frame and silence, until there is
+// a generator for it. Playback runs THROUGH that media, and the half-open rule below is
+// what keeps it from running through the same insertion forever.
-describe("the pause a frame steps over", () => {
+describe("the insertion a frame runs into", () => {
const marks: RulerInsert[] = [
{ id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
{ id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
];
it("is found when the frame crosses it", () => {
- expect(holdEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
- expect(holdEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
+ expect(insertionEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
+ expect(insertionEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
});
- it("is not found again from the moment it holds", () => {
- // The hold pins the playhead to exactly 4. Coming out, the next frames must not
- // re-enter — otherwise the film never gets past the pause.
- expect(holdEnteredBetween(4, 4.02, marks)).toBeUndefined();
- expect(holdEnteredBetween(4, 4.5, marks)).toBeUndefined();
+ it("is not found again from the moment it occupies", () => {
+ // While the insertion plays, the raw playhead stands still at exactly 4. Coming out,
+ // the next frames must not re-enter — otherwise the film never gets past it.
+ expect(insertionEnteredBetween(4, 4.02, marks)).toBeUndefined();
+ expect(insertionEnteredBetween(4, 4.5, marks)).toBeUndefined();
});
it("takes the earliest of several in one frame, and none outside", () => {
- expect(holdEnteredBetween(0, 20, marks)?.id).toBe("i1");
- expect(holdEnteredBetween(5, 8, marks)).toBeUndefined();
+ expect(insertionEnteredBetween(0, 20, marks)?.id).toBe("i1");
+ expect(insertionEnteredBetween(5, 8, marks)).toBeUndefined();
});
- it("holds a pause landing exactly on the frame boundary", () => {
- expect(holdEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
+ it("plays an insertion landing exactly on the frame boundary", () => {
+ expect(insertionEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
});
});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index 6b6de359d..c6786d25c 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -10,23 +10,23 @@
// This module is the arithmetic, and nothing else: pure, no document, no React. It answers
// two questions.
//
-// • Where does a pause land on the RULER? A range is anchored in SOURCE time, so it has
+// • Where does an insertion land on the RULER? A range is anchored in SOURCE time, so it has
// to be projected through whichever clip plays that moment — `rulerInserts`.
-// • What does the ruler look like once the pauses are counted? Stored raw seconds and
+// • What does the ruler look like once the insertions are counted? Stored raw seconds and
// the seconds the user actually scrubs are no longer the same number, and
// `expandRawSec` / `collapseRawSec` are the one place that difference is resolved.
//
-// The two are inverses everywhere except INSIDE a pause, where they cannot be: a stretch
+// The two are inverses everywhere except INSIDE an insertion, where they cannot be: a stretch
// of ruler maps to the single source moment being held. `collapseRawSec` returns that
// moment, which is exactly what a decoder parked on a held frame should be told.
import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
-/** A pause placed on the raw ruler, ready to be counted. */
+/** An insertion placed on the raw ruler, ready to be counted. */
export interface RulerInsert {
id: string;
wordId: string;
- /** Where the pause begins, in STORED raw seconds — before any pause is counted. */
+ /** Where the insertion begins, in STORED raw seconds — before any insertion is counted. */
atRawSec: number;
durationSec: number;
}
@@ -34,7 +34,7 @@ export interface RulerInsert {
/**
* Project each insert onto the raw ruler through the clip that plays its source moment.
*
- * A range whose moment no clip plays yields nothing: the pause exists for a word that is
+ * A range whose moment no clip plays yields nothing: the insertion exists for a word that is
* not on the timeline, so there is no ruler position for it and nothing to add. Same rule
* the captions follow for a line no clip covers.
*
@@ -49,7 +49,7 @@ export function rulerInserts(
for (const clip of clips) {
if (clip.assetId !== insert.assetId) continue;
const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
- // Inclusive at both edges: a pause sits at the END of the word it follows, which
+ // Inclusive at both edges: an insertion sits at the END of the word it follows, which
// is routinely a clip's own boundary.
if (insert.atSec < clip.sourceStartSec || insert.atSec > sourceEnd) continue;
placed.push({
@@ -64,7 +64,7 @@ export function rulerInserts(
return placed.sort((a, b) => a.atRawSec - b.atRawSec);
}
-/** How much time the pauses add in total — what the ruler grows by. */
+/** How much time the insertions add in total — what the ruler grows by. */
export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
return inserts.reduce((sum, insert) => sum + insert.durationSec, 0);
}
@@ -73,8 +73,8 @@ export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
* Stored raw seconds → the ruler the user sees.
*
* Monotone and total: every stored moment has exactly one place on the expanded ruler.
- * A moment sitting exactly ON a pause maps to where the pause BEGINS, so the frame that
- * is about to be held keeps its own instant and the pause opens after it.
+ * A moment sitting exactly ON an insertion maps to where the insertion BEGINS, so the last recorded
+ * frame keeps its own instant and the inserted media opens after it.
*/
export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): number {
let out = sec;
@@ -87,7 +87,7 @@ export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): numb
/**
* The ruler the user sees → stored raw seconds.
*
- * The inverse of {@link expandRawSec} outside a pause. Inside one it cannot be an inverse
+ * The inverse of {@link expandRawSec} outside an insertion. Inside one it cannot be an inverse
* — a whole stretch of ruler stands for a single held moment — and it returns that moment,
* flagged, so a caller driving a decoder knows to hold rather than to seek.
*/
@@ -108,15 +108,16 @@ export function collapseRawSec(
}
/**
- * The pause a frame of playback stepped over, if it stepped over one.
+ * The insertion a frame of playback ran into, if it ran into one.
*
- * Half-open on the LEFT, and that is the whole point: a player that holds pins its raw
- * playhead to exactly `atRawSec` for the length of the pause, so `>` is what refuses that
- * same moment on the way OUT. With `>=` the pause is re-entered the instant it ends and the
- * film never gets past it. Closed on the right (with the frame epsilon) so a pause landing
- * precisely on a frame boundary is held rather than skipped.
+ * Half-open on the LEFT, and that is the whole point: while inserted media is playing, the
+ * RAW playhead stands still at exactly `atRawSec` — the recording really is at that one
+ * instant, because none of the inserted seconds come from it. `>` is therefore what refuses
+ * that same moment on the way out; with `>=` the insertion is re-entered the frame it ends
+ * and the film never gets past it. Closed on the right (with the frame epsilon) so an
+ * insertion landing precisely on a frame boundary is played rather than skipped.
*/
-export function holdEnteredBetween(
+export function insertionEnteredBetween(
prevRawSec: number,
nextRawSec: number,
inserts: readonly RulerInsert[],
@@ -138,12 +139,12 @@ export interface InsertedWordMark {
/**
* Where each added word's mark belongs, one per word.
*
- * Claimed once, and half-open at a clip's far edge except for the last: a pause sits at the
+ * Claimed once, and half-open at a clip's far edge except for the last: an insertion sits at the
* END of the word it follows, which is routinely a split boundary, and testing both edges
* inclusively painted the same word in BOTH halves (issue #560).
*
* Returns RAW seconds. The caller expands them; it used to mix a raw-then-expanded position
- * for a word with a pause and a fraction of the clip's SOURCE span for one without, in the
+ * for a word with an insertion and a fraction of the clip's SOURCE span for one without, in the
* same ternary — two clocks, and the clip box is not drawn in the second.
*/
export function insertedWordMarks(
From caf56fad5321f7d784e36c320fc86f5835a6647d Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 20:43:53 +0200
Subject: [PATCH 81/84] fix(preview): park the picture on the insertion, don't
re-seek it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Re-pinning `currentTime` every frame to a still-playing element is a seek storm the
decoder never settles out of — that is what "playback stops at the insertion" was. One
`pause()` parks it on the frame the insertion stands for and silences the recording
under it; one `play()` lets go. Both are what the insertion IS, so the mute bookkeeping
goes away with them.
The cost of parking is that `.paused` stops answering "is the film stopped?" — it
says the element is parked, and the programme is still running over it. That question is
now `filmPlaying`, asked once per frame and used by the four gates that always meant it:
the shared clock, the two imported-track gates, and the tick's own early return.
Net 18 lines lighter than the version it replaces.
---
src/components/ai-edition/VirtualPreview.tsx | 100 ++++++++-----------
1 file changed, 41 insertions(+), 59 deletions(-)
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 7e3ba49b4..5661f0e64 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -576,24 +576,20 @@ export function VirtualPreview({
* yet, so the stand-in is a fixed frame and silence — but it is a piece of media on the
* timeline like any other, and playback runs THROUGH it rather than around it.
*
- * The `` cannot supply those seconds: they are not in the file. So for the
- * insertion's duration the element is PINNED (`currentTime` held at its source moment,
- * which is the fixed frame) and MUTED (the inserted audio is silence today). It is
- * deliberately NOT `pause()`d — the film is playing, and pausing the element told the
- * whole app otherwise: the store's `playing` flag mirrors the element's `pause` event,
- * so the transport flipped to stopped the moment an insertion began. */
- const insertionRef = useRef<{
- insertId: string;
- rawSec: number;
- sourceSec: number;
- durationSec: number;
- startedAtMs: number;
- } | null>(null);
- /** The element's own muted flag, to restore when the insertion ends. */
- const mutedBeforeInsertionRef = useRef(false);
- /** How far into the insertion currently playing we are, in ruler seconds. Read by
- * `updateVirtualTime` so the position it publishes advances ACROSS the insertion while
- * the raw second it also publishes stands still at the insertion's moment. */
+ * The `` cannot supply those seconds: they are not in the file. So it is PARKED
+ * for the insertion's duration — paused, which holds the frame the insertion stands for
+ * and silences the recording under it — and a wall clock runs the insertion out.
+ *
+ * Parked, not re-seeked: writing `currentTime` every frame to a still-playing element
+ * is a seek storm the decoder never settles out of, and that is what "playback stops at
+ * the insertion" actually was. The cost is that `.paused` stops answering "is the
+ * film stopped?" — see `filmPlaying` in the tick. */
+ const insertionRef = useRef<{ rawSec: number; durationSec: number; startedAtMs: number } | null>(
+ null,
+ );
+ /** How far into the insertion the wall clock has run, in seconds. Read by
+ * `updateVirtualTime` so the RULER position it publishes crosses the insertion while the
+ * RAW second it publishes alongside stands still at the insertion's own moment. */
const insertionElapsedRef = useRef(0);
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
@@ -698,34 +694,25 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
- // Play the insertion FIRST: everything below is positioned against a clock that is
- // running through it — the imported tracks especially, which sit on the programme
- // clock exactly as they do in the render's output stream.
- let insertionElapsedSec = 0;
+ // Run the insertion out FIRST: everything below is positioned against a clock that
+ // crosses it — the imported tracks especially, which sit on the programme clock
+ // exactly as they do in the render's output stream.
const insertion = insertionRef.current;
if (insertion) {
- insertionElapsedSec = Math.min(
- insertion.durationSec,
- (performance.now() - insertion.startedAtMs) / 1000,
- );
- if (insertionElapsedSec >= insertion.durationSec) {
+ const elapsedSec = (performance.now() - insertion.startedAtMs) / 1000;
+ // `!v.paused` means something un-parked the element under us — the transport.
+ if (elapsedSec >= insertion.durationSec || !v.paused) {
insertionRef.current = null;
- insertionElapsedSec = 0;
- v.muted = mutedBeforeInsertionRef.current;
+ insertionElapsedRef.current = 0;
+ if (v.paused) void v.play().catch(() => undefined);
} else {
- // Re-pinned every frame: the element is still playing, so left alone it
- // would decode straight past the fixed frame the insertion stands for.
- if (Math.abs(v.currentTime - insertion.sourceSec) > 0.01) {
- try {
- v.currentTime = insertion.sourceSec;
- } catch {
- // not seekable this instant; the next frame retries
- }
- }
- v.muted = true;
+ insertionElapsedRef.current = elapsedSec;
}
}
- insertionElapsedRef.current = insertionElapsedSec;
+ // A PARKED element is not a stopped film, and this is the question every gate
+ // below actually means: the picture is held on the insertion's frame on purpose
+ // while the programme keeps running over it.
+ const filmPlaying = !v.paused || insertionRef.current !== null;
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -775,7 +762,7 @@ export function VirtualPreview({
virtualTimeSecRef.current,
filmInsertsRef.current,
speedRegionsRef.current,
- ) + insertionElapsedSec;
+ ) + insertionElapsedRef.current;
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
@@ -867,7 +854,7 @@ export function VirtualPreview({
// media metadata not ready yet
}
}
- if (!v.paused && trackTarget.shouldPlay && el.paused) {
+ if (filmPlaying && trackTarget.shouldPlay && el.paused) {
// Resume a context suspended by autoplay policy, exactly as the primary
// loop does above — otherwise a track that starts while the primary
// element is silent (its span is over, or a recording with no separate
@@ -877,7 +864,7 @@ export function VirtualPreview({
}
const playback = el.play();
if (playback) void playback.catch(() => undefined);
- } else if ((v.paused || !trackTarget.shouldPlay) && !el.paused) {
+ } else if ((!filmPlaying || !trackTarget.shouldPlay) && !el.paused) {
el.pause();
}
}
@@ -886,7 +873,7 @@ export function VirtualPreview({
// bypasses React state entirely.
if (clockRef) {
clockRef.current.sourceTimeSec = v.currentTime;
- clockRef.current.isPlaying = !v.paused;
+ clockRef.current.isPlaying = filmPlaying;
clockRef.current.playbackRate = v.playbackRate;
clockRef.current.virtualTimeSec = virtualTimeSecRef.current;
}
@@ -977,7 +964,7 @@ export function VirtualPreview({
// `clockRef` et `setSourceTimeSec` ci-dessus continuent d'être publiés : la webcam
// et le calque curseur ont besoin du temps source même à l'arrêt. Seule la
// position de la TIMELINE cesse d'être dictée par le média.
- if (v.paused) {
+ if (!filmPlaying) {
return;
}
if (clipsRef.current.length === 0) {
@@ -1053,14 +1040,15 @@ export function VirtualPreview({
? undefined
: insertionEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
if (entering) {
- mutedBeforeInsertionRef.current = v.muted;
insertionRef.current = {
- insertId: entering.id,
rawSec: entering.atRawSec,
- sourceSec: position.sourceTimeSec,
durationSec: entering.durationSec,
startedAtMs: performance.now(),
};
+ insertionElapsedRef.current = 0;
+ // Parks the picture on the frame the insertion stands for, and silences the
+ // recording under it. Both are what the insertion IS.
+ v.pause();
// The insertion's own moment, not the frame we happened to land on: the
// transcript cue, the caption lookup and the audio mix all read this, and
// through the insertion the RECORDING really is at that one instant.
@@ -1173,17 +1161,11 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
- // A seek AWAY ends the insertion that was playing: the playhead is somewhere else
- // now, so the fixed frame it was pinning is not the frame any more. A seek TO the
- // insertion's own moment is not "away" — it is the echo of the position this very
- // tick published, and clearing on it would cut every insertion short the frame it
- // began.
- const playingInsertion = insertionRef.current;
- if (playingInsertion && Math.abs(playingInsertion.rawSec - nextVirtualTimeSec) > 1e-3) {
- insertionRef.current = null;
- const el = videoRef.current;
- if (el) el.muted = mutedBeforeInsertionRef.current;
- }
+ // A seek ends the insertion that was playing: the playhead is somewhere else now,
+ // so the frame it parked on is not the frame any more. The rAF's own seeks (clip
+ // advance, trim skip) are gated on `!v.paused` and so never land here mid-insertion.
+ insertionRef.current = null;
+ insertionElapsedRef.current = 0;
const position = locateVirtualPosition(clips, nextVirtualTimeSec);
if (!position) {
videoRef.current?.pause();
From f44d521696dff05c06310c5815717d0fc5f546de Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 20:53:23 +0200
Subject: [PATCH 82/84] fix(timeline): the scrub release carries its ruler
second too
The drag published both coordinates on every move and then dropped one on pointerup,
which is the whole snap-back: the raw second alone lands on the insertion's near edge,
because it is the one position inside an insertion no raw value can name.
---
src/components/ai-edition/v4/V4Timeline.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 8b202fca8..b97f24ab7 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -960,8 +960,12 @@ export function V4Timeline({
rafSeekRef.current = 0;
}
if (pendingSeekTimeRef.current !== null) {
- setCurrentTime(pendingSeekTimeRef.current);
+ // The RULER second goes with it, or releasing over an insertion drops the
+ // only coordinate that could name where the pointer was: the raw second
+ // alone lands on the insertion's near edge, which is the snap-back.
+ setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
pendingSeekTimeRef.current = null;
+ pendingSeekRulerRef.current = null;
}
setScrubbingTimeSec(null);
setScrubRulerSec(null);
From 140d56dfee9000ecc6b4a4d516a22d72dabf9cf4 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 21:44:21 +0200
Subject: [PATCH 83/84] =?UTF-8?q?refactor(timeline):=20one=20clock=20?=
=?UTF-8?q?=E2=80=94=20an=20insertion=20is=20media,=20so=20the=20clip=20is?=
=?UTF-8?q?=20longer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two contradictory models were living in the tree and fighting. Under the one I wrote, an
insertion took ZERO seconds of stored timeline and a second "ruler" was computed on top;
under the one the native compositor already assumed, it took its real duration. The
native path could therefore never see a held segment at all — its raw span was zero wide
— which is why playback stuttered exactly where the user was looking.
An insertion is MEDIA inside the clip, so the clip carrying it is that much longer. That
is now said once, in `reflowClipsForInserts`, called from the single writer that keeps
the ranges true. It is absolute rather than incremental, so it is idempotent and doubles
as the migration for documents written before insertions existed.
Everything downstream then reads ONE coordinate, and the second one goes away with all
its conversions: `expandRawSec`, `collapseRawSec`, `totalInsertedSec`, the store's
`currentRulerSec` and its plumbing, and thirty lines of interleaving inside
`projectRawTimelineSecToPlayback`, which is now the identity when nothing is cut.
What replaces them is one fact stated in one place: inside a clip carrying insertions,
source ↔ timeline is no longer a plain shift. `sourceToTimelineSec` / `timelineToSourceSec`
say so, and the six mappers that used to shift by hand call them — the kept spans, the
removed spans, the caption placement, the preview's two position lookups, and the native
decoder's segment spans. `timelineToSourceSec` also answers the question a shift cannot:
inside an insertion there is no source moment, and it names the insertion instead.
Two tests changed because they pinned the old model, and say so now: the clip lengthens
with the word, and a take's cues do not move when the film below it gains an insertion.
---
.../ai-edition/NativeCompositorOverlay.tsx | 8 +-
src/components/ai-edition/NewEditorShell.tsx | 21 +-
src/components/ai-edition/VirtualPreview.tsx | 45 ++--
src/components/ai-edition/v4/V4Timeline.tsx | 86 ++------
.../ai-edition/captions/captionLane.test.ts | 16 +-
src/lib/ai-edition/captions/cues.ts | 26 +--
src/lib/ai-edition/document/timeline.ts | 100 +++++----
.../ai-edition/document/transcript.test.ts | 24 ++-
src/lib/ai-edition/document/transcript.ts | 13 +-
src/lib/ai-edition/store/projectStore.ts | 24 +--
.../ai-edition/timeline/inserted-time.test.ts | 194 ++++++++----------
src/lib/ai-edition/timeline/inserted-time.ts | 127 +++++++-----
.../timeline/programme-time.test.ts | 98 +++++----
src/lib/ai-edition/timeline/programme-time.ts | 33 ++-
src/lib/ai-edition/timeline/timelineMap.ts | 8 +-
.../ai-edition/timeline/virtual-preview.ts | 33 ++-
src/native/sceneDescription.ts | 3 +-
src/native/useNativePlaybackSync.ts | 9 +-
18 files changed, 461 insertions(+), 407 deletions(-)
diff --git a/src/components/ai-edition/NativeCompositorOverlay.tsx b/src/components/ai-edition/NativeCompositorOverlay.tsx
index e4af64102..18b63f72e 100644
--- a/src/components/ai-edition/NativeCompositorOverlay.tsx
+++ b/src/components/ai-edition/NativeCompositorOverlay.tsx
@@ -74,7 +74,13 @@ export function NativeCompositorOverlay() {
return resolveVisibleClips(document);
}, [document]);
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, nativeClips, document?.timeline.clips ?? []),
+ () =>
+ resolveNativePosition(
+ currentTimeSec,
+ nativeClips,
+ document?.timeline.clips ?? [],
+ document?.timeline.insertRanges ?? [],
+ ),
[nativeClips, currentTimeSec, document],
);
const activeClip = activePosition?.clip ?? null;
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 0beac701e..0f3e551e4 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -21,7 +21,12 @@ import {
setDocumentWordText,
} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
-import { type AxcutAudioTrack, type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
+import {
+ type AxcutAudioTrack,
+ type AxcutClip,
+ type AxcutInsertRange,
+ documentSchema,
+} from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
@@ -91,15 +96,17 @@ const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
function NativePlaybackSync({
visibleClips,
clips,
+ insertRanges,
}: {
visibleClips: AxcutClip[];
clips: AxcutClip[];
+ insertRanges: readonly AxcutInsertRange[];
}) {
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);
+ useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips, insertRanges);
return null;
}
@@ -422,8 +429,8 @@ export function NewEditorShell() {
);
const handleTimeChange = useCallback(
- (timeSec: number, rulerSec?: number) => {
- setCurrentTime(timeSec, rulerSec);
+ (timeSec: number) => {
+ setCurrentTime(timeSec);
},
[setCurrentTime],
);
@@ -1419,7 +1426,11 @@ export function NewEditorShell() {
className={v4.app}
style={{ gridTemplateRows: `58px 1fr ${showTimeline ? timelineRow : "0px"}` }}
>
-
+ void;
+ onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
durationSec: number,
assetId: string,
@@ -591,6 +585,10 @@ export function VirtualPreview({
* `updateVirtualTime` so the RULER position it publishes crosses the insertion while the
* RAW second it publishes alongside stands still at the insertion's own moment. */
const insertionElapsedRef = useRef(0);
+ // The ranges themselves for anything that maps through a clip; their timeline positions
+ // for the one thing that asks "did this frame run into one".
+ const insertRangesRef = useRef(insertRanges);
+ insertRangesRef.current = insertRanges;
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
// One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
@@ -760,7 +758,7 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
virtualTimeSecRef.current,
- filmInsertsRef.current,
+ insertRangesRef.current,
speedRegionsRef.current,
) + insertionElapsedRef.current;
for (const track of audioTracksRef.current) {
@@ -770,7 +768,7 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
track.startMs / 1000,
- filmInsertsRef.current,
+ insertRangesRef.current,
speedRegionsRef.current,
);
// Length is measured WITHOUT speed, position WITH it. A trim REMOVES
@@ -785,13 +783,13 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
track.endMs / 1000,
- filmInsertsRef.current,
+ insertRangesRef.current,
) -
projectRawTimelineSecToPlayback(
clipsRef.current,
trimRangesRef.current,
track.startMs / 1000,
- filmInsertsRef.current,
+ insertRangesRef.current,
),
);
// A voiceover follows the cuts AND its own insertions, through one walk over
@@ -985,6 +983,7 @@ export function VirtualPreview({
activeSourceId,
0.05,
activeClipIdRef.current ?? undefined,
+ insertRangesRef.current,
);
if (pos) {
activeClipIdRef.current = pos.clip.id;
@@ -1055,7 +1054,10 @@ export function VirtualPreview({
updateVirtualTime(entering.atRawSec);
return;
}
- updateVirtualTime(nextRawTime);
+ // While an insertion plays the element is parked, so the position it reports stands
+ // still at where the insertion opens. Its own wall clock is what carries the
+ // playhead across it — nothing else is moving. Zero the rest of the time.
+ updateVirtualTime(nextRawTime + insertionElapsedRef.current);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
@@ -1076,14 +1078,7 @@ export function VirtualPreview({
const updateVirtualTime = useCallback(
(nextTimeSec: number) => {
setVirtualTimeSec(nextTimeSec);
- // Two coordinates, one publish. The raw second says where the RECORDING is; the
- // ruler second says where on the timeline the playhead is, which is the only one
- // that can move while an insertion plays — the recording is standing still at the
- // insertion's moment for its whole duration.
- onTimeChange?.(
- nextTimeSec,
- expandRawSec(nextTimeSec, filmInsertsRef.current) + insertionElapsedRef.current,
- );
+ onTimeChange?.(nextTimeSec);
// ponytail: mirrors main's per-frame `video.playbackRate = ...`
// (videoEventHandlers.ts) — the browser does the actual time
// warping, so this is the only thing speed regions need. No
@@ -1166,7 +1161,7 @@ export function VirtualPreview({
// advance, trim skip) are gated on `!v.paused` and so never land here mid-insertion.
insertionRef.current = null;
insertionElapsedRef.current = 0;
- const position = locateVirtualPosition(clips, nextVirtualTimeSec);
+ const position = locateVirtualPosition(clips, nextVirtualTimeSec, insertRanges);
if (!position) {
videoRef.current?.pause();
updateVirtualTime(0);
@@ -1273,7 +1268,11 @@ export function VirtualPreview({
// the one that has to come back. An asset switch queued in the
// meantime is newer intent still, so it wins outright.
if (!pendingSeekRef.current) {
- const position = locateVirtualPosition(clipsRef.current, virtualTimeSecRef.current);
+ const position = locateVirtualPosition(
+ clipsRef.current,
+ virtualTimeSecRef.current,
+ insertRangesRef.current,
+ );
// `locateVirtualPosition` answers for whatever clip the playhead
// is on, which after a boundary advance can belong to a DIFFERENT
// asset — its source time would be a meaningless offset into the
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index b97f24ab7..ef487f931 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -51,13 +51,9 @@ import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
import {
- collapseRawSec,
- expandRawSec,
type InsertedWordMark,
insertedWordMarks,
- type RulerInsert,
rulerInserts,
- totalInsertedSec,
} from "@/lib/ai-edition/timeline/inserted-time";
import {
newRegionDurationSec,
@@ -230,15 +226,8 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
- /** The media added words inserted. `currentTimeSec` is a STORED second; the ruler it is
- * drawn on counts the insertions, so it has to be placed through them or it drifts from
- * the clips by the whole added time. */
- inserts: readonly RulerInsert[];
/** Live scrub position in RAW seconds, when a drag is in flight. */
overrideTimeSec: number | null;
- /** The same drag's RULER position. Preferred when present: it is the only coordinate
- * that can name a moment INSIDE an insertion, which is zero raw seconds wide. */
- overrideRulerSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
playheadRef?: React.MutableRefObject;
@@ -262,35 +251,13 @@ interface PlayheadOverlayProps {
*/
const PlayheadOverlay = memo(function PlayheadOverlay({
totalSec,
- inserts,
overrideTimeSec,
- overrideRulerSec,
canvasStyle,
onPointerDown,
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const storeRulerSec = useProjectStore((s) => s.currentRulerSec);
- // The ruler position, from the most trustworthy source that has one.
- //
- // The raw second alone cannot draw this playhead: an insertion occupies ruler seconds
- // and none of the recording, so every ruler second inside one collapses to the same raw
- // moment and expanding it back puts the playhead on the insertion's near edge — where it
- // visibly stalls through playback, and where it snaps back to after a scrub released over
- // one (issue #560).
- //
- // The store's ruler second is trusted only when it still AGREES with the raw one: a
- // caller that predates insertions writes the raw value for both, which is right until an
- // insertion sits before it. Collapsing is the test, and expanding is the fallback.
- const storeSec =
- Math.abs(collapseRawSec(storeRulerSec, inserts).sec - storeTimeSec) < 1e-3
- ? storeRulerSec
- : expandRawSec(storeTimeSec, inserts);
- const pct =
- ((overrideRulerSec ??
- (overrideTimeSec !== null ? expandRawSec(overrideTimeSec, inserts) : storeSec)) /
- totalSec) *
- 100;
+ const pct = (((overrideTimeSec ?? storeTimeSec) / totalSec) * 100) as number;
return (
@@ -629,9 +596,7 @@ export function V4Timeline({
onAddVoiceover,
}: {
tl: TimelineApi;
- /** `rulerSec` is the same moment on the ruler the user sees; it differs from `sec` as
- * soon as an insertion sits before it, or under it. */
- setCurrentTime: (sec: number, rulerSec?: number) => void;
+ setCurrentTime: (sec: number) => void;
variant?: "edit" | "media";
onDropAsset?: (assetId: string) => Promise;
videoSources?: VideoSource[];
@@ -732,13 +697,11 @@ export function V4Timeline({
() =>
Math.max(
1,
- clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0) + totalInsertedSec(inserts),
+ clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0),
),
[clips, inserts],
);
const pctOf = useCallback((sec: number) => (sec / total) * 100, [total]);
- /** Stored raw seconds → a percentage of the expanded ruler. */
- const pctAt = useCallback((sec: number) => pctOf(expandRawSec(sec, inserts)), [pctOf, inserts]);
const showLanes = variant === "edit";
// The visible fraction of the timeline, and what one second is worth on screen
@@ -857,14 +820,8 @@ export function V4Timeline({
// pointer for the frame the store hasn't caught up on yet. Handed down as an
// override to the two components that read the playhead from the store.
const [scrubbingTimeSec, setScrubbingTimeSec] = useState(null);
- // The pointer's RULER position while scrubbing, kept apart from the raw one above.
- // Two numbers because they mean different things: the timecode reads the raw clock,
- // like the store, and the playhead has to be able to sit INSIDE an insertion — which
- // takes up none of the recording, so no raw value can address a moment within it.
- const [scrubRulerSec, setScrubRulerSec] = useState(null);
const rafSeekRef = useRef(0);
const pendingSeekTimeRef = useRef(null);
- const pendingSeekRulerRef = useRef(null);
// ── interactions ────────────────────────────────────────────────
const playheadElRef = useRef(null);
@@ -886,13 +843,7 @@ export function V4Timeline({
// supposed to be pointing at, which is what showed as the wrong subtitle under a
// correctly-placed playhead (issue #560).
//
- // Collapsing lands on the insertion's own moment when the pointer is inside one,
- // which is the honest answer: an insertion takes up none of the recording, so
- // there is no raw value inside it to seek the media to. The ruler position goes
- // to the store ALONGSIDE it, which is what lets the playhead stay where it was
- // released instead of snapping to the insertion's near edge.
- const rulerTime = pct * total;
- const { sec: targetTime } = collapseRawSec(rulerTime, inserts);
+ const targetTime = pct * total;
// Direct DOM playhead update (0ms latency, zero React re-render overhead)
if (playheadElRef.current) {
@@ -901,16 +852,14 @@ export function V4Timeline({
// Optimistic local UI state update
setScrubbingTimeSec(targetTime);
- setScrubRulerSec(rulerTime);
pendingSeekTimeRef.current = targetTime;
- pendingSeekRulerRef.current = rulerTime;
if (isImmediate) {
if (rafSeekRef.current !== 0) {
cancelAnimationFrame(rafSeekRef.current);
rafSeekRef.current = 0;
}
- setCurrentTime(targetTime, rulerTime);
+ setCurrentTime(targetTime);
return;
}
@@ -919,7 +868,7 @@ export function V4Timeline({
rafSeekRef.current = requestAnimationFrame(() => {
rafSeekRef.current = 0;
if (pendingSeekTimeRef.current !== null) {
- setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
+ setCurrentTime(pendingSeekTimeRef.current);
}
});
}
@@ -960,15 +909,10 @@ export function V4Timeline({
rafSeekRef.current = 0;
}
if (pendingSeekTimeRef.current !== null) {
- // The RULER second goes with it, or releasing over an insertion drops the
- // only coordinate that could name where the pointer was: the raw second
- // alone lands on the insertion's near edge, which is the snap-back.
- setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
+ setCurrentTime(pendingSeekTimeRef.current);
pendingSeekTimeRef.current = null;
- pendingSeekRulerRef.current = null;
}
setScrubbingTimeSec(null);
- setScrubRulerSec(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
@@ -1750,10 +1694,10 @@ export function V4Timeline({
compact ? ` ${styles.lanePillCompact}` : ""
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
- left: `${pctAt(seg.segStart)}%`,
+ left: `${pctOf(seg.segStart)}%`,
// Measured on the expanded ruler at BOTH ends: a region straddling an insertion
// covers it, so its box has to grow by that insertion and not merely slide.
- width: `${pctOf(expandRawSec(seg.segEnd, inserts) - expandRawSec(seg.segStart, inserts))}%`,
+ width: `${pctOf(seg.segEnd - seg.segStart)}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
? undefined
@@ -2164,7 +2108,7 @@ export function V4Timeline({
{tick.major ? (
{fmtTick(tick.sec, rulerTicks.step)}
@@ -2254,7 +2198,7 @@ export function V4Timeline({
// ruler and the audio pills were drawn on the stored one, so any
// insertion in the film slid the two lanes apart. The take keeps its
// own length — only its head follows the ruler.
- leftPct={pctAt(start)}
+ leftPct={pctOf(start)}
widthPct={pctOf(widthSec)}
row={audioRows.rowOf.get(track.id) ?? 0}
rowHeight={AUDIO_ROW_HEIGHT_PX + AUDIO_ROW_GAP_PX}
@@ -2319,8 +2263,8 @@ export function V4Timeline({
// On the expanded ruler the box also carries whatever insertions fall
// inside it — the film really does stay on this clip's frame for
// them, so they belong to its box rather than between boxes.
- const boxStart = expandRawSec(c.timelineStartSec, inserts);
- const boxEnd = expandRawSec(c.timelineEndSec, inserts);
+ const boxStart = c.timelineStartSec;
+ const boxEnd = c.timelineEndSec;
const boxLen = boxEnd - boxStart;
const asset = tl.assets.find((a) => a.id === c.assetId);
const clipVideoUrl = videoSources.find((v) => v.id === c.assetId)?.src;
@@ -2407,7 +2351,7 @@ export function V4Timeline({
// SOURCE span, in the same ternary — two clocks, one of which the
// box is not drawn in.
const inserted = inserts.find((ins) => ins.wordId === wordId);
- const left = ((expandRawSec(atRawSec, inserts) - boxStart) / boxLen) * 100;
+ const left = ((atRawSec - boxStart) / boxLen) * 100;
const width = inserted ? (inserted.durationSec / boxLen) * 100 : 0;
return (
{
expect(texts(corrected, "voiceover")).toContain("Kubernetes words");
});
- it("measures a pause against the FILM, on either lane", () => {
- // A pause is a held CLIP frame. Measuring it against the take instead would land
- // every voiceover cue early — so the inserts stay clips-derived on both lanes.
+ it("leaves a take's cues alone when the FILM gains an insertion", () => {
+ // An insertion is media inside the clip that carries it, and it lengthens that clip.
+ // A take laid over the film keeps its own position on the timeline — the picture
+ // slides underneath it — so its cues do not move either. Measured per placement,
+ // through the asset the placement actually plays.
const paused = doc({
timeline: {
...doc().timeline,
@@ -164,11 +166,9 @@ describe("captionLane", () => {
});
const before = deriveCaptionCues(doc(), on("voiceover"), {});
const after = deriveCaptionCues(paused, on("voiceover"), {});
- // The line covers the held moment, so its END is pushed out by the second the film
- // gained and it stays on screen through the pause. That it moves AT ALL is the
- // point: the insert names the recording's asset, so a placement-derived ruler
- // would have found nothing to apply and left the voiceover cue where it was.
- expect(after[0].endMs - before[0].endMs).toBeCloseTo(1000, 0);
+ // The insertion names the RECORDING's asset. The take is a different asset laid at
+ // its own timeline position, so nothing about this cue changes.
expect(after[0].startMs).toBe(before[0].startMs);
+ expect(after[0].endMs).toBe(before[0].endMs);
});
});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 78fd9250f..553f1b856 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -22,10 +22,10 @@ import {
splitMergedCaptionsByWordBounds,
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
-import type { AxcutDocument, AxcutTranscript } from "../schema";
+import type { AxcutDocument, AxcutInsertRange, AxcutTranscript } from "../schema";
import { lanePlacements, type TranscriptPlacement } from "../timeline/aggregated-transcript";
import { takeInserts } from "../timeline/insert-mapping";
-import { expandRawSec, type RulerInsert, rulerInserts } from "../timeline/inserted-time";
+import { sourceToTimelineSec } from "../timeline/inserted-time";
import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
@@ -165,7 +165,7 @@ export function sourceSpanToTimelineSpans(
* window and the ruler head, which both providers carry (issue #560). `AxcutClip`
* stays structurally assignable, so every existing caller is unaffected. */
clips: TranscriptPlacement[],
- inserts: readonly RulerInsert[] = [],
+ inserts: readonly AxcutInsertRange[] = [],
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -174,20 +174,15 @@ export function sourceSpanToTimelineSpans(
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
if (e <= s) continue;
+ // `"closes"` on the end: a line running up to an added word covers the media that
+ // word inserted, so it stays on screen through it instead of going dark over the
+ // one moment the word exists for.
out.push({
- startSec: clip.timelineStartSec + (s - clip.sourceStartSec),
- endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
+ startSec: sourceToTimelineSec(clip, s, inserts, "opens"),
+ endSec: sourceToTimelineSec(clip, e, inserts, "closes"),
});
}
- // Onto the ruler the viewer actually sees. Expanding BOTH ends does the whole job:
- // a line after a pause slides along by it, and a line that covers the held moment
- // has only its end pushed out — so it stays on screen through the pause instead of
- // going dark over the one moment an added word exists for.
- if (inserts.length === 0) return out;
- return out.map((span) => ({
- startSec: expandRawSec(span.startSec, inserts),
- endSec: expandRawSec(span.endSec, inserts),
- }));
+ return out;
}
/**
@@ -222,7 +217,6 @@ export function deriveCaptionCues(
// lengthens the ruler under everything, including a voiceover laid over it. Feeding it
// placements would measure the pause against the take instead of the film, and land
// every voiceover cue early.
- const inserts = rulerInserts(document.timeline.insertRanges ?? [], document.timeline.clips);
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
const linesByAsset = new Map();
@@ -244,7 +238,7 @@ export function deriveCaptionCues(
line.startSec,
line.endSec,
placements,
- inserts,
+ document.timeline.insertRanges ?? [],
)) {
const startMs = Math.round(span.startSec * 1000);
const endMs = Math.max(Math.round(span.endSec * 1000), startMs + 1);
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index d61ce3475..791389d2b 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -21,7 +21,6 @@ import type {
*/
export type PlaybackSegment = AxcutClip & { heldSec?: number };
-import type { RulerInsert } from "../timeline/inserted-time";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -136,6 +135,54 @@ function collectWordRefs(
// after any structural change (insert / move / remove / trim) so the timeline
// never has gaps or overlaps between clips. Shared by useTimeline (UI) and
// the agent tool executor (main process) so both enforce the same invariant.
+/** How much media this clip's own insertions add to it, in seconds.
+ *
+ * Half-open at the start and closed at the end, matching where an insertion sits: at the
+ * END of the word it follows, which is a moment the clip plays. */
+export function insertedSecForClip(
+ clip: AxcutClip,
+ insertRanges: readonly AxcutInsertRange[],
+): number {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ return insertRanges.reduce(
+ (sum, range) =>
+ range.assetId === clip.assetId &&
+ range.atSec > clip.sourceStartSec &&
+ range.atSec <= sourceEnd + 1e-6
+ ? sum + range.durationSec
+ : sum,
+ 0,
+ );
+}
+
+/**
+ * Clip geometry that accounts for the media inserted inside each clip (issue #560).
+ *
+ * An added word inserts media — a fixed frame and silence, until there is a generator for
+ * it — and a clip carrying it is that much longer, exactly as it would be if the media had
+ * come from a file. This is the ONE place that says so; every reader downstream then works
+ * in a single coordinate, which is what makes the playhead, the native decoder and the
+ * export agree without any of them converting between two rulers.
+ *
+ * Absolute rather than incremental, so it is idempotent: a stored clip's length is always
+ * its source length (every writer above builds it that way), and re-running this on an
+ * already-reflowed document changes nothing. That is what lets it also serve as the
+ * migration for documents written before insertions existed.
+ */
+export function reflowClipsForInserts(
+ clips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
+): AxcutClip[] {
+ return resequenceClips(
+ clips.map((clip) => {
+ const sourceLen = (clip.sourceEndSec ?? clip.sourceStartSec) - clip.sourceStartSec;
+ if (sourceLen <= 0) return clip; // duration not probed yet; leave it to the prober
+ const len = sourceLen + insertedSecForClip(clip, insertRanges);
+ return { ...clip, timelineEndSec: clip.timelineStartSec + len };
+ }),
+ );
+}
+
export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
let cursor = 0;
return clips.map((c) => {
@@ -372,14 +419,13 @@ export function projectRawTimelineSecToPlayback(
trimRanges: AxcutTrimRange[],
rawSec: number,
/**
- * The recording lane's insertions, already placed on the raw ruler by `rulerInserts`.
+ * The insertions, so the kept spans below can carry them.
*
- * REQUIRED, not optional, and every call site was migrated with it. An optional
- * parameter would silently keep the early-audio bug alive at every site that had not
- * been touched yet — which is the exact failure this argument exists to fix, and the
- * kind that shows up as "the music starts a beat early" months later.
+ * REQUIRED, not optional: an optional parameter would silently keep the early-audio bug
+ * alive at every site not yet touched — the one that shows up as "the music starts a
+ * beat early" months later.
*/
- filmInserts: readonly RulerInsert[],
+ insertRanges: readonly AxcutInsertRange[],
/**
* Speed regions on the raw ruler. Supplied by the AUDIO paths, which overlay
* a 1x track onto the finished programme and so need its real, speed-adjusted
@@ -393,39 +439,17 @@ export function projectRawTimelineSecToPlayback(
let lastRawEnd = 0; // raw end of the last kept segment, for the trailing overhang
let landed: number | null = null; // output(rawSec), once it falls in/before a kept segment
- // An insertion occupies ZERO raw seconds and D OUTPUT seconds — it is the one
- // thing a flat kept-interval list cannot express, which is why it was left out and why
- // every audio track after an insertion has been landing D seconds early in both the preview
- // and the export. Interleaved here rather than added afterwards, because where the insertion
- // sits inside the kept span decides which side of it `rawSec` falls on.
- const holds = [...filmInserts].sort((a, b) => a.atRawSec - b.atRawSec);
- let nextHold = 0;
-
// The kept stretches come from `keptRawSpans`, which is this walk — it was lifted out of
// here so the transcript lanes and the audio mix could ask the same question and get the
- // same answer (issue #560). Trims only REMOVE, so a kept span's RAW length is what
- // survives; how long it takes to PLAY is a separate question `outputDurationOfRawSpan`
- // answers, because a speed region scales it.
- for (const seg of keptRawSpans(ordered, trimRanges)) {
- let from = seg.startSec;
- // Every insertion this segment carries, in order. One whose moment a trim removed is
- // in no kept span at all and is never reached — the moment it holds is not in the
- // film any more, so neither is the insertion.
- while (nextHold < holds.length && holds[nextHold].atRawSec < seg.startSec) nextHold++;
- while (nextHold < holds.length && holds[nextHold].atRawSec < seg.endSec) {
- const hold = holds[nextHold++];
- const at = Math.max(from, hold.atRawSec);
- if (landed === null && rawSec < at) {
- const within = Math.min(Math.max(rawSec, from), at);
- landed = outCursor + outputDurationOfRawSpan(from, within, speedRegions);
- }
- outCursor += outputDurationOfRawSpan(from, at, speedRegions);
- from = at;
- // Strictly after the insertion's own moment, matching `expandRawSec`: a track whose
- // head sits exactly there starts WITH the insertion, not after it.
- if (landed === null && rawSec <= at) landed = outCursor;
- outCursor += hold.durationSec;
- }
+ // same answer (issue #560). It carries the insertions too: they are timeline seconds like
+ // any other, which is exactly what one clock buys — this walk used to interleave them
+ // itself, and every reader that forgot to had audio landing early.
+ //
+ // Trims only REMOVE, so a kept span's length is what survives; how long it takes to PLAY
+ // is a separate question `outputDurationOfRawSpan` answers, because a speed region scales
+ // it.
+ for (const seg of keptRawSpans(ordered, trimRanges, insertRanges)) {
+ const from = seg.startSec;
if (landed === null && rawSec < seg.endSec) {
// `rawSec` is inside this segment, or before it in a trimmed/gap region (then
// the span clamps to nothing → the output edge just before the gap).
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 87c6b8213..fdc3909d1 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -750,11 +750,29 @@ describe("insert ranges", () => {
expect(insertRangesMatchWords(take)).toBe(true);
});
- // The clips are the thing the first attempt broke. Nothing here may touch them.
- it("leaves the clips exactly as they were", () => {
+ // An insertion is MEDIA inside the clip, so the clip carrying it is exactly that much
+ // longer — the one fact every reader downstream depends on, and the reason none of them
+ // needs a second ruler to convert to. Its source window is untouched: no frame of the
+ // recording was added or removed.
+ it("lengthens the clip that carries the insertion, by the insertion", () => {
const before = docWithClip();
const result = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
- expect(result.timeline.clips).toEqual(before.timeline.clips);
+ const [range] = result.timeline.insertRanges;
+ const was = before.timeline.clips[0];
+ const now = result.timeline.clips[0];
+ expect(now.timelineEndSec - now.timelineStartSec).toBeCloseTo(
+ was.timelineEndSec - was.timelineStartSec + range.durationSec,
+ 5,
+ );
+ expect(now.sourceStartSec).toBe(was.sourceStartSec);
+ expect(now.sourceEndSec).toBe(was.sourceEndSec);
+ });
+
+ it("gives the length back when the word goes", () => {
+ const before = docWithClip();
+ const added = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
+ const removed = removeDocumentWords(added, "asset_1", ["synth_1"]);
+ expect(removed.timeline.clips).toEqual(before.timeline.clips);
});
it("stores nothing when the word fits in silence that is already there", () => {
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 87d6a5800..8d9c49487 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,5 +1,6 @@
import type { AxcutDocument, AxcutInsertRange, AxcutTranscript, AxcutWord } from "../schema";
import { createId } from "./ids";
+import { reflowClipsForInserts } from "./timeline";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -385,7 +386,17 @@ function withInsertRangesForWords(document: AxcutDocument, assetId: string): Axc
if (kept.length === existing.length && kept.every((range, i) => range === existing[i])) {
return document;
}
- return { ...document, timeline: { ...document.timeline, insertRanges: kept } };
+ // The clips grow with them. An insertion is media inside the clip, so the clip is that
+ // much longer — the single fact every downstream reader needs, written once, here, where
+ // the ranges themselves are written.
+ return {
+ ...document,
+ timeline: {
+ ...document.timeline,
+ insertRanges: kept,
+ clips: reflowClipsForInserts(document.timeline.clips, kept),
+ },
+ };
}
/**
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index 69d0cd340..fd47da9cc 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -60,20 +60,6 @@ export interface ProjectState {
error: string | null;
sourceDurationSec: number;
currentTimeSec: number;
- /** The playhead's position on the RULER — the timeline the user sees and scrubs.
- *
- * `currentTimeSec` is the stored, RAW second: where the recording is. The two are the
- * same number until an insertion exists, and then they permanently are not. An insertion
- * is media added INSIDE a clip (issue #560), so it takes up ruler seconds while taking up
- * none of the recording — every ruler second inside one names the same raw moment.
- *
- * Which means the raw second cannot say WHERE inside an insertion the playhead is, and a
- * playhead that only had that number fell to the insertion's near edge the instant it
- * entered: playback stalled visually there, and releasing a scrub over one snapped back
- * to its start. Consumers that resolve MEDIA (seek, captions, transcript cue, mix) keep
- * reading `currentTimeSec` — through an insertion the recording really is at that one
- * instant. Only what draws or measures the ruler reads this. */
- currentRulerSec: number;
/** The selected imported audio track (issue #350), or null. In the store — not
* `useTimeline`'s local selection — because the media panel (which imports the
* file) and the inspector (which edits it) sit in different component subtrees
@@ -160,9 +146,7 @@ export interface ProjectState {
opts: DocumentWriteOptions,
) => Promise;
setSourceDuration: (sec: number) => void;
- /** `rulerSec` defaults to `sec`, which is right everywhere no insertion is involved
- * and is what every existing caller means. */
- setCurrentTime: (sec: number, rulerSec?: number) => void;
+ setCurrentTime: (sec: number) => void;
setPlaying: (playing: boolean) => void;
markClean: () => void;
/**
@@ -213,7 +197,6 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
- currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
@@ -566,8 +549,8 @@ export const useProjectStore = create((set, get) => ({
set({ sourceDurationSec: sec });
},
- setCurrentTime(sec, rulerSec) {
- set({ currentTimeSec: sec, currentRulerSec: rulerSec ?? sec });
+ setCurrentTime(sec) {
+ set({ currentTimeSec: sec });
},
setPlaying(playing) {
@@ -598,7 +581,6 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
- currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index bd16a698c..7114afc6d 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -8,16 +8,15 @@
import { describe, expect, it } from "vitest";
import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
- collapseRawSec,
- expandRawSec,
insertedWordMarks,
insertionEnteredBetween,
type RulerInsert,
rulerInserts,
- totalInsertedSec,
+ sourceToTimelineSec,
+ timelineToSourceSec,
} from "./inserted-time";
-function clip(overrides: Partial & Pick): AxcutClip {
+function clipFixture(overrides: Partial & Pick): AxcutClip {
return {
assetId: "a1",
sourceStartSec: 0,
@@ -47,7 +46,9 @@ function insert(overrides: Partial = {}): AxcutInsertRange {
describe("rulerInserts", () => {
it("projects a pause through the clip that plays its moment", () => {
// The clip plays source 4–10 starting at ruler 20, so source 6 is ruler 22.
- const clips = [clip({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 })];
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 }),
+ ];
expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([
{ id: "ins_1", wordId: "synth_1", atRawSec: 22, durationSec: 0.5 },
]);
@@ -56,18 +57,22 @@ describe("rulerInserts", () => {
// The word is not on the timeline, so its pause has no place on the ruler and adds
// nothing — the same rule a caption line follows when no clip covers it.
it("drops a pause no clip plays", () => {
- const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 })];
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 }),
+ ];
expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([]);
});
it("counts a pause sitting exactly on a clip's edge", () => {
// A pause sits at the END of the word it follows, which is routinely the boundary.
- const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 })];
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 }),
+ ];
expect(rulerInserts([insert({ atSec: 4 })], clips)).toHaveLength(1);
});
it("returns them in ruler order, whatever order they were stored in", () => {
- const clips = [clip({ id: "c1" })];
+ const clips = [clipFixture({ id: "c1" })];
const placed = rulerInserts(
[insert({ id: "b", atSec: 8 }), insert({ id: "a", atSec: 2 })],
clips,
@@ -77,87 +82,13 @@ describe("rulerInserts", () => {
it("places a pause only once when two clips could play its moment", () => {
const clips = [
- clip({ id: "c1" }),
- clip({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
+ clipFixture({ id: "c1" }),
+ clipFixture({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
];
expect(rulerInserts([insert()], clips)).toHaveLength(1);
});
});
-describe("the expanded ruler", () => {
- const INSERTS: RulerInsert[] = [
- { id: "a", wordId: "w_a", atRawSec: 2, durationSec: 0.5 },
- { id: "b", wordId: "w_b", atRawSec: 6, durationSec: 1 },
- ];
-
- it("leaves everything before the first pause where it was", () => {
- expect(expandRawSec(0, INSERTS)).toBe(0);
- expect(expandRawSec(1.9, INSERTS)).toBe(1.9);
- });
-
- // The frame about to be held keeps its own instant; the pause opens after it.
- it("keeps the held moment itself in place", () => {
- expect(expandRawSec(2, INSERTS)).toBe(2);
- });
-
- it("shifts everything after a pause by what it added", () => {
- expect(expandRawSec(3, INSERTS)).toBe(3.5);
- expect(expandRawSec(6, INSERTS)).toBe(6.5);
- expect(expandRawSec(7, INSERTS)).toBe(8.5);
- });
-
- it("grows the ruler by the pauses' total", () => {
- expect(totalInsertedSec(INSERTS)).toBe(1.5);
- expect(expandRawSec(10, INSERTS)).toBe(10 + totalInsertedSec(INSERTS));
- });
-
- it("round-trips every moment that is not inside a pause", () => {
- for (const sec of [0, 1.9, 3, 5.99, 7, 10]) {
- const back = collapseRawSec(expandRawSec(sec, INSERTS), INSERTS);
- expect(back.sec).toBeCloseTo(sec, 9);
- expect(back.heldBy).toBeNull();
- }
- });
-
- // The held moment is the one place the pair is not a clean inverse, and it is not
- // meant to be: source 2 occupies the WHOLE of ruler [2, 2.5) — it is what the pause
- // shows. Expanding picks the start of that stretch; collapsing it back answers with
- // the same source moment and says it is being held, which is the honest reading of a
- // moment that is on screen for half a second.
- it("says the held moment is held, and still names the right source moment", () => {
- const back = collapseRawSec(expandRawSec(2, INSERTS), INSERTS);
- expect(back.sec).toBe(2);
- expect(back.heldBy?.id).toBe("a");
- });
-
- // Not a gap in the model — this IS the pause. A stretch of ruler stands for one held
- // source moment, and the caller is told which pause is holding it so it parks the
- // decoder instead of seeking through content that belongs after.
- it("collapses a moment inside a pause onto the frame being held", () => {
- for (const sec of [2.01, 2.25, 2.49]) {
- const back = collapseRawSec(sec, INSERTS);
- expect(back.sec).toBe(2);
- expect(back.heldBy?.id).toBe("a");
- }
- });
-
- it("resumes on the far side of a pause", () => {
- const back = collapseRawSec(2.5, INSERTS);
- expect(back.sec).toBe(2);
- expect(back.heldBy).toBeNull();
- });
-
- it("counts every earlier pause when collapsing a later moment", () => {
- // Ruler 8.5 is source 7: 0.5s from the first pause and 1s from the second.
- expect(collapseRawSec(8.5, INSERTS)).toEqual({ sec: 7, heldBy: null });
- });
-
- it("is the identity when there are no pauses", () => {
- expect(expandRawSec(4, [])).toBe(4);
- expect(collapseRawSec(4, [])).toEqual({ sec: 4, heldBy: null });
- });
-});
-
// ─── Where an added word's mark goes ─────────────────────────────────────────
// Issue #560. Two defects lived in one ternary in V4Timeline: a word WITH a pause was
// placed on the expanded ruler and one WITHOUT at a fraction of the clip's SOURCE span —
@@ -232,37 +163,84 @@ describe("insertedWordMarks", () => {
});
});
-// ─── The scrub round-trip ───────────────────────────────────────────────────
-// The timeline measures the pointer against the EXPANDED ruler and writes the result into
-// a store every consumer reads as a RAW second — the preview seek, the caption lookup, the
-// transcript cue, the audio mix. Straight through, the playhead sat one accumulated pause
-// AHEAD of everything it pointed at: the right playhead, the wrong subtitle (issue #560).
-
-describe("a scrub survives the round trip", () => {
- const marks: RulerInsert[] = [
- { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
- { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
+// ─── Source ↔ timeline, inside one clip ─────────────────────────────────────
+// The whole consequence of an insertion being MEDIA: the clip is longer than its source
+// window, so a moment past an insertion sits that much further along the timeline. Every
+// place that used to convert between a "raw" and an "expanded" ruler is asking this, of
+// one clip — and getting it wrong put a caption, a playhead or a decoder in the wrong
+// place (issue #560).
+
+describe("source ↔ timeline through a clip that carries insertions", () => {
+ // Ten seconds of recording laid at timeline 0, with 0.5s inserted at source 2 and 1s
+ // at source 6 — so the clip is 11.5s long and its source window is untouched.
+ const clip = clipFixture({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11.5,
+ });
+ const ranges: AxcutInsertRange[] = [
+ {
+ id: "a",
+ assetId: "a1",
+ atSec: 2,
+ durationSec: 0.5,
+ wordId: "w_a",
+ reason: "",
+ origin: "user",
+ },
+ {
+ id: "b",
+ assetId: "a1",
+ atSec: 6,
+ durationSec: 1,
+ wordId: "w_b",
+ reason: "",
+ origin: "user",
+ },
];
- it("comes back to the raw second it started from, before and after each pause", () => {
- for (const raw of [0, 1.5, 3.9, 6, 8.5, 12, 30]) {
- expect(collapseRawSec(expandRawSec(raw, marks), marks).sec).toBeCloseTo(raw, 6);
+ it("leaves everything before the first insertion where it was", () => {
+ expect(sourceToTimelineSec(clip, 0, ranges)).toBeCloseTo(0, 6);
+ expect(sourceToTimelineSec(clip, 1.9, ranges)).toBeCloseTo(1.9, 6);
+ });
+
+ it("counts every insertion before the moment, and only those", () => {
+ expect(sourceToTimelineSec(clip, 4, ranges)).toBeCloseTo(4.5, 6);
+ expect(sourceToTimelineSec(clip, 10, ranges)).toBeCloseTo(11.5, 6);
+ });
+
+ it("puts the insertion's own moment where it opens, or where it closes", () => {
+ // The choice is real: a position and a span's START go before the inserted media,
+ // a span's END goes after it, so a caption running up to an added word covers it.
+ expect(sourceToTimelineSec(clip, 2, ranges, "opens")).toBeCloseTo(2, 6);
+ expect(sourceToTimelineSec(clip, 2, ranges, "closes")).toBeCloseTo(2.5, 6);
+ });
+
+ it("comes back to the source moment it started from", () => {
+ for (const source of [0, 1.9, 2, 3, 5.5, 6, 9.99]) {
+ const back = timelineToSourceSec(clip, sourceToTimelineSec(clip, source, ranges), ranges);
+ expect(back.sourceSec).toBeCloseTo(source, 6);
}
});
- it("lands on the held moment for a pointer inside a pause, and says so", () => {
- // Every ruler second inside an insertion is the same raw moment: none of those
- // seconds come from the recording, so there is nothing else it could mean.
- const inside = collapseRawSec(5, marks);
- expect(inside.sec).toBeCloseTo(4, 6);
- expect(inside.heldBy?.id).toBe("i1");
- expect(collapseRawSec(5.9, marks).sec).toBeCloseTo(4, 6);
+ it("has no source moment inside an insertion, and says which one", () => {
+ // There is nothing else it could answer: none of those seconds come from the file.
+ const inside = timelineToSourceSec(clip, 2.25, ranges);
+ expect(inside.sourceSec).toBeCloseTo(2, 6);
+ expect(inside.insideInsert?.id).toBe("a");
+ expect(timelineToSourceSec(clip, 2.5, ranges).insideInsert).toBeNull();
+ });
+
+ it("is the plain shift when the clip carries nothing", () => {
+ expect(sourceToTimelineSec(clip, 4, [])).toBeCloseTo(4, 6);
+ expect(timelineToSourceSec(clip, 4, []).sourceSec).toBeCloseTo(4, 6);
});
- it("counts every pause before the pointer, not just the first", () => {
- // Ruler 13 is past both: 13 − 2 − 1 = raw 10.
- expect(collapseRawSec(13, marks).sec).toBeCloseTo(10, 6);
- expect(collapseRawSec(13, marks).heldBy).toBeNull();
+ it("ignores insertions belonging to another recording", () => {
+ const other = [{ ...ranges[0], assetId: "a2" }];
+ expect(sourceToTimelineSec(clip, 4, other)).toBeCloseTo(4, 6);
});
});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index c6786d25c..c921ce878 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -45,86 +45,119 @@ export function rulerInserts(
clips: readonly AxcutClip[],
): RulerInsert[] {
const placed: RulerInsert[] = [];
- for (const insert of inserts) {
- for (const clip of clips) {
- if (clip.assetId !== insert.assetId) continue;
- const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
- // Inclusive at both edges: an insertion sits at the END of the word it follows, which
- // is routinely a clip's own boundary.
- if (insert.atSec < clip.sourceStartSec || insert.atSec > sourceEnd) continue;
+ const claimed = new Set();
+ for (const clip of clips) {
+ const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
+ const mine = inserts
+ // Inclusive at both edges: an insertion sits at the END of the word it follows,
+ // which is routinely a clip's own boundary. Claimed once, by the first clip that
+ // plays the moment — two clips over the same recording are two places, not two
+ // insertions.
+ .filter(
+ (insert) =>
+ insert.assetId === clip.assetId &&
+ !claimed.has(insert.id) &&
+ insert.atSec >= clip.sourceStartSec &&
+ insert.atSec <= sourceEnd,
+ )
+ .sort((a, b) => a.atSec - b.atSec);
+ // Each insertion opens after the ones before it in the same clip: the clip's length
+ // already carries all of them, so a plain source-shift would stack them all at the
+ // first one's position.
+ let carriedSec = 0;
+ for (const insert of mine) {
+ claimed.add(insert.id);
placed.push({
id: insert.id,
wordId: insert.wordId,
- atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec),
+ atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + carriedSec,
durationSec: insert.durationSec,
});
- break;
+ carriedSec += insert.durationSec;
}
}
return placed.sort((a, b) => a.atRawSec - b.atRawSec);
}
-/** How much time the insertions add in total — what the ruler grows by. */
-export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
- return inserts.reduce((sum, insert) => sum + insert.durationSec, 0);
-}
-
/**
- * Stored raw seconds → the ruler the user sees.
+ * A clip's own source moment → where it lands on the timeline.
+ *
+ * Not a plain shift, and this is the whole consequence of an insertion being MEDIA: the
+ * clip is longer than its source window by everything inserted inside it, so a moment past
+ * an insertion sits that much further along. Every place that used to convert between a
+ * "raw" and an "expanded" ruler is really asking this, of one clip.
*
- * Monotone and total: every stored moment has exactly one place on the expanded ruler.
- * A moment sitting exactly ON an insertion maps to where the insertion BEGINS, so the last recorded
- * frame keeps its own instant and the inserted media opens after it.
+ * `edge` decides what happens AT an insertion's own moment, which is a real choice and not
+ * a rounding detail. `"opens"` puts the moment before the inserted media — right for a
+ * position, and for the START of a span, so the span does not swallow the insertion that
+ * precedes it. `"closes"` puts it after — right for the END of a span, so a stretch running
+ * up to an insertion covers it rather than stopping short and leaving it orphaned.
*/
-export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): number {
- let out = sec;
+export function sourceToTimelineSec(
+ /** Only the three fields that locate a clip — so a voiceover placement, which carries
+ * the same three, maps through this too (issue #560). */
+ clip: Pick,
+ sourceSec: number,
+ inserts: readonly AxcutInsertRange[],
+ edge: "opens" | "closes" = "opens",
+): number {
+ let added = 0;
for (const insert of inserts) {
- if (insert.atRawSec < sec) out += insert.durationSec;
+ if (insert.assetId !== clip.assetId) continue;
+ if (insert.atSec <= clip.sourceStartSec) continue;
+ if (edge === "opens" ? insert.atSec < sourceSec : insert.atSec <= sourceSec + 1e-6) {
+ added += insert.durationSec;
+ }
}
- return out;
+ return clip.timelineStartSec + (sourceSec - clip.sourceStartSec) + added;
}
/**
- * The ruler the user sees → stored raw seconds.
+ * The inverse: a timeline second → the source moment the clip is showing there.
*
- * The inverse of {@link expandRawSec} outside an insertion. Inside one it cannot be an inverse
- * — a whole stretch of ruler stands for a single held moment — and it returns that moment,
- * flagged, so a caller driving a decoder knows to hold rather than to seek.
+ * Inside an insertion there is no source moment — that is what makes it an insertion — so
+ * it answers with the moment the inserted media follows, and names the insertion. A caller
+ * driving a decoder needs both: where to park, and the fact that it should stay parked.
*/
-export function collapseRawSec(
- sec: number,
- inserts: readonly RulerInsert[],
-): { sec: number; heldBy: RulerInsert | null } {
- let offset = 0;
- for (const insert of inserts) {
- const startsAt = insert.atRawSec + offset;
- if (sec < startsAt) break;
- if (sec < startsAt + insert.durationSec) {
- return { sec: insert.atRawSec, heldBy: insert };
+export function timelineToSourceSec(
+ clip: AxcutClip,
+ timelineSec: number,
+ inserts: readonly AxcutInsertRange[],
+): { sourceSec: number; insideInsert: AxcutInsertRange | null } {
+ const mine = inserts
+ .filter((insert) => insert.assetId === clip.assetId && insert.atSec > clip.sourceStartSec)
+ .sort((a, b) => a.atSec - b.atSec);
+ let added = 0;
+ for (const insert of mine) {
+ const opensAt = clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + added;
+ if (timelineSec < opensAt) break;
+ if (timelineSec < opensAt + insert.durationSec) {
+ return { sourceSec: insert.atSec, insideInsert: insert };
}
- offset += insert.durationSec;
+ added += insert.durationSec;
}
- return { sec: sec - offset, heldBy: null };
+ return {
+ sourceSec: clip.sourceStartSec + (timelineSec - clip.timelineStartSec) - added,
+ insideInsert: null,
+ };
}
/**
* The insertion a frame of playback ran into, if it ran into one.
*
- * Half-open on the LEFT, and that is the whole point: while inserted media is playing, the
- * RAW playhead stands still at exactly `atRawSec` — the recording really is at that one
- * instant, because none of the inserted seconds come from it. `>` is therefore what refuses
- * that same moment on the way out; with `>=` the insertion is re-entered the frame it ends
- * and the film never gets past it. Closed on the right (with the frame epsilon) so an
- * insertion landing precisely on a frame boundary is played rather than skipped.
+ * Half-open on the LEFT, and that is the whole point: a player parks on the insertion's
+ * frame and pins its clock to exactly `atRawSec` for the first frame of it, so `>` is what
+ * refuses that same moment on the way in a second time. Closed on the right (with the frame
+ * epsilon) so an insertion landing precisely on a frame boundary is played, not skipped.
*/
export function insertionEnteredBetween(
- prevRawSec: number,
- nextRawSec: number,
+ prevSec: number,
+ nextSec: number,
inserts: readonly RulerInsert[],
epsilonSec = 1e-6,
): RulerInsert | undefined {
return inserts.find(
- (insert) => insert.atRawSec > prevRawSec && insert.atRawSec <= nextRawSec + epsilonSec,
+ (insert) => insert.atRawSec > prevSec && insert.atRawSec <= nextSec + epsilonSec,
);
}
diff --git a/src/lib/ai-edition/timeline/programme-time.test.ts b/src/lib/ai-edition/timeline/programme-time.test.ts
index 9942fb71d..7b2b35cca 100644
--- a/src/lib/ai-edition/timeline/programme-time.test.ts
+++ b/src/lib/ai-edition/timeline/programme-time.test.ts
@@ -7,7 +7,7 @@
import { describe, expect, it } from "vitest";
import { projectRawTimelineSecToPlayback, resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
import { keptRawSpans, removalAt, removedRawSpans, subtractRemoved } from "./programme-time";
function clip(over: Partial & { id: string }): AxcutClip {
@@ -275,53 +275,71 @@ describe("subtractRemoved", () => {
});
});
-// ─── The pause the projection used to ignore ─────────────────────────────────
-// A pause occupies ZERO raw seconds and D OUTPUT seconds, which a flat kept-interval list
-// cannot express — so it was left out, and every audio track after a pause landed D seconds
-// early in both the preview and the export. `filmInserts` is required precisely so no call
-// site can quietly keep that bug.
+// ─── The insertion the projection has to walk over ──────────────────────────
+// An insertion is media INSIDE a clip, so the clip carrying it is that much longer and the
+// insertion's seconds are timeline seconds like any other. The projection's job is unchanged
+// by that — timeline in, output out — but it has to be TOLD, because it walks each clip's
+// kept SOURCE stretches and those are shorter than the clip.
-describe("projectRawTimelineSecToPlayback with the film's pauses", () => {
- const clips = twoClips();
- const pause = { id: "i1", wordId: "w1", atRawSec: 5, durationSec: 1 };
-
- it("pushes everything after a pause later by exactly what it bought", () => {
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [])).toBeCloseTo(8, 6);
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [pause])).toBeCloseTo(9, 6);
- });
-
- it("leaves everything before it where it was", () => {
- expect(projectRawTimelineSecToPlayback(clips, [], 3, [pause])).toBeCloseTo(3, 6);
- });
+describe("projectRawTimelineSecToPlayback across an insertion", () => {
+ // One second inserted at source 5 of the first clip, so that clip runs 0..11 and the
+ // second one starts at 11.
+ const inserted: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ const clips = [
+ clip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11,
+ }),
+ clip({
+ id: "c2",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 11,
+ timelineEndSec: 21,
+ }),
+ ];
- it("starts a track whose head sits exactly on the pause WITH the pause", () => {
- // Strict, matching `expandRawSec`: arriving at the pause's moment is the beginning
- // of the hold, not the end of it.
- expect(projectRawTimelineSecToPlayback(clips, [], 5, [pause])).toBeCloseTo(5, 6);
+ it("is the identity when nothing is cut — the insertion is already in the film", () => {
+ // This is what one clock buys. Under two, the walk had to re-add the insertion here
+ // and every reader that forgot to had its audio landing a second early.
+ expect(projectRawTimelineSecToPlayback(clips, [], 3, inserted)).toBeCloseTo(3, 6);
+ expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted)).toBeCloseTo(8, 6);
+ expect(projectRawTimelineSecToPlayback(clips, [], 15, inserted)).toBeCloseTo(15, 6);
});
- it("counts two pauses, in order", () => {
- const second = { id: "i2", wordId: "w2", atRawSec: 7, durationSec: 0.5 };
- expect(projectRawTimelineSecToPlayback(clips, [], 9, [pause, second])).toBeCloseTo(10.5, 6);
- // Order of the argument must not matter: the walk sorts.
- expect(projectRawTimelineSecToPlayback(clips, [], 9, [second, pause])).toBeCloseTo(10.5, 6);
+ it("keeps the insertion's own seconds when a trim takes the film around it", () => {
+ // Cutting source 0..2 of the first clip removes two seconds of RECORDING. The second
+ // the added word bought is not recording, so it survives.
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 0, endSec: 2 })];
+ expect(projectRawTimelineSecToPlayback(clips, trims, 8, inserted)).toBeCloseTo(6, 6);
});
- it("never reaches a pause a trim removed", () => {
- // The moment it holds is not in the film any more, so neither is the pause — the
- // same rule `resolvePlaybackSegments` already follows.
+ it("loses an insertion whose own moment a trim removed", () => {
+ // The moment it follows is not in the film any more, so neither is it — the same
+ // rule `resolvePlaybackSegments` follows.
const trims = [trim({ id: "t1", clipId: "c1", startSec: 4, endSec: 6 })];
- expect(projectRawTimelineSecToPlayback(clips, trims, 8, [pause])).toBeCloseTo(
- projectRawTimelineSecToPlayback(clips, trims, 8, []),
- 6,
- );
+ const out = projectRawTimelineSecToPlayback(clips, trims, 11, inserted);
+ // 10s of recording, less the 2s cut, and the insertion gone with it.
+ expect(out).toBeCloseTo(8, 6);
});
- it("compresses the film around a pause but never the pause itself", () => {
- // A voice plays at 1x. A 2x region halves the film either side; the second the pause
- // bought is still a second.
- const speed = [{ startMs: 0, endMs: 20_000, speed: 2 }];
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [], speed)).toBeCloseTo(4, 6);
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [pause], speed)).toBeCloseTo(5, 6);
+ it("compresses the film around an insertion, and the insertion with it", () => {
+ // A 2x region halves whatever timeline it covers. The insertion is timeline, so it
+ // is halved too — the film is one thing, and speed is a property of the film.
+ const speed = [{ startMs: 0, endMs: 21_000, speed: 2 }];
+ expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted, speed)).toBeCloseTo(4, 6);
});
});
diff --git a/src/lib/ai-edition/timeline/programme-time.ts b/src/lib/ai-edition/timeline/programme-time.ts
index 60c23f032..f2b7cb3ec 100644
--- a/src/lib/ai-edition/timeline/programme-time.ts
+++ b/src/lib/ai-edition/timeline/programme-time.ts
@@ -18,7 +18,8 @@
// Storage does not change: a trim stays source-time anchored to a clip. This is the
// derived READING of those rows, computed on demand and never written back.
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import { sourceToTimelineSec } from "./inserted-time";
import { type Interval, subtractInterval } from "./intervals";
import { trimAppliesToClip } from "./trim-mapping";
@@ -58,11 +59,20 @@ function clipRawExtent(clip: AxcutClip): RawSpan {
};
}
-/** Source interval → raw, through the clip that carries it. */
-function sourceToRaw(clip: AxcutClip, interval: Interval): RawSpan {
+/** Source interval → timeline, through the clip that carries it.
+ *
+ * `"closes"` on the end is what makes an insertion INSIDE a kept stretch part of it: the
+ * film plays those seconds, so they belong to the span. An insertion at the stretch's own
+ * start belongs to whatever came before — and if a trim took that, it is gone with it,
+ * which is right: the moment it follows is not in the film any more. */
+function sourceToRaw(
+ clip: AxcutClip,
+ interval: Interval,
+ insertRanges: readonly AxcutInsertRange[],
+): RawSpan {
return {
- startSec: clip.timelineStartSec + (interval.startSec - clip.sourceStartSec),
- endSec: clip.timelineStartSec + (interval.endSec - clip.sourceStartSec),
+ startSec: sourceToTimelineSec(clip, interval.startSec, insertRanges, "opens"),
+ endSec: sourceToTimelineSec(clip, interval.endSec, insertRanges, "closes"),
};
}
@@ -89,7 +99,11 @@ function keptSourceIntervals(clip: AxcutClip, trimRanges: AxcutTrimRange[]): Int
*
* Zero-length spans are dropped, so a caller can trust `endSec > startSec`.
*/
-export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]): RawSpan[] {
+export function keptRawSpans(
+ clips: AxcutClip[],
+ trimRanges: AxcutTrimRange[],
+ insertRanges: readonly AxcutInsertRange[] = [],
+): RawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
const spans: RawSpan[] = [];
for (const clip of ordered) {
@@ -101,7 +115,7 @@ export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]):
continue;
}
for (const iv of keptSourceIntervals(clip, trimRanges)) {
- const span = sourceToRaw(clip, iv);
+ const span = sourceToRaw(clip, iv, insertRanges);
if (span.endSec > span.startSec) spans.push(span);
}
}
@@ -126,6 +140,7 @@ export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]):
export function removedRawSpans(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
+ insertRanges: readonly AxcutInsertRange[] = [],
): RemovedRawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
if (ordered.length === 0) return [];
@@ -154,12 +169,12 @@ export function removedRawSpans(
.filter((trim) => trimAppliesToClip(trim, clip))
.map((trim) => ({
id: trim.id,
- ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }),
+ ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }, insertRanges),
}));
let holeStart = extent.startSec;
for (const iv of kept) {
- const span = sourceToRaw(clip, iv);
+ const span = sourceToRaw(clip, iv, insertRanges);
if (span.startSec > holeStart) {
removed.push(taggedHole(holeStart, span.startSec, applicable));
}
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index 507126f5c..4440b4be0 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -18,7 +18,7 @@
// forward by the trimmed duration.
import type { PlaybackSegment } from "../document/timeline";
-import type { AxcutClip } from "../schema";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
import { ventilateSpanAcrossClips } from "./region-ventilation";
import { findRawClipForSegment, getRawVirtualStartTime } from "./virtual-preview";
@@ -402,8 +402,9 @@ export function anchorRegionsWithDerivedMs<
export function segmentRawSpanSec(
segment: PlaybackSegment,
rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[] = [],
): { startSec: number; endSec: number } {
- const startSec = getRawVirtualStartTime(segment, rawClips);
+ const startSec = getRawVirtualStartTime(segment, rawClips, insertRanges);
// A held segment's source window is the single frame it shows, so its source length
// is zero — its RAW span is the pause it carries. Without this the playhead could
// never be inside it and would step straight over the pause.
@@ -682,9 +683,10 @@ export function resolveNativePosition(
rawSec: number,
visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[] = [],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
// Segment whose RAW extent contains the playhead (last segment's end inclusive).
const index = spans.findIndex((s, i) => {
diff --git a/src/lib/ai-edition/timeline/virtual-preview.ts b/src/lib/ai-edition/timeline/virtual-preview.ts
index 1cdc5d2f6..8448ae99d 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.ts
@@ -1,7 +1,8 @@
// Ported from axcut/apps/web/src/lib/virtual-preview.ts — pure time-mapping
// functions shared by the VirtualPreview component and the timeline math.
-import type { AxcutClip } from "../schema";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
+import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
export type VirtualPosition = {
clip: AxcutClip;
@@ -22,6 +23,7 @@ export function clampVirtualTime(clips: AxcutClip[], value: number): number {
export function locateVirtualPosition(
clips: AxcutClip[],
virtualTimeSec: number,
+ insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition | null {
if (clips.length === 0) return null;
const clamped = clampVirtualTime(clips, virtualTimeSec);
@@ -32,7 +34,11 @@ export function locateVirtualPosition(
const resolvedIndex = clipIndex >= 0 ? clipIndex : clips.length - 1;
const clip = clips[resolvedIndex];
const clipDuration = (clip.sourceEndSec ?? 0) - clip.sourceStartSec;
- const clipOffset = Math.max(0, Math.min(clipDuration, clamped - clip.timelineStartSec));
+ // Inside an insertion there is no source moment — none of those seconds came from the
+ // file — so this answers with the one the inserted media follows, which is the frame a
+ // decoder should be parked on.
+ const { sourceSec } = timelineToSourceSec(clip, clamped, insertRanges);
+ const clipOffset = Math.max(0, Math.min(clipDuration, sourceSec - clip.sourceStartSec));
return {
clip,
clipIndex: resolvedIndex,
@@ -72,10 +78,19 @@ export function findRawClipForSegment(
* Maps a kept segment (`AxcutClip` from `resolvePlaybackSegments`) back to its
* exact start position on the raw (untrimmed) document timeline.
*/
-export function getRawVirtualStartTime(segment: AxcutClip, rawClips: AxcutClip[]): number {
+export function getRawVirtualStartTime(
+ segment: AxcutClip,
+ rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[] = [],
+): number {
const rawClip = findRawClipForSegment(segment, rawClips);
if (!rawClip) return segment.timelineStartSec;
- return rawClip.timelineStartSec + (segment.sourceStartSec - rawClip.sourceStartSec);
+ // A HELD segment is the inserted media itself, so it starts where the insertion opens.
+ // Every other segment starting at that same source moment is the film RESUMING, so it
+ // starts where the insertion closes. Same source second, two different places on the
+ // timeline — which is the whole reason an insertion is media and not a marker.
+ const edge = (segment as { heldSec?: number }).heldSec !== undefined ? "opens" : "closes";
+ return sourceToTimelineSec(rawClip, segment.sourceStartSec, insertRanges, edge);
}
/**
@@ -126,6 +141,7 @@ function toPositionAt(
clips: AxcutClip[],
clipIndex: number,
sourceTimeSec: number,
+ insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition {
const clip = clips[clipIndex];
const sourceOffset = Math.max(
@@ -135,7 +151,9 @@ function toPositionAt(
return {
clip,
clipIndex,
- virtualTimeSec: clip.timelineStartSec + sourceOffset,
+ // Not `timelineStartSec + offset`: a clip carrying insertions is longer than its
+ // source window, so a moment past one sits that much further along (issue #560).
+ virtualTimeSec: sourceToTimelineSec(clip, clip.sourceStartSec + sourceOffset, insertRanges),
sourceTimeSec,
};
}
@@ -181,6 +199,7 @@ export function locateSourcePosition(
// its id here so it's preferred whenever the source time still falls
// inside it, before falling back to the ambiguous asset-wide scan.
preferredClipId?: string,
+ insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition | null {
if (preferredClipId) {
const preferredIndex = clips.findIndex((clip) => clip.id === preferredClipId);
@@ -198,7 +217,7 @@ export function locateSourcePosition(
(!assetId || clips[preferredIndex].assetId === assetId) &&
isWithinClipBounds(clips[preferredIndex], sourceTimeSec, epsilon, "inclusive")
) {
- return toPositionAt(clips, preferredIndex, sourceTimeSec);
+ return toPositionAt(clips, preferredIndex, sourceTimeSec, insertRanges);
}
}
const scan = (closingEdge: ClosingEdge) =>
@@ -219,7 +238,7 @@ export function locateSourcePosition(
const strict = scan("exclusive");
const clipIndex = strict >= 0 ? strict : scan("inclusive");
if (clipIndex < 0) return null;
- return toPositionAt(clips, clipIndex, sourceTimeSec);
+ return toPositionAt(clips, clipIndex, sourceTimeSec, insertRanges);
}
/**
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 887b63d79..7fa4f2f03 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -42,7 +42,6 @@ import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { takeInserts } from "@/lib/ai-edition/timeline/insert-mapping";
-import { rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { projectRegionsToSource } from "@/lib/ai-edition/timeline/timelineMap";
@@ -575,7 +574,7 @@ export function buildSceneDescription(
// question, and it does not depend on the track.
// Placed once: the projection below counts them, so a track after a pause lands where
// the ruler says rather than D seconds early.
- const filmInserts = rulerInserts(document.timeline.insertRanges ?? [], projectedClips);
+ const filmInserts = document.timeline.insertRanges ?? [];
const removed = removedRawSpans(projectedClips, document.timeline.trimRanges);
// The take's pills, keyed by group. A voiceover is walked ONCE per pill and never per
// stored fragment: the document keeps one fragment per clip a take covers, so walking
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index f1ab884ed..f9d23c12c 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -18,7 +18,7 @@
* re-aligns them.
*/
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
-import type { AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutClip, AxcutInsertRange } from "@/lib/ai-edition/schema";
import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap";
import {
getCurrentNativeViewId,
@@ -34,10 +34,13 @@ export function useNativePlaybackSync(
visibleSegments: readonly AxcutClip[],
/** RAW clip layout (`document.timeline.clips`) `currentTimeSec` is expressed against. */
rawClips: readonly AxcutClip[],
+ /** The insertions those clips carry — a clip is longer than its source window by them,
+ * so a segment's place on the timeline cannot be found without them (issue #560). */
+ insertRanges: readonly AxcutInsertRange[] = [],
): void {
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips]),
- [visibleSegments, rawClips, currentTimeSec],
+ () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips], insertRanges),
+ [visibleSegments, rawClips, currentTimeSec, insertRanges],
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
From 8ca6473697ca5f63d06cf03da376bd8b0e657a50 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 21:53:38 +0200
Subject: [PATCH 84/84] fix(captions): place a cue past an insertion on the
source it names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A caption cue is built fresh on every derive and carries no clip anchor, so it is placed
by intersecting its TIMELINE span with each segment's extent. Those extents came from
`segmentRawSpanSec` with no insertions, so the segment that RESUMES after one was taken
to start where the insertion opens rather than where it closes — and every cue past an
insertion landed a full insertion early on the source. That is what the desynchronised
subtitles were.
The insertions now reach `projectRegionsToSource`, and through it the four region kinds
the scene projects: captions and annotations, zoom, speed, camera-fullscreen. Pinned in
`timelineMap.test.ts` — timeline 7..9 over a clip with a second inserted at source 5 is
source 6..8, and a cue before the insertion does not move.
---
.../ai-edition/timeline/timelineMap.test.ts | 64 ++++++++++++++++++-
src/lib/ai-edition/timeline/timelineMap.ts | 7 +-
src/native/sceneDescription.ts | 4 ++
3 files changed, 73 insertions(+), 2 deletions(-)
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index ea3f6c61b..b06ac8fb9 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -5,7 +5,7 @@
import { describe, expect, it } from "vitest";
import { resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
import {
anchorRawRegionsToClips,
anchorRegionsWithDerivedMs,
@@ -805,3 +805,65 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
+
+// ─── Placing an unanchored region past an insertion ─────────────────────────
+// A caption cue is built fresh on every derive and carries no clip anchor, so it is placed
+// by intersecting its TIMELINE span with each segment's extent. A clip carrying insertions
+// is longer than its source window, so the segment that resumes after one starts that much
+// further along — and a projection blind to that put every caption after an insertion on
+// the wrong stretch of source. That is what "the subtitles are out of sync" was (#560).
+
+describe("projectRegionsToSource past an insertion", () => {
+ const raw = clip({
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11, // 10s of recording + 1s inserted at source 5
+ });
+ const inserts: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ // What `resolvePlaybackSegments` produces: the clip split at the insertion, with the
+ // inserted media between the halves.
+ const segments = [
+ clip({ id: "c1_seg1", assetId: "a1", sourceStartSec: 0, sourceEndSec: 5 }),
+ clip({ id: "c1_seg2", assetId: "a1", sourceStartSec: 5, sourceEndSec: 10 }),
+ ];
+
+ it("lands a region on the source it actually names", () => {
+ // Timeline 7..9 is one second past the insertion, so it is source 6..8.
+ const [out] = projectRegionsToSource(
+ [region("cue", 7, 9)],
+ segments,
+ [raw],
+ () => "x",
+ inserts,
+ );
+ expect(out.startMs).toBe(6000);
+ expect(out.endMs).toBe(8000);
+ expect(out.clipIndex).toBe(1);
+ });
+
+ it("leaves a region before the insertion where it was", () => {
+ const [out] = projectRegionsToSource(
+ [region("cue", 1, 3)],
+ segments,
+ [raw],
+ () => "x",
+ inserts,
+ );
+ expect(out.startMs).toBe(1000);
+ expect(out.endMs).toBe(3000);
+ expect(out.clipIndex).toBe(0);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index 4440b4be0..2f3749ae7 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -568,11 +568,16 @@ export function projectRegionsToSource<
visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
makeId: () => string,
+ /** The insertions the clips carry. A segment after one starts that much further along
+ * the timeline, and an UNANCHORED region — a caption cue, which is built fresh each
+ * time and has no clip anchor — is placed by intersecting with exactly that extent.
+ * Without them the caption landed on the wrong stretch of source (issue #560). */
+ insertRanges: readonly AxcutInsertRange[] = [],
): (T & { clipIndex?: number; underTrim?: boolean })[] {
// RAW extents + owning raw clip per visible segment. Both are only consulted by the
// path that needs them (raw fallback / anchor match), but resolving them once keeps
// the per-region loop free of repeated lookups.
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
const segmentRawClipIds = visibleSegments.map((seg) => findRawClipForSegment(seg, rawClips)?.id);
const out: (T & { clipIndex?: number; underTrim?: boolean })[] = [];
for (const region of regions) {
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 7fa4f2f03..683066b69 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -777,6 +777,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("zoom"),
+ document.timeline.insertRanges ?? [],
);
// Same raw→source projection as the zoom regions above, for the same reason: annotations are
// authored in RAW document time and the compositor matches each frame's SOURCE time.
@@ -813,6 +814,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("ann"),
+ document.timeline.insertRanges ?? [],
);
const projectedCameraFullscreenRegions = projectRegionsToSource(
((document.legacyEditor as Record | null)?.cameraFullscreenRegions as
@@ -821,6 +823,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("camfull"),
+ document.timeline.insertRanges ?? [],
);
// Speed regions carry an extra `speed` field the standard `rangeSchema` does not, so we
// can't read from `document.timeline.speedRanges` today (see SceneDescription.speedRegions
@@ -835,6 +838,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("speed"),
+ document.timeline.insertRanges ?? [],
);
// Webcam rect, single source of truth between preview & native :