diff --git a/src/lib/ai-edition/document/audioLanes.test.ts b/src/lib/ai-edition/document/audioLanes.test.ts index 9e8b3645f..34ba7aba6 100644 --- a/src/lib/ai-edition/document/audioLanes.test.ts +++ b/src/lib/ai-edition/document/audioLanes.test.ts @@ -132,6 +132,25 @@ describe("one row per kind", () => { expect([resized?.startMs, resized?.endMs]).toEqual([4000, 12_000]); }); + it("stops a resized edge at a neighbour it was dragged clean past", () => { + const before = doc([ + track({ id: "a", startMs: 2000, endMs: 4000 }), + track({ id: "b", startMs: 8000, endMs: 12_000 }), + ]); + // "b"'s left edge dragged back to 1s, past the whole of "a". It used to land on the far + // side of "a", cropped to 1–2s. + const next = placeAudioTrackInDocument( + before, + track({ id: "b", startMs: 1000, endMs: 12_000 }), + ids, + "resize", + ); + const resized = audioLanePills(next.audioTracks, "voiceover").find( + (p) => (p.trackId ?? p.id) === "b", + ); + expect([resized?.startMs, resized?.endMs]).toEqual([4000, 12_000]); + }); + it("leaves a voiceover over a music bed alone", () => { // Different kinds, different rows: the normal case, and it must not clamp. const before = doc([track({ id: "bed", kind: "music", startMs: 0, endMs: 20_000 })]); diff --git a/src/lib/ai-edition/document/audioTracks.ts b/src/lib/ai-edition/document/audioTracks.ts index b0055cb3a..1e6e9f6f2 100644 --- a/src/lib/ai-edition/document/audioTracks.ts +++ b/src/lib/ai-edition/document/audioTracks.ts @@ -412,9 +412,8 @@ export function placeAudioTrackInDocument( mode: "move" | "resize" | "create", ): AxcutDocument { const groupId = trackGroupId(pill); - const others = audioLanePills(doc.audioTracks, pill.kind).filter( - (other) => trackGroupId(other) !== groupId, - ); + const lane = audioLanePills(doc.audioTracks, pill.kind); + const others = lane.filter((other) => trackGroupId(other) !== groupId); const spanMs = Math.max(0, pill.endMs - pill.startMs); let startMs = pill.startMs; @@ -426,6 +425,8 @@ export function placeAudioTrackInDocument( startMs = firstFreeHeadMs(others, pill.startMs, spanMs); endMs = startMs + spanMs; } else { + // Where the pill sat before the edit is what says which side of a neighbour it is on. + const before = lane.find((other) => trackGroupId(other) === groupId) ?? pill; const clamped = clampSpanAgainstNeighbours( { start: pill.startMs, end: pill.endMs }, `lane:${pill.kind}:${groupId}`, @@ -435,6 +436,7 @@ export function placeAudioTrackInDocument( start: other.startMs, end: other.endMs, })), + { start: before.startMs, end: before.endMs }, ); startMs = clamped.start; endMs = clamped.end; diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index ea3f6c61b..2e4bdb014 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -645,39 +645,118 @@ describe("clampSpanAgainstNeighbours (rule 2 — repel)", () => { }); it("stops at a different-identity neighbour on the right", () => { - const out = clampSpanAgainstNeighbours({ start: 0, end: 8 }, "fast", [ - other("n", 5, 12, "slow"), - ]); + // The right edge dragged from 4 to 8. + const out = clampSpanAgainstNeighbours( + { start: 0, end: 8 }, + "fast", + [other("n", 5, 12, "slow")], + { start: 0, end: 4 }, + ); expect(out).toEqual({ start: 0, end: 5 }); }); it("stops at a different-identity neighbour on the left", () => { - const out = clampSpanAgainstNeighbours({ start: 3, end: 10 }, "fast", [ - other("n", 0, 5, "slow"), - ]); + // The left edge dragged from 6 to 3. + const out = clampSpanAgainstNeighbours( + { start: 3, end: 10 }, + "fast", + [other("n", 0, 5, "slow")], + { start: 6, end: 10 }, + ); expect(out).toEqual({ start: 5, end: 10 }); }); it("treats a same-identity neighbour as no obstacle (they simply merge)", () => { - const out = clampSpanAgainstNeighbours({ start: 0, end: 8 }, "fast", [ - other("n", 5, 12, "fast"), - ]); + const out = clampSpanAgainstNeighbours( + { start: 0, end: 8 }, + "fast", + [other("n", 5, 12, "fast")], + { start: 0, end: 4 }, + ); expect(out).toEqual({ start: 0, end: 8 }); }); it("is squeezed by blockers on both sides", () => { - const out = clampSpanAgainstNeighbours({ start: 1, end: 10 }, "fast", [ - other("l", 0, 3, "slow"), - other("r", 7, 12, "other"), - ]); + // Stretched both ways, from 4–6: each edge stops at its own blocker. + const out = clampSpanAgainstNeighbours( + { start: 1, end: 10 }, + "fast", + [other("l", 0, 3, "slow"), other("r", 7, 12, "other")], + { start: 4, end: 6 }, + ); expect(out).toEqual({ start: 3, end: 7 }); }); it("leaves a span that overlaps nothing untouched", () => { expect( - clampSpanAgainstNeighbours({ start: 2, end: 4 }, "fast", [other("n", 8, 9, "slow")]), + clampSpanAgainstNeighbours({ start: 2, end: 4 }, "fast", [other("n", 8, 9, "slow")], { + start: 0, + end: 2, + }), ).toEqual({ start: 2, end: 4 }); }); + + it("keeps a moved span whole, flush against the neighbour it ran into", () => { + const n = [other("n", 6, 12, "slow")]; + // From the left: it stops with its end on the neighbour's start… + expect( + clampSpanAgainstNeighbours({ start: 5, end: 9 }, "fast", n, { start: 0, end: 4 }), + ).toEqual({ start: 2, end: 6 }); + // …and from the right, with its start on the neighbour's end. + expect( + clampSpanAgainstNeighbours({ start: 9, end: 13 }, "fast", n, { start: 20, end: 24 }), + ).toEqual({ start: 12, end: 16 }); + }); + + it("never throws a moved span to the far side of a neighbour it overlaps", () => { + // Dropped with its start past the neighbour's start, which decided the side before. + expect( + clampSpanAgainstNeighbours({ start: 7, end: 11 }, "fast", [other("n", 6, 10, "slow")], { + start: 0, + end: 4, + }), + ).toEqual({ start: 2, end: 6 }); + }); + + it("keeps a drop that overlaps nothing, even past a neighbour", () => { + expect( + clampSpanAgainstNeighbours({ start: 12, end: 16 }, "fast", [other("n", 6, 10, "slow")], { + start: 0, + end: 4, + }), + ).toEqual({ start: 12, end: 16 }); + }); + + it("stops a left edge dragged past a whole neighbour at that neighbour", () => { + expect( + clampSpanAgainstNeighbours({ start: 30, end: 77 }, "fast", [other("n", 40, 50, "slow")], { + start: 64, + end: 77, + }), + ).toEqual({ start: 50, end: 77 }); + }); + + it("leaves a move with no room where it was", () => { + // Neighbours at 0–3 and 7–12, and the span already flush against the second one. + expect( + clampSpanAgainstNeighbours( + { start: 2, end: 7 }, + "fast", + [other("l", 0, 3, "slow"), other("r", 7, 12, "other")], + { start: 12, end: 17 }, + ), + ).toEqual({ start: 12, end: 17 }); + }); + + it("ignores a neighbour the span already overlapped before the edit", () => { + // An add does not clamp, so two regions can overlap; no position on the way clears that. + expect( + clampSpanAgainstNeighbours({ start: 0, end: 5 }, "fast", [other("n", 4, 9, "slow")], { + start: 1, + end: 6, + }), + ).toEqual({ start: 0, end: 5 }); + }); }); describe("pills wired to the universal rules", () => { @@ -769,6 +848,124 @@ describe("pills wired to the universal rules", () => { }); }); +describe("a dragged pill stays whole against its neighbour (#1008)", () => { + const ids = () => { + let n = 0; + return () => `gen_${n++}`; + }; + const spans = (regions: Array<{ startMs: number; endMs: number }>) => + regions.map((r) => [r.startMs, r.endMs, (r as { clipId?: string }).clipId]); + + // The report's layout. Clip 1 ends on no whole millisecond, like any real clip, and zoom B + // (1.5×) ends on that boundary. + const clip1 = clip({ + id: "clip_1", + assetId: "rec", + sourceStartSec: 29.4196, + sourceEndSec: 135.32, + timelineStartSec: 0, + timelineEndSec: 105.9004, + }); + const clip2 = clip({ + id: "clip_2", + assetId: "rec_2", + sourceStartSec: 0, + sourceEndSec: 60, + timelineStartSec: 105.9004, + timelineEndSec: 165.9004, + }); + const clips = [clip1, clip2]; + + it("stops a zoom against a neighbour that ends on a clip boundary, keeping its length", () => { + const regions = anchorRegionsWithDerivedMs( + [ + { id: "a", startMs: 64_030, endMs: 77_000, depth: 3 }, + { id: "b", startMs: 100_100, endMs: 105_900, depth: 2 }, + ], + clips, + ids(), + ); + // A (12.97 s) dropped with its start past B's start: it covers B and runs into clip 2. + const out = replacePillSpan(regions, "a", 101_160, 114_130, clips, ids()); + expect(spans(out.filter((r) => r.depth === 3))).toEqual([[87_130, 100_100, "clip_1"]]); + expect(spans(out.filter((r) => r.depth === 2))).toEqual([[100_100, 105_900, "clip_1"]]); + }); + + it("keeps a speed region whole when it is dragged into a neighbour in the same clip", () => { + // #1017: a 13.6 s 2× region dragged into a 1.5× one came out 1.6 s long. + const regions = anchorRegionsWithDerivedMs( + [ + { id: "fast", startMs: 10_000, endMs: 23_600, speed: 2 }, + { id: "slow", startMs: 40_000, endMs: 50_000, speed: 1.5 }, + ], + clips, + ids(), + ); + const out = replacePillSpan(regions, "fast", 38_400, 52_000, clips, ids()); + expect(spans(out.filter((r) => r.speed === 2))).toEqual([[26_400, 40_000, "clip_1"]]); + expect(spans(out.filter((r) => r.speed === 1.5))).toEqual([[40_000, 50_000, "clip_1"]]); + }); + + it("keeps a zoom whole when it is dragged left into a neighbour in the same clip", () => { + // #1017: a 27.1 s zoom came out 19.3 s long. + const regions = anchorRegionsWithDerivedMs( + [ + { id: "z", startMs: 60_000, endMs: 87_100, depth: 4 }, + { id: "n", startMs: 20_000, endMs: 40_000, depth: 2 }, + ], + clips, + ids(), + ); + const out = replacePillSpan(regions, "z", 32_200, 59_300, clips, ids()); + expect(spans(out.filter((r) => r.depth === 4))).toEqual([[40_000, 67_100, "clip_1"]]); + expect(spans(out.filter((r) => r.depth === 2))).toEqual([[20_000, 40_000, "clip_1"]]); + }); + + it("stores no zero-length fragment for an edge rounded onto a clip boundary", () => { + // Snapped onto the 105.9004 s junction, a start is stored as 105.900 s: 0.4 ms short of + // clip 2. That 0.4 ms used to become a fragment of its own on clip 1, zero-length once + // rounded back to ms, and it took the region's id away from the fragment that plays. + const out = anchorRegionsWithDerivedMs( + [{ id: "z", startMs: 105_900, endMs: 114_130, depth: 3 }], + clips, + ids(), + ); + expect(out.map((r) => r.id)).toEqual(["z"]); + expect(spans(out)).toEqual([[105_900, 114_130, "clip_2"]]); + }); + + it("keeps a half-millisecond piece that rounds to a whole millisecond", () => { + // A boundary at 1.0005 s leaves 0.5 ms of a 1000–1002 ms region on the first clip. Stored, + // that piece is 1000–1001 ms: dropping it would bring the region back a millisecond short. + const halfMsClips = [ + clip({ + id: "clip_1", + assetId: "rec", + sourceStartSec: 0, + sourceEndSec: 1.0005, + timelineStartSec: 0, + timelineEndSec: 1.0005, + }), + clip({ + id: "clip_2", + assetId: "rec_2", + sourceStartSec: 0, + sourceEndSec: 30, + timelineStartSec: 1.0005, + timelineEndSec: 31.0005, + }), + ]; + const out = anchorRegionsWithDerivedMs( + [{ id: "z", startMs: 1_000, endMs: 1_002, depth: 3 }], + halfMsClips, + ids(), + ); + expect(out[0].id).toBe("z"); + expect(Math.min(...out.map((r) => r.startMs))).toBe(1_000); + expect(Math.max(...out.map((r) => r.endMs))).toBe(1_002); + }); +}); + describe("legacy groupId must never affect identity (regression: test 1)", () => { it("merges two independently authored regions that carry DIFFERENT legacy groupIds", () => { // Reproduces the in-app failure: both regions were migrated to v5 and kept a diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index b98601a03..8e3dcbcf7 100644 --- a/src/lib/ai-edition/timeline/timelineMap.ts +++ b/src/lib/ai-edition/timeline/timelineMap.ts @@ -37,6 +37,20 @@ export type ClipAnchored = Omit & { sourceEndSec: number; }; +/** Region edges are stored in whole ms and clip boundaries are not, so an edge put on a + * boundary misses it by up to half a ms. A piece past a boundary whose whole-ms span, as + * `anchorRegionsWithDerivedMs` stores it, is empty is that miss, not content: stored, it was + * a zero-length fragment holding the region's id (#1008). One that rounds to a millisecond + * stays, or the region would come back a millisecond short. */ +function roundsToNothing( + clip: AxcutClip | undefined, + fragment: { localStartSec: number; localEndSec: number }, +): boolean { + if (!clip) return true; + const ms = (localSec: number) => Math.round((clip.timelineStartSec + localSec) * 1000); + return ms(fragment.localEndSec) <= ms(fragment.localStartSec); +} + /** * Migrate RAW-virtual-ms regions (the v4 document-level storage) to clip-anchored * source-time fragments (the v5 storage). Each region is ventilated across the RAW @@ -45,8 +59,8 @@ export type ClipAnchored = Omit & { * merge rule, since they share properties — no bookkeeping). Each fragment * gets its own unique `id` (first keeps the original region id; extras from * `makeId`). A zero-length / off-timeline region covers no clip and is dropped (it - * could never play). Pure; reused by the v4→v5 schema migration and by re-anchoring - * after a raw edit. + * could never play), and so is a piece that rounds to no whole ms ({@link roundsToNothing}). Pure; + * reused by the v4→v5 schema migration and by re-anchoring after a raw edit. */ export function anchorRawRegionsToClips( regions: T[], @@ -56,7 +70,11 @@ export function anchorRawRegionsToClips [c.id, c])); const out: ClipAnchored[] = []; for (const region of regions) { - const frags = ventilateSpanAcrossClips(region.startMs / 1000, region.endMs / 1000, rawClips); + const frags = ventilateSpanAcrossClips( + region.startMs / 1000, + region.endMs / 1000, + rawClips, + ).filter((f) => !roundsToNothing(byId.get(f.clipId), f)); frags.forEach((f, i) => { const clip = byId.get(f.clipId); if (!clip) return; @@ -215,25 +233,46 @@ export function coalesceByIdentity(spans: IdentifiedSpan[], epsilonSec = 0.001): /** * Rule 2 — clamp an edited span so it cannot overlap a same-kind span of a DIFFERENT - * identity. Blocking neighbours act as walls: the edited span stops at the nearest - * blocking edge on each side and the neighbour is never modified or displaced, so an - * edit can never cascade into regions the user did not touch. Same-identity spans are - * not obstacles — overlapping them is harmless, they simply merge (rule 1). + * identity. Blocking neighbours act as walls, and `from`, the span before the edit, decides + * which side of a wall the span stays on: the edit backs off toward it until it clears every + * wall. The edge that ran into a wall stops on it, and the other edge backs off with it only + * if it moved the same way. So a move keeps its length and stops flush against the neighbour + * it ran into, while a resize, or a stretch both ways, keeps every edge that hit nothing. A + * drop that overlaps nothing stands, past a neighbour or not. Clamping each edge on its own, + * with the side guessed from the desired span, cut a moved region short or threw it past the + * neighbour (#1008). The neighbour is never modified or displaced, so an edit can never + * cascade into regions the user did not touch. Same-identity spans are not obstacles — + * overlapping them is harmless, they simply merge (rule 1). Neither is a neighbour that `from` + * already overlaps: an add does not clamp, and no point on the way back would clear it. */ export function clampSpanAgainstNeighbours( desired: { start: number; end: number }, identity: string, others: IdentifiedSpan[], + from: { start: number; end: number }, ): { start: number; end: number } { let start = Math.min(desired.start, desired.end); let end = Math.max(desired.start, desired.end); - for (const other of [...others].sort((a, b) => a.start - b.start)) { - if (other.identity === identity) continue; - if (other.end <= start || other.start >= end) continue; // no overlap - if (other.start <= start) start = Math.max(start, other.end); - else end = Math.min(end, other.start); + const walls = others.filter( + (o) => o.identity !== identity && (o.end <= from.start || o.start >= from.end), + ); + for (;;) { + const wall = walls.find((o) => o.start < end && o.end > start); + if (!wall) return { start, end }; + // Backing off only moves the span toward `from`, clear of every wall, so a wall is met + // once at most. + walls.splice(walls.indexOf(wall), 1); + if (wall.start >= from.end) { + // The end ran into it: `t` is how far along its way the end touches it. + const t = (wall.start - from.end) / (end - from.end); + if (start > from.start) start = from.start + t * (start - from.start); + end = wall.start; + } else { + const t = (from.start - wall.end) / (from.start - start); + if (end < from.end) end = from.end + t * (end - from.end); + start = wall.end; + } } - return { start, end: Math.max(start, end) }; } /** A pill as the ruler draws it: a run of same-identity regions that touch. */ @@ -308,7 +347,8 @@ export function dropPillsByIds p !== pill) .map((p) => ({ id: p.ids[0], start: p.start, end: p.end, identity: p.identity })), + pill, ); const under = new Set(pill.ids); diff --git a/technical-documentation/architecture/document-model.md b/technical-documentation/architecture/document-model.md index f9cd5fd93..31bdff450 100644 --- a/technical-documentation/architecture/document-model.md +++ b/technical-documentation/architecture/document-model.md @@ -67,7 +67,7 @@ each region is split into one fragment per covered clip, with the source-time window (`clipId`, `sourceStartSec`, `sourceEndSec`) as the source of truth and `startMs`/`endMs` re-derived as a transition cache. The upgrader reads the RAW clip layout out of `timeline.clips` and runs every region array through -`anchorRegionsWithDerivedMs` (`src/lib/ai-edition/timeline/timelineMap.ts:376`): +`anchorRegionsWithDerivedMs` (`src/lib/ai-edition/timeline/timelineMap.ts:417`): - `document.zoomRanges` - `document.annotations` diff --git a/technical-documentation/architecture/timeline-model.md b/technical-documentation/architecture/timeline-model.md index 84b31ddfa..13fd4a1a1 100644 --- a/technical-documentation/architecture/timeline-model.md +++ b/technical-documentation/architecture/timeline-model.md @@ -60,19 +60,19 @@ Every public export of | Function | What it converts | Direction | |---|---|---| -| `anchorRawRegionsToClips` (`:51`) | v4 RAW-virtual-ms region → one anchored fragment per covered clip (drops zero-length / off-timeline regions) | RAW-virtual → clip-anchored | -| `anchorRegionsWithDerivedMs` (`:376`) | Same as above but never drops user data: emits `{…fragment, startMs, endMs}` for anchored regions and passes un-anchorable regions through with their original ms | RAW-virtual → v5 stored shape | -| `anchoredToRawSpanSec` (`:92`) | One anchored fragment → its current RAW-virtual span on the ruler | clip-anchored → RAW-virtual | -| `regionIdentityKey` (`:163`) | A region → canonical identity key (properties minus position/provenance); equal keys = "same kind, same look" | region → identity string | -| `coalesceByIdentity` (`:193`) | Set of identified spans → merged runs that touch and share an identity | spans → pills | -| `clampSpanAgainstNeighbours` (`:223`) | A desired span clamped against different-identity neighbours (no cascade) | desired → clamped span | -| `coalesceRegionsForRuler` (`:251`) | Region array → ruler pills (one entry per merged run, payload carried by `member`) | regions → pills | -| `resolvePillIds` (`:275`) | Region id → every region id under its pill (recomputed, not stored) | id → ids | -| `dropPillById` / `dropPillsByIds` (`:290` / `:299`) | Delete every region under a pill (resolved from the merge rule) | regions → regions | -| `replacePillSpan` (`:316`) | Move/resize a pill: clamp against different-identity neighbours, then re-anchor to the clamped span | pill + clip layout → re-anchored fragments | -| `segmentRawSpanSec` (`:401`) | One kept playback segment → its RAW-virtual extent | segment → RAW span | -| `projectRegionsToSource` (`:558`) | Region array → source-ms entries with `clipIndex` for native (anchored path uses anchor; unanchored path falls back to RAW mapping through each segment's own raw extent — never drops an un-anchorable region onto an unrelated clip). A region wholly under a trim is emitted once, marked `underTrim`, addressed by the segment the cut interrupts | RAW/anchored → source + `clipIndex` | -| `resolveNativePosition` (`:676`) | RAW-virtual playhead → `{clip, clipIndex, sourceTimeSec}` for the active native decoder + paired camera (over a trimmed-out stretch it presents the removed frames themselves, borrowing the same segment index the modifiers under that cut borrow) | RAW-virtual → source + `clipIndex` | +| `anchorRawRegionsToClips` (`:65`) | v4 RAW-virtual-ms region → one anchored fragment per covered clip (drops zero-length / off-timeline regions, and the sub-ms sliver a whole-ms edge leaves past a clip boundary) | RAW-virtual → clip-anchored | +| `anchorRegionsWithDerivedMs` (`:417`) | Same as above but never drops user data: emits `{…fragment, startMs, endMs}` for anchored regions and passes un-anchorable regions through with their original ms | RAW-virtual → v5 stored shape | +| `anchoredToRawSpanSec` (`:110`) | One anchored fragment → its current RAW-virtual span on the ruler | clip-anchored → RAW-virtual | +| `regionIdentityKey` (`:181`) | A region → canonical identity key (properties minus position/provenance); equal keys = "same kind, same look" | region → identity string | +| `coalesceByIdentity` (`:211`) | Set of identified spans → merged runs that touch and share an identity | spans → pills | +| `clampSpanAgainstNeighbours` (`:248`) | A desired span clamped against different-identity neighbours, backing off toward the span before the edit (a move keeps its length; no cascade) | desired + span before the edit → clamped span | +| `coalesceRegionsForRuler` (`:290`) | Region array → ruler pills (one entry per merged run, payload carried by `member`) | regions → pills | +| `resolvePillIds` (`:314`) | Region id → every region id under its pill (recomputed, not stored) | id → ids | +| `dropPillById` / `dropPillsByIds` (`:329` / `:338`) | Delete every region under a pill (resolved from the merge rule) | regions → regions | +| `replacePillSpan` (`:356`) | Move/resize a pill: clamp against different-identity neighbours, then re-anchor to the clamped span | pill + clip layout → re-anchored fragments | +| `segmentRawSpanSec` (`:442`) | One kept playback segment → its RAW-virtual extent | segment → RAW span | +| `projectRegionsToSource` (`:599`) | Region array → source-ms entries with `clipIndex` for native (anchored path uses anchor; unanchored path falls back to RAW mapping through each segment's own raw extent — never drops an un-anchorable region onto an unrelated clip). A region wholly under a trim is emitted once, marked `underTrim`, addressed by the segment the cut interrupts | RAW/anchored → source + `clipIndex` | +| `resolveNativePosition` (`:717`) | RAW-virtual playhead → `{clip, clipIndex, sourceTimeSec}` for the active native decoder + paired camera (over a trimmed-out stretch it presents the removed frames themselves, borrowing the same segment index the modifiers under that cut borrow) | RAW-virtual → source + `clipIndex` | The two **universal region rules** every region kind obeys are expressed once in this file rather than re-derived per kind: @@ -83,7 +83,9 @@ file rather than re-derived per kind: not where it came from. 2. **Repel** (`clampSpanAgainstNeighbours`) — two regions of the same kind with different identities may not overlap. An edit clamps to the neighbour's edge; the - neighbour never moves (no cascade). + neighbour never moves (no cascade). Which edge is decided by where the region was + before the edit, so a moved region keeps its length and stops flush against the + neighbour on the side it came from. A kind with no properties (trim, full-camera) collapses to a constant identity, so its regions always merge — the long-standing trim behaviour, now derived from the general @@ -235,6 +237,16 @@ the contract a reviewer can grade against. Each is asserted in - **Repel never cascades.** A different-identity neighbour acts as a wall — the edited span stops at its edge and the neighbour never moves. (`clampSpanAgainst Neighbours` "stops at a different-identity neighbour on the right" / "left".) +- **Repel never shortens a move.** A region dragged into a different-identity neighbour + keeps its length and stops against it on the side it came from, clip boundary or not; + it is never cut short or thrown past it (#1008). (`replacePillSpan` "stops a zoom + against a neighbour that ends on a clip boundary, keeping its length".) +- **No fragment is a rounding sliver.** Region edges are whole ms and clip boundaries are + not, so an edge placed on a junction misses it by up to half a ms. A piece whose stored + whole-ms span would be empty is never stored as a fragment of its own; one that rounds to + a millisecond stays, so the region keeps its length. (`anchorRegionsWithDerivedMs` "stores + no zero-length fragment for an edge rounded onto a clip boundary", "keeps a + half-millisecond piece that rounds to a whole millisecond".) - **Identity ignores provenance.** `id`, `clipId`, `sourceStartSec`, `reason`, `origin`, `source`, `annotationSource`, and the legacy `groupId` are *not* part of the identity key. Two regions that differ only in any of those still merge when