Skip to content

Commit 51464dc

Browse files
committed
fix(timeline): lever l'ambiguïté quand deux clips partagent le même média
Un trim était stocké en temps source PAR ASSET, sans identité de clip. Dès que deux clips tirent du même média (clip dupliqué, même enregistrement posé deux fois), ils partagent le même espace de coordonnées : « sur quel clip est cette coupe ? » n'avait pas de réponse, et chaque lecteur en inventait une. Un seul défaut, trois symptômes : - transcript : la coupe apparaissait sur LES DEUX clips ; - règle : la pastille était dessinée sur le PREMIER ; - lecture/export : la plage était retirée des DEUX clips. AxcutTrimRange gagne `clipId` — `startSec`/`endSec` SONT déjà la fenêtre source, donc c'était la seule pièce manquante pour rejoindre l'ancre de clip v5 que zoom/annotation/speed ont depuis longtemps. `trimAppliesToClip` est désormais la définition unique par laquelle passent buildClipSection, trimToTimelineSpan et resolvePlaybackSegments. Un trim sans `clipId` garde le sens historique par asset : les documents existants se lisent exactement comme avant. Schéma 6 -> 7. La migration VENTILE au lieu de choisir : un trim stocké devient une ligne ancrée par clip couvert, chacune bornée à la fenêtre de son clip. Un trim v6 coupait réellement tous les clips de son asset, donc le rendu ne bouge pas — mais les lignes sont maintenant adressables séparément, ce qui permet de supprimer la copie posée sur le clip qu'on ne visait pas. Un trim ne couvrant aucun clip est conservé tel quel plutôt que supprimé. Corollaires du modèle : `duplicateClip` copie explicitement les trims ancrés sur la copie (avant c'était un effet de bord du match par asset), et `removeClip` supprime ceux du clip effacé pour qu'un jumeau du même asset ne les ressuscite pas. --- Transcript : la même ambiguïté un cran plus haut Un transcript appartient à l'ASSET, donc deux clips sur un média projettent le même AxcutWord deux fois : `word.id` nomme un instant dans le média, pas un élément à l'écran. `ClipWord.id` = `clipWordId(clip.id, word.id)` devient l'identité à l'écran (clé React, data-word-id, surlignage du mot lu, ancre du curseur), et `findCueWordId` choisit sa section par `cue.clipId`. Les jetons [silence] rendent ce cloisonnement inconditionnel : withSilenceGaps les numérote à partir de 1 dans chaque clip, donc `silence_1` existait dans tous les blocs. --- Deux défauts adjacents, trouvés en vérifiant Cliquer un mot du transcript appelait `onSeek(word.startSec)` — du temps SOURCE — alors que handleSeek attend du temps TIMELINE. Invisible tant qu'un seul clip siégeait à la position 0, où les deux coïncident ; le surlignage désormais cloisonné le rendait manifeste. Backspace/Delete devenaient un no-op silencieux dès qu'un mot déjà coupé se trouvait sur leur trajet : la résolution tombait dessus et skipWordRange le jetait comme non conservé. La marche saute maintenant les mots déjà coupés, dans les deux sens ; une garde qui prétendait traiter le cas renvoyait le mot qu'elle venait d'écarter et était morte de toute façon. --- Suppression d'un trim = toute la pastille Une coupe étirée par-dessus une frontière de clip est nécessairement 2+ lignes (ventilateTimelineSpanToTrims) rendues comme une seule pastille. La suppression filtrait par id, laissant la moitié continuer de couper. `dropTrimPillsByIds` aligne les trois chemins (touche Suppr, inspecteur, outil LLM removeTrim) sur ce que dropPillById fait déjà pour les autres types ; le contournement local de FloatingInspector disparaît. Tests : aucun test existant ne couvrait deux clips partageant un asset avec des fenêtres source qui se chevauchent — le garde-fou le plus proche prenait des fenêtres à 45 s d'écart, donc il passait malgré le défaut. Chaque nouveau test a été vérifié en échec sans son correctif.
1 parent eee6a9a commit 51464dc

29 files changed

Lines changed: 1857 additions & 195 deletions

electron/ai-edition/agent-tools.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,11 @@ export const addTrimArgs = z.object({
340340
startSec: secondsSchema,
341341
endSec: secondsSchema,
342342
assetId: z.string().min(1).optional(),
343+
// A cut belongs to ONE clip. Without this, a project where two clips draw from the same
344+
// asset (a duplicated clip) cannot say which of them the model meant, and the cut lands
345+
// on both. Resolved from the source range when the model omits it and only one clip
346+
// matches; ambiguity is reported back rather than guessed.
347+
clipId: z.string().min(1).optional(),
343348
reason: z.string().default(""),
344349
});
345350

@@ -601,6 +606,9 @@ export function documentSnapshotForModel(
601606
trimRanges: document.timeline.trimRanges.map((s) => ({
602607
id: s.id,
603608
assetId: s.assetId,
609+
// The clip the cut is on — the only thing separating two cuts over the same
610+
// media. `null` is a pre-v7 cut that still applies to every clip of its asset.
611+
clipId: s.clipId ?? null,
604612
startSec: s.startSec,
605613
endSec: s.endSec,
606614
reason: s.reason,
@@ -872,9 +880,38 @@ export function executeAgentTool(
872880
}
873881
const startSec = Math.min(parsed.data.startSec, parsed.data.endSec);
874882
const endSec = Math.max(parsed.data.startSec, parsed.data.endSec);
883+
884+
// Which clip the cut sits on. Named explicitly when the model says so; otherwise
885+
// inferred from the source range — but only when the answer is unique. Two clips
886+
// over the same asset covering that range is a real question the model has to
887+
// answer (their ids are in the snapshot), not one to settle by picking the first.
888+
const covering = document.timeline.clips.filter(
889+
(c) =>
890+
c.assetId === assetId &&
891+
endSec > c.sourceStartSec &&
892+
startSec < (c.sourceEndSec ?? Number.POSITIVE_INFINITY),
893+
);
894+
let clipId = parsed.data.clipId;
895+
if (clipId) {
896+
const target = document.timeline.clips.find((c) => c.id === clipId);
897+
if (!target) return failure(`Unknown clip: ${clipId}`);
898+
if (target.assetId !== assetId) {
899+
return failure(`Clip ${clipId} does not use asset ${assetId}.`);
900+
}
901+
} else if (covering.length === 1) {
902+
clipId = covering[0].id;
903+
} else if (covering.length > 1) {
904+
return failure(
905+
`${covering.length} clips use asset ${assetId} over ${formatSec(startSec)}${formatSec(
906+
endSec,
907+
)} (${covering.map((c) => c.id).join(", ")}). Pass clipId to say which one to trim.`,
908+
);
909+
}
910+
875911
const trim = {
876912
id: createId("trim"),
877913
assetId,
914+
...(clipId ? { clipId } : {}),
878915
startSec,
879916
endSec,
880917
reason: parsed.data.reason,
@@ -904,12 +941,27 @@ export function executeAgentTool(
904941
}
905942
const startSec = Math.min(parsed.data.startSec, parsed.data.endSec);
906943
const endSec = Math.max(parsed.data.startSec, parsed.data.endSec);
944+
// Moving a cut out of the clip it is anchored to would leave it storing a range
945+
// nothing plays — silently inert. Re-point it at the clip the new range actually
946+
// lands in, but only when that clip is unique: with several candidates the old
947+
// anchor is the better guess than an arbitrary one.
948+
const reanchor = (trim: AxcutDocument["timeline"]["trimRanges"][number]) => {
949+
if (!trim.clipId) return undefined;
950+
const covers = (c: { sourceStartSec: number; sourceEndSec?: number }) =>
951+
endSec > c.sourceStartSec && startSec < (c.sourceEndSec ?? Number.POSITIVE_INFINITY);
952+
const current = document.timeline.clips.find((c) => c.id === trim.clipId);
953+
if (current && covers(current)) return trim.clipId;
954+
const candidates = document.timeline.clips.filter(
955+
(c) => c.assetId === trim.assetId && covers(c),
956+
);
957+
return candidates.length === 1 ? candidates[0].id : trim.clipId;
958+
};
907959
const next: AxcutDocument = {
908960
...document,
909961
timeline: {
910962
...document.timeline,
911963
trimRanges: document.timeline.trimRanges.map((r) =>
912-
r.id === trimRangeId ? { ...r, startSec, endSec } : r,
964+
r.id === trimRangeId ? { ...r, clipId: reanchor(r), startSec, endSec } : r,
913965
),
914966
},
915967
};

electron/ai-edition/deep-agent/service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
142142
getCursorTrack:
143143
"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.",
144144
addTrim:
145-
"Add a 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. Call this once per silent range.",
146-
setTrim: "Move or resize an existing trim range by id. Times are source-time seconds.",
145+
"Add a 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. Call this once per silent range. 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).",
146+
setTrim:
147+
"Move or resize an existing trim range by id. Times are source-time seconds. The cut follows to whichever clip the new range lands in, when that clip is unambiguous.",
147148
setClipRange:
148149
"Set a clip's in/out points (source-time seconds) to shorten its head or tail — distinct from a trim (which cuts a span inside the clip). All clips are re-laid back-to-back afterwards, so downstream clips shift automatically. Use this ONLY when the user explicitly asks to shorten or extend a user-placed clip. Do NOT use this for 'remove silences' or 'cut pauses' — for those, use addTrim.",
149150
moveClip:

electron/ai-edition/document-service.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,17 @@ describe("DocumentService", () => {
2424
});
2525

2626
describe("createProject", () => {
27-
it("creates a v6 doc with the given title and writes it to disk", async () => {
27+
it("creates a v7 doc with the given title and writes it to disk", async () => {
2828
const doc = await service.createProject("Demo Project");
29-
expect(doc.schemaVersion).toBe(6);
29+
expect(doc.schemaVersion).toBe(7);
3030
expect(doc.project.title).toBe("Demo Project");
3131
expect(doc.project.id).toMatch(/^proj_/);
3232
expect(doc.assets).toEqual([]);
3333

3434
const filePath = path.join(tempDir, `${doc.project.id}.openscreen`);
3535
const raw = await fs.readFile(filePath, "utf8");
3636
expect(JSON.parse(raw)).toMatchObject({
37-
schemaVersion: 6,
37+
schemaVersion: 7,
3838
project: { title: "Demo Project" },
3939
});
4040
});

src/components/ai-edition/EditorEmptyState.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const sampleDoc = vi.hoisted(() => ({
2525
// load site (DocumentService, browserShim) runs `migrateRawDocumentToCurrent`
2626
// before returning, and the renderer's `parseDocument` is a pure v6
2727
// validator. Test fixtures model the post-hoist contract.
28-
schemaVersion: 6,
28+
schemaVersion: 7,
2929
project: {
3030
id: "proj_test",
3131
title: "Test",

src/components/ai-edition/NewEditorShell.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
type UnsavedChoice,
3535
} from "./Modals";
3636
import { Preview } from "./Preview";
37+
import type { TrimTarget } from "./RightPanes";
3738
import v4 from "./v4/EditorShellV4.module.css";
3839
import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar";
3940
import { type Facet, FloatingInspector } from "./v4/FloatingInspector";
@@ -574,8 +575,17 @@ export function NewEditorShell() {
574575
// apps/web/src/App.tsx. The serialised save + inside-the-chain doc
575576
// read is owned by `useSequentialTimelineOps` above.
576577
const handleAddTrimRange = useCallback(
577-
(assetId: string, startSec: number, endSec: number, reason: string) => {
578-
void applyTimelineOp({ type: "add_trim_range", assetId, startSec, endSec, reason });
578+
(target: TrimTarget, startSec: number, endSec: number, reason: string) => {
579+
// `clipId` is what keeps the cut on the block the user typed in: with two clips
580+
// over the same media, an asset-only trim showed up on both (see `trimAppliesToClip`).
581+
void applyTimelineOp({
582+
type: "add_trim_range",
583+
assetId: target.assetId,
584+
clipId: target.clipId,
585+
startSec,
586+
endSec,
587+
reason,
588+
});
579589
},
580590
[applyTimelineOp],
581591
);

0 commit comments

Comments
 (0)