From f50b13e72b10246c3c1e38ef3da371dd64f25da4 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 31 Aug 2026 22:58:39 +0200 Subject: [PATCH 1/2] fix(preview): show the modifiers under a trim when the playhead is on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trim is marked by its pill and skipped during playback, but a user can still park the playhead on it. Two things then went wrong at once, and both are about the preview only — the render still cuts. `resolveNativePosition` snapped to the next kept segment's first frame, so the ruler pointed at one moment and the preview showed another. The trim keeps its place on the raw ruler and the decoder holds the whole recording — only the kept window was narrowed — so it now presents the frame that is actually there. `projectRegionsToSource` dropped every region overlapping no kept segment, so an annotation, zoom or Full Camera lying entirely under a cut never reached the compositor. It is now emitted once, marked `underTrim`, borrowing the `clipIndex` of the segment the cut interrupts (`cutAddressingSegmentIndex` — the SAME one the playhead borrows, or `belongs()` would filter it out). The borrowed index is what keeps the old leak dead: the naive fix re-emitted the region with its raw ms and no clipIndex, and native then matched it on any clip whose source window numerically overlapped. A region whose clip has no kept segment left to name it is still dropped. `underTrim` is what keeps the render cutting. Native plays such a region dry: full strength on its own span, nothing outside, no ease-in/out window and no chaining with a neighbouring zoom. An export never composes a frame at those source times, so the entry is inert there — without the gate a zoom's ease-in (1.5 s before its start) would reach the kept frames beside the cut. Speed regions are not emitted at all: a still frame has no rate to show, and those spans drive the export's frame count. `for_clip_window` now reads as its own doc comment always claimed — clipIndex decides when present, window overlap is the fallback for payloads that lack one, not a second condition. Refs #216 --- crates/compositor/src/regions.rs | 43 +++- crates/compositor/src/scene.rs | 48 +++- .../ai-edition/timeline/timelineMap.test.ts | 131 ++++++++++- src/lib/ai-edition/timeline/timelineMap.ts | 218 +++++++++++++++--- src/native/sceneDescription.test.ts | 68 ++++++ src/native/sceneDescription.ts | 38 ++- .../architecture/timeline-model.md | 23 +- workbench/lib/oracles.ts | 5 +- 8 files changed, 512 insertions(+), 62 deletions(-) diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index 000939708..b97f8ee2a 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -175,9 +175,17 @@ fn lerp(a: f32, b: f32, t: f32) -> f32 { /// `startSec` (le zoom anticipe légèrement), plein régime pendant la région, ease-out après /// `endSec`. Les temps reçus sont les temps source échantillonnés par le pipeline, donc ces /// enveloppes restent alignées quand une speed region répète ou saute des frames. +/// +/// `under_trim` coupe les enveloppes : la région vit sous une coupe, donc pleine force sur son +/// span et rien en dehors. Sans ça son ease-in (1,5 s AVANT `start_sec`) et son ease-out +/// déborderaient sur les frames GARDÉES de part et d'autre du trim — un zoom que l'export ne +/// rendra jamais, visible dans la preview juste à côté de la coupe. Cf. `SceneZoomRegion`. fn zoom_region_strength(region: &SceneZoomRegion, t: f32) -> f32 { let start = region.start_sec as f32; let end = region.end_sec as f32; + if region.under_trim { + return if t >= start && t < end { 1.0 } else { 0.0 }; + } let zoom_in_end = start + ZOOM_IN_OVERLAP_S; let lead_in_start = zoom_in_end - ZOOM_IN_TRANSITION_WINDOW_S; let lead_out_end = end + TRANSITION_WINDOW_S; @@ -311,8 +319,13 @@ fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>) /// transition), en secondes. Indices dans `regions` (pas d'id nécessaire — contrairement au /// web qui matche par `region.id` car il travaille sur des objets isolés, ici tout vient du /// même slice donc les positions suffisent). +/// +/// Les régions `under_trim` sont exclues du chaînage, des DEUX côtés : leur contenu est coupé au +/// rendu, donc un pan lissé vers (ou depuis) l'une d'elles ferait bouger des frames gardées au +/// nom d'une région que l'export ne joue pas. Elles restent des régions dominantes indépendantes, +/// sèches sur leur propre span (cf. `zoom_region_strength`). fn connected_pairs(regions: &[SceneZoomRegion]) -> Vec<(usize, usize, f32, f32)> { - let mut order: Vec = (0..regions.len()).collect(); + let mut order: Vec = (0..regions.len()).filter(|&i| !regions[i].under_trim).collect(); order.sort_by(|&a, &b| regions[a].start_sec.partial_cmp(®ions[b].start_sec).unwrap()); let mut pairs = Vec::new(); for w in order.windows(2) { @@ -628,6 +641,7 @@ mod zoom_focus_tests { focus_y: 0.5, focus_mode: Some("manual".into()), rotation: None, + under_trim: false, } } @@ -678,6 +692,33 @@ mod zoom_focus_tests { assert_eq!(state.scale, 1.0); assert_eq!(state.focus, [0.5, 0.5]); } + + /// Une région sous un trim est jouée SÈCHE : pleine échelle sur son span, identité juste + /// avant et juste après. `region()` couvre [2,8] et son ease-in normal démarre 1,5 s avant + /// `start_sec` — c'est exactement ce débordement qui atteindrait les frames GARDÉES autour + /// de la coupe et ferait diverger la preview de l'export. Cf. issue #216. + #[test] + fn a_region_under_a_trim_has_no_transition_window() { + let mut r = region(2.5, 0.5); + r.under_trim = true; + let regions = [r]; + assert_eq!(zoom_state_at(®ions, 1.5, None).scale, 1.0); + assert_eq!(zoom_state_at(®ions, 2.0, None).scale, 2.5); + assert_eq!(zoom_state_at(®ions, 7.9, None).scale, 2.5); + assert_eq!(zoom_state_at(®ions, 8.0, None).scale, 1.0); + } + + /// Et elle ne se chaîne pas avec sa voisine gardée : un pan lissé vers une région que + /// l'export ne joue pas ferait bouger des frames qui, elles, sont rendues. + #[test] + fn a_region_under_a_trim_is_not_chained_with_its_neighbour() { + let mut cut = region(3.0, 0.5); + cut.under_trim = true; + cut.start_sec = 9.0; + cut.end_sec = 10.0; + // Sans le filtre, l'écart de 1 s < CHAINED_ZOOM_PAN_GAP_S apparierait [2,8] et [9,10]. + assert!(connected_pairs(&[region(2.0, 0.5), cut]).is_empty()); + } } #[cfg(test)] diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index b6fb420c0..ff300d475 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -331,6 +331,18 @@ pub struct SceneZoomRegion { pub focus_mode: Option, /// "iso" | "left" | "right" | null. pub rotation: Option, + /// La région entière tombe sur une portion qu'un trim retire. Ses temps sont donc HORS de + /// la fenêtre source de `clip_index`, qui n'est là que pour l'adresser (le segment que la + /// coupe interrompt, cf. `cutAddressingSegmentIndex` côté TS). + /// + /// Conséquence de rendu : la région est jouée SÈCHE, pleine force sur `[start_sec, end_sec)` + /// et rien en dehors — ni fenêtre d'ease-in/ease-out, ni chaînage avec une région voisine. + /// C'est ce qui garde la coupe : un export ne compose jamais de frame à ces temps source, + /// alors qu'une enveloppe de transition, elle, déborderait sur les frames gardées d'à côté. + /// L'utilisateur qui pose la tête de lecture sur le trim voit l'effet ; le rendu, non. + /// `#[serde(default)]` : absent de tout payload sans trim sous un modificateur (issue #216). + #[serde(default)] + pub under_trim: bool, } /// Une zone de vitesse portée par le temps source d'un clip. @@ -509,6 +521,13 @@ impl Scene { /// Copie de scène limitée aux régions du clip actif. `clipIndex` est l'identité fiable /// lorsque plusieurs clips réutilisent les mêmes temps source ; son absence retombe sur le /// chevauchement avec la fenêtre source pour accepter les anciens payloads. + /// + /// Les deux tests étaient jusqu'ici cumulés, ce que la phrase ci-dessus ne dit pas : le + /// chevauchement est le REPLI, pas une seconde condition. La différence n'apparaît que pour + /// une région hors fenêtre, et une seule l'est — celle qui vit sous un trim (`under_trim`, + /// cf. `SceneZoomRegion`). L'app en émet une par modificateur entièrement coupé, adressée au + /// segment que la coupe interrompt, pour que la tête de lecture posée sur le trim montre ce + /// qu'il y a dessous. Exiger le chevauchement l'aurait filtrée ici même. pub(crate) fn for_clip_window( &self, clip_index: usize, @@ -517,7 +536,9 @@ impl Scene { ) -> Scene { let belongs = |region_clip_index: Option, start_sec: f64, end_sec: f64| { let overlaps_window = end_sec > source_start_sec && start_sec < source_end_sec; - overlaps_window && region_clip_index.map(|i| i == clip_index).unwrap_or(true) + region_clip_index + .map(|i| i == clip_index) + .unwrap_or(overlaps_window) }; let mut scene = self.clone(); scene.zoom_regions.retain(|region| { @@ -780,17 +801,36 @@ mod annotation_tests { #[test] fn for_clip_window_keeps_only_the_annotations_of_the_composed_clip() { - // Même règle que les zoom/speed/camera regions : bon clip ET recouvrement de la fenêtre. + // Même règle que les zoom/speed/camera regions : `clipIndex` décide seul quand il est là. + // `under-trim` porte des temps hors fenêtre EXPRÈS (il vit sous une coupe) et doit donc + // survivre : le dessin est ensuite borné par `startSec`/`endSec`, jamais atteints par un + // export. Cf. issue #216. let json = scene_json( r##"[{"id":"keep","clipIndex":0,"startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}, {"id":"other-clip","clipIndex":1,"startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}, - {"id":"out-of-window","clipIndex":0,"startSec":50.0,"endSec":51.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}]"##, + {"id":"under-trim","clipIndex":0,"underTrim":true,"startSec":50.0,"endSec":51.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}]"##, + ); + let scene = Scene::from_json(&json).expect("parse"); + let filtered = scene.for_clip_window(0, 0.0, 10.0); + assert_eq!( + filtered.annotations.iter().map(|a| a.id.as_str()).collect::>(), + vec!["keep", "under-trim"] + ); + } + + #[test] + fn for_clip_window_still_falls_back_to_window_overlap_without_a_clip_index() { + // Vieux payload : rien ne dit à quel clip la région appartient, le chevauchement de + // fenêtre reste la seule réponse disponible. C'est le REPLI, pas une seconde condition. + let json = scene_json( + r##"[{"id":"in-window","startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}, + {"id":"out-of-window","startSec":50.0,"endSec":51.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}]"##, ); let scene = Scene::from_json(&json).expect("parse"); let filtered = scene.for_clip_window(0, 0.0, 10.0); assert_eq!( filtered.annotations.iter().map(|a| a.id.as_str()).collect::>(), - vec!["keep"] + vec!["in-window"] ); } } diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index bf2a0c128..ea3f6c61b 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -121,14 +121,15 @@ describe("projectRegionsToSource", () => { ]); }); - it("drops a region a trim removes entirely rather than leaking it onto a later clip", () => { + it("keeps a fully-trimmed region on its own clip instead of leaking it onto a later one", () => { // The reported bug: an effect fully UNDER a trim fired later instead of being ignored. // Two clips of DIFFERENT assets whose source windows overlap numerically (c1: asset a // [0,10] @ raw[0,10]; c2: asset b [0,10] @ raw[10,20]). A zoom anchored to c1 at source // [3,5] is then fully trimmed away on c1 (trim removes a[2,8]; c2's asset b is - // untouched). It must VANISH — not reappear during c2, whose source window [0,10] - // numerically contains [3,5]. A clipIndex-less passthrough used to re-emit it with raw - // coords, and native's `belongs()` then matched it on c2 (any overlapping clip). + // untouched). It is kept so the playhead can be parked on the cut (issue #216), but it + // must stay ADDRESSED TO c1 — the clipIndex-less passthrough that used to re-emit it + // with raw coords let native's `belongs()` match it on c2, whose source window [0,10] + // numerically contains [3,5]. const c1 = clip({ id: "c1", assetId: "a", @@ -146,14 +147,42 @@ describe("projectRegionsToSource", () => { timelineEndSec: 20, }); const segments = resolvePlaybackSegments([c1, c2], [trim("a", 2, 8)]); + // segments: c1[0,2] (0), c1[8,10] (1), c2[0,10] (2). + const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 }; + expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([ + { ...anchored, startMs: 3000, endMs: 5000, clipIndex: 0, underTrim: true }, + ]); + }); + + it("drops a fully-trimmed region whose clip has no kept segment left to address it", () => { + // Nothing of c1 survives, so there is no index that names it. Emitting one anyway is + // exactly the leak above: it would land on c2. + const c1 = clip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + }); + const c2 = clip({ + id: "c2", + assetId: "b", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 10, + timelineEndSec: 20, + }); + const segments = resolvePlaybackSegments([c1, c2], [trim("a", 0, 10)]); const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 }; expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([]); }); - it("drops an unanchored region that a trim removes entirely", () => { + it("keeps an unanchored region a trim removes entirely, mapped through its raw clip", () => { // The same class for an un-migrated (v1.7-imported) region that has only its RAW span: - // raw[4.5,5.5] sits inside the removed stretch a[4,6], so it maps to no kept segment - // and must be dropped — not passed through with its raw coords onto the native scene. + // raw[4.5,5.5] sits inside the removed stretch a[4,6]. It maps to no kept segment, so it + // is emitted once against the segment the cut interrupts (c1[0,4], index 0) on the source + // span its raw coordinates name — never passed through with raw coords and no clipIndex. const c = clip({ id: "c1", assetId: "a", @@ -162,7 +191,52 @@ describe("projectRegionsToSource", () => { timelineEndSec: 10, }); const segments = resolvePlaybackSegments([c], [trim("a", 4, 6)]); - expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x")).toEqual([]); + expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x")).toEqual([ + { id: "r", startMs: 4500, endMs: 5500, clipIndex: 0, underTrim: true }, + ]); + }); + + it("addresses a head trim to the clip's FIRST kept segment", () => { + // The cut opens the clip, so there is no segment before it; the first one is the only + // thing that can name it. + const c = clip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineEndSec: 10, + }); + const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]); + const anchored = { ...region("r", 1, 2), clipId: "c1", sourceStartSec: 1, sourceEndSec: 2 }; + expect(projectRegionsToSource([anchored], segments, [c], () => "x")).toEqual([ + { ...anchored, startMs: 1000, endMs: 2000, clipIndex: 0, underTrim: true }, + ]); + }); + + it("addresses a fully-trimmed region and the playhead over it to the SAME segment", () => { + // The agreement `for_clip_window` (scene.rs) depends on: it only keeps a region whose + // clipIndex equals the clip being composed. Two clips of the same asset so the choice is + // not trivially unique — c1 raw[0,10], c2 raw[10,20], the trim on c1's tail. + const c1 = clip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineEndSec: 10, + }); + const c2 = clip({ + id: "c2", + assetId: "a", + sourceStartSec: 20, + sourceEndSec: 30, + timelineStartSec: 10, + timelineEndSec: 20, + }); + const segments = resolvePlaybackSegments([c1, c2], [trim("a", 6, 10)]); + const anchored = { ...region("r", 7, 9), clipId: "c1", sourceStartSec: 7, sourceEndSec: 9 }; + const [projected] = projectRegionsToSource([anchored], segments, [c1, c2], () => "x"); + // raw 8 is inside c1's removed tail; the region covering source [7,9] is that same cut. + expect(resolveNativePosition(8, segments, [c1, c2])?.clipIndex).toBe(projected.clipIndex); }); // --- anchored path: the anchor is the SSOT, `startMs`/`endMs` are not consulted --- @@ -341,7 +415,7 @@ describe("resolveNativePosition", () => { }); }); - it("snaps to the next kept segment when the playhead sits over a trimmed-out stretch", () => { + it("presents the removed frames themselves when the playhead sits over a trim", () => { const c = clip({ id: "c1", assetId: "a", @@ -350,13 +424,46 @@ describe("resolveNativePosition", () => { timelineEndSec: 10, }); const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]); - // raw 3 is inside the removed [2,4] stretch → resume at seg2's source start (4). + // raw 3 is inside the removed [2,4] stretch. The trim keeps its place on the ruler, so + // raw 3 IS source 3, and the decoder still holds it — only the kept window was narrowed. + // It used to answer seg2's first frame (source 4), which is not the frame the ruler + // points at, and would incrust any modifier under the cut on someone else's image (#216). + // The segment it borrows is the one the cut interrupts (seg1), so a modifier under that + // cut — addressed the same way — survives `belongs()`. expect(resolveNativePosition(3, segments, [c])).toMatchObject({ - clipIndex: 1, - sourceTimeSec: 4, + clipIndex: 0, + sourceTimeSec: 3, }); }); + it("presents a head trim's own frames rather than the clip's first kept one", () => { + const c = clip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineEndSec: 10, + }); + const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]); + expect(resolveNativePosition(1, segments, [c])).toMatchObject({ + clipIndex: 0, + sourceTimeSec: 1, + }); + }); + + it("clamps to the last kept segment past the end of the ruler", () => { + const c = clip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineEndSec: 10, + }); + const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]); + // No raw clip owns raw 99 — nothing to present, so the historical clamp stands. + expect(resolveNativePosition(99, segments, [c])).toMatchObject({ clipIndex: 1 }); + }); + it("returns null when there are no segments", () => { expect(resolveNativePosition(1, [], [])).toBeNull(); }); diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index b2b0edd2e..b98601a03 100644 --- a/src/lib/ai-edition/timeline/timelineMap.ts +++ b/src/lib/ai-edition/timeline/timelineMap.ts @@ -438,6 +438,84 @@ export function hasCompleteClipAnchor( ); } +/** + * RAW-virtual extent of a raw clip: the whole stretch of ruler it occupies, trims + * included. Derived from its own source length rather than read off `timelineEndSec` + * so it agrees with `segmentRawSpanSec` by construction. + */ +function rawClipSpanSec(clip: AxcutClip): { startSec: number; endSec: number } { + const lenSec = (clip.sourceEndSec ?? clip.sourceStartSec) - clip.sourceStartSec; + return { startSec: clip.timelineStartSec, endSec: clip.timelineStartSec + lenSec }; +} + +/** The raw clip whose ruler stretch contains `rawSec` (last clip's end inclusive). */ +function rawClipAt(rawSec: number, rawClips: AxcutClip[]): AxcutClip | undefined { + return rawClips.find((clip, i) => { + const { startSec, endSec } = rawClipSpanSec(clip); + const isLast = i === rawClips.length - 1; + return rawSec >= startSec && (rawSec < endSec || (isLast && rawSec <= endSec)); + }); +} + +/** + * Which KEPT segment ADDRESSES a source moment the trims removed. + * + * A cut stretch has, by construction, no segment of its own — that is what being cut + * means — yet everything the native side matches is keyed by `clipIndex` into the + * compressed stream. So a modifier lying under a trim, and the playhead parked on it, + * both have to borrow a neighbour's index. THE rule, in one place, because the two + * must pick the SAME one: `for_clip_window` (scene.rs) only keeps a region whose + * `clipIndex` equals the clip being composed, so a region addressing segment 0 while + * the playhead addresses segment 1 would silently draw nothing. + * + * The rule: the last kept segment of that clip starting at or before the moment — + * i.e. the content the cut interrupts — falling back to the clip's first segment when + * the cut precedes all of them (a trim on the clip's head). `-1` when the clip has no + * kept segment at all: nothing addresses it, and inventing an index would put the + * modifier on an unrelated clip, the exact leak `belongs()` exists to prevent. + */ +function cutAddressingSegmentIndex( + visibleSegments: AxcutClip[], + segmentRawClipIds: (string | undefined)[], + rawClipId: string, + sourceSec: number, +): number { + let index = -1; + visibleSegments.forEach((seg, i) => { + if (segmentRawClipIds[i] !== rawClipId) return; + if (index < 0 || seg.sourceStartSec <= sourceSec) index = i; + }); + return index; +} + +/** + * The SOURCE span of a region that no kept segment covers, plus the raw clip it lives on. + * `null` when no raw clip carries it (nothing to address it with — see + * `cutAddressingSegmentIndex`). + */ +function cutRegionSourceSpan( + region: T, + rawClips: AxcutClip[], +): { clipId: string; startSec: number; endSec: number } | null { + if (hasCompleteClipAnchor(region)) { + return { + clipId: region.clipId, + startSec: Math.min(region.sourceStartSec, region.sourceEndSec), + endSec: Math.max(region.sourceStartSec, region.sourceEndSec), + }; + } + // Unanchored: only a RAW span to go on. Map it through the raw clip that carries its + // start — the same clip the anchor would have named had migration been able to write one. + const lo = Math.min(region.startMs, region.endMs) / 1000; + const hi = Math.max(region.startMs, region.endMs) / 1000; + const clip = rawClipAt(lo, rawClips); + if (!clip) return null; + const span = rawClipSpanSec(clip); + const toSource = (sec: number) => + clip.sourceStartSec + (Math.min(Math.max(sec, span.startSec), span.endSec) - span.startSec); + return { clipId: clip.id, startSec: toSource(lo), endSec: toSource(hi) }; +} + /** * Resolve regions (zoom / annotation / speed / camera-fullscreen) onto the SOURCE-ms * ranges the native compositor matches against, plus the `clipIndex` into the @@ -452,9 +530,26 @@ export function hasCompleteClipAnchor( * extent, kept because migration deliberately preserves un-anchorable regions. * * In both paths a region split across two kept segments by a trim yields one entry per - * segment (fresh id for the extra copies, original id on the first), and a region - * overlapping no visible segment passes through unchanged with no `clipIndex` (native - * falls back to time-overlap) — the contract callers already relied on. + * segment (fresh id for the extra copies, original id on the first). + * + * A region overlapping no visible segment lies entirely under a trim: it is emitted ONCE, + * marked `underTrim`, on its own source span and borrowing the `clipIndex` of the kept + * segment the cut interrupts (`cutAddressingSegmentIndex`). It is deliberately NOT + * dropped: a trim is marked by its pill and skipped during playback, but a user who + * moves the playhead onto it themselves should see what is underneath rather than the + * next segment's first frame — the modifiers included (issue #216). What makes that safe + * is the borrowed `clipIndex`: the naive fix re-emitted the region with its RAW-virtual ms + * and NO clipIndex, and native's `belongs()` (scene.rs) accepts a clipIndex-less region on + * ANY clip whose source window numerically overlaps those raw numbers — so the effect + * fired later on an unrelated clip (the same wrong-clip class as the `speed_at` fix in + * regions.rs). An index pins it to one clip, and `underTrim` is what tells native to gate + * it hard on its own span so a zoom's ease-in cannot bleed into the kept frames next to + * the cut — the render still cuts, exactly as it did. + * + * A region whose clip has no kept segment at all still has nothing to address it with, and + * is dropped. With no segments AT ALL there is no layout to resolve against, so the + * historical clipIndex-less passthrough stays for that degenerate case (it reaches an empty + * native clip list and so can never be matched anyway). * * `visibleSegments` MUST be the same array (same order) serialized to `Scene.clips` so * the emitted `clipIndex` lines up with the native stream; `rawClips` is @@ -467,22 +562,25 @@ export function projectRegionsToSource< visibleSegments: AxcutClip[], rawClips: AxcutClip[], makeId: () => string, -): (T & { clipIndex?: number })[] { +): (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 segmentRawClipIds = visibleSegments.map((seg) => findRawClipForSegment(seg, rawClips)?.id); - const out: (T & { clipIndex?: number })[] = []; + const out: (T & { clipIndex?: number; underTrim?: boolean })[] = []; for (const region of regions) { let emitted = 0; - const emit = (clipIndex: number, srcStartSec: number, srcEndSec: number) => { + const emit = (clipIndex: number, srcStartSec: number, srcEndSec: number, underTrim = false) => { out.push({ ...region, id: emitted === 0 ? region.id : makeId(), startMs: Math.round(srcStartSec * 1000), endMs: Math.round(srcEndSec * 1000), clipIndex, + // Omitted rather than sent as `false`: every payload without a trim under a + // modifier stays byte-for-byte what it was. + ...(underTrim ? { underTrim: true } : {}), }); emitted += 1; }; @@ -520,17 +618,20 @@ export function projectRegionsToSource< ); }); } - // A region that WAS resolved against real segments but overlapped none of them - // sits entirely under a trim (or off the visible timeline) — its content was - // removed, so it must NOT render. Re-emitting it here is exactly what let a - // fully-trimmed effect resurface elsewhere: it would carry its RAW-virtual - // startMs/endMs and NO clipIndex, and native's `belongs()` (scene.rs) accepts a - // clipIndex-less region on ANY clip whose SOURCE window numerically overlaps those - // raw numbers — so the effect fired *later* on an unrelated clip instead of being - // ignored (the same wrong-clip class as the `speed_at` fix in regions.rs). Only - // when there are no segments AT ALL is there no layout to resolve against; keep the - // passthrough for that degenerate case (it reaches an empty native clip list and so - // can never be matched anyway). + // Overlapped no kept segment → everything it covers sits under a trim. Emit it on + // its own source span, addressed by the segment the cut interrupts, and marked so + // native gates it on that span alone (see the contract note above). + if (emitted === 0 && visibleSegments.length > 0) { + const cut = cutRegionSourceSpan(region, rawClips); + const clipIndex = cut + ? cutAddressingSegmentIndex(visibleSegments, segmentRawClipIds, cut.clipId, cut.startSec) + : -1; + if (cut && clipIndex >= 0 && cut.endSec > cut.startSec) { + emit(clipIndex, cut.startSec, cut.endSec, true); + } + } + // No segments AT ALL: no layout to resolve against, so the region passes through on + // its raw ms with no clipIndex, as it always has. if (emitted === 0 && visibleSegments.length === 0) out.push(region); } return out; @@ -558,11 +659,19 @@ const NATIVE_EOF_MARGIN_SEC = 0.033; * maps raw→source through each segment's OWN raw extent (via `rawClips`, the * un-compressed layout) so the source time is correct after a trim, and returns * the segment's `clipIndex` in the compressed stream so `setActiveClip`/`presentTime` - * address the right decoder + the right paired camera. When the raw playhead sits - * over a trimmed-out stretch, it snaps to the next kept segment (where content - * resumes) rather than the removed frames. Returns null only when there are no - * segments at all. Replaces `nativePlaybackPosition.resolveNativePlaybackPosition`, + * address the right decoder + the right paired camera. Returns null only when there are + * no segments at all. Replaces `nativePlaybackPosition.resolveNativePlaybackPosition`, * which conflated the raw and compressed layouts (correct only without trims). + * + * Over a trimmed-out stretch it presents THE FRAME THAT IS ACTUALLY THERE: the trim keeps + * its place on the raw ruler, so the playhead names a real source moment, and the decoder + * holds the whole recording — only the kept WINDOW was narrowed. It used to snap to the + * next kept segment's first frame instead, which meant the ruler said one thing and the + * preview showed another; a modifier under the cut would then have been incrusted on a + * frame that is not its own (issue #216). Playback is untouched: it never lets the playhead + * linger in a cut, and native free-runs past `source_end_sec` on its own. The segment it + * borrows for `clipIndex` is `cutAddressingSegmentIndex` — the SAME one the modifiers under + * that cut borrow, or `belongs()` would filter them out. */ export function resolveNativePosition( rawSec: number, @@ -573,25 +682,70 @@ export function resolveNativePosition( const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips)); // Segment whose RAW extent contains the playhead (last segment's end inclusive). - let index = spans.findIndex((s, i) => { + const index = spans.findIndex((s, i) => { const isLast = i === spans.length - 1; return rawSec >= s.startSec && (rawSec < s.endSec || (isLast && rawSec <= s.endSec)); }); - // Over a trimmed-out gap (or before the first kept frame): snap to the next kept - // segment; if the playhead is past all kept content, clamp into the last one. - let clampToSegmentStart = false; - if (index < 0) { - index = spans.findIndex((s) => s.startSec >= rawSec); - if (index < 0) index = visibleSegments.length - 1; - else clampToSegmentStart = true; + if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips); + + const seg = visibleSegments[index]; + 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); + return { + clip: seg, + clipIndex: index, + sourceTimeSec: Math.max(seg.sourceStartSec, Math.min(maxSource, unclamped)), + }; +} + +/** + * The playhead is on a stretch no kept segment covers — a trim, or the head of a clip a + * trim opens on. Resolve it through the RAW clip that owns that stretch (raw↔source is a + * plain shift within one clip) and borrow the addressing segment's index, so the decoder + * presents the removed frames themselves. Clamped to the RAW clip's own source window, + * not the segment's: the whole point is to leave that window. + * + * Falls back to the historical snap — next kept segment, else the last one — when the + * playhead is off every raw clip (past the end of the ruler) or its clip has no kept + * segment left to address it with. + */ +function positionUnderCut( + rawSec: number, + visibleSegments: AxcutClip[], + rawClips: AxcutClip[], +): NativePosition { + const rawClip = rawClipAt(rawSec, rawClips); + if (rawClip) { + const segmentRawClipIds = visibleSegments.map( + (seg) => findRawClipForSegment(seg, rawClips)?.id, + ); + const sourceSec = rawClip.sourceStartSec + (rawSec - rawClipSpanSec(rawClip).startSec); + const index = cutAddressingSegmentIndex( + visibleSegments, + segmentRawClipIds, + rawClip.id, + sourceSec, + ); + if (index >= 0) { + const rawSourceEnd = rawClip.sourceEndSec ?? rawClip.sourceStartSec; + const maxSource = Math.max(rawClip.sourceStartSec, rawSourceEnd - NATIVE_EOF_MARGIN_SEC); + return { + clip: visibleSegments[index], + clipIndex: index, + sourceTimeSec: Math.max(rawClip.sourceStartSec, Math.min(maxSource, sourceSec)), + }; + } } + const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips)); + const next = spans.findIndex((s) => s.startSec >= rawSec); + const index = next >= 0 ? next : visibleSegments.length - 1; const seg = visibleSegments[index]; const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec; - const unclamped = clampToSegmentStart - ? seg.sourceStartSec - : seg.sourceStartSec + (rawSec - spans[index].startSec); const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC); + const unclamped = + next >= 0 ? seg.sourceStartSec : seg.sourceStartSec + (rawSec - spans[index].startSec); return { clip: seg, clipIndex: index, diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index a98cfbe00..61f33d6ad 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -615,6 +615,74 @@ describe("buildSceneDescription.zoomRegions with an earlier trim", () => { }, ]); }); + + it("keeps a zoom that a trim removes entirely, marked underTrim", () => { + // A trim cuts at render time, but it is marked by its pill and the user can still park + // the playhead on it — so what lies underneath has to reach the compositor (issue #216). + // Trim removes source [2,8]; the zoom at raw [3,5] falls entirely inside it. It is + // emitted on its own source span, addressed to the segment the cut interrupts (seg1, + // source [0,2] → clipIndex 0), and marked so native gates it on that span alone. + const doc = makeDoc({ + assets: [makeAsset({ id: "a", originalPath: "/a.mp4" })], + clips: [ + makeClip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + }), + ], + timeline: { + trimRanges: [ + { id: "t1", assetId: "a", startSec: 2, endSec: 8, reason: "", origin: "user" }, + ], + }, + zoomRanges: [ + makeZoom({ id: "z", startMs: 3000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }), + ], + }); + expect(buildSceneDescription(doc).zoomRegions).toEqual([ + { + id: "z", + startSec: 3, + endSec: 5, + scale: ZOOM_DEPTH_SCALES[3], + focusX: 0.5, + focusY: 0.5, + focusMode: null, + rotation: null, + clipIndex: 0, + underTrim: true, + }, + ]); + }); + + it("drops a speed region a trim removes entirely rather than shipping it inert", () => { + // Same geometry, speed instead of zoom. A still frame has no rate to show, and these + // spans are what the export's frame count is derived from — nothing to gain. + const doc = makeDoc({ + assets: [makeAsset({ id: "a", originalPath: "/a.mp4" })], + clips: [ + makeClip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + }), + ], + timeline: { + trimRanges: [ + { id: "t1", assetId: "a", startSec: 2, endSec: 8, reason: "", origin: "user" }, + ], + }, + legacyEditor: { speedRegions: [{ id: "s", startMs: 3000, endMs: 5000, speed: 2 }] }, + }); + expect(buildSceneDescription(doc).speedRegions).toEqual([]); + }); }); // --- cameraFullscreenRegions ------------------------------------------------- diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index c696b62e5..376a8580a 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -72,6 +72,16 @@ export interface SceneZoomRegion { * numerically overlap (same or different asset). Unset only for a region that * `projectRegionsToSourceTime` couldn't place on any clip. */ clipIndex?: number; + /** The whole region lies on a stretch a trim removed. Its `startSec`/`endSec` are outside + * `clips[clipIndex]`'s source window on purpose, and `clipIndex` is the segment the cut + * interrupts (`cutAddressingSegmentIndex`) — the ONLY thing addressing it. + * + * Native shows it when the playhead is parked on the cut and gates it HARD on its own + * span: no ease-in / ease-out window, and no chaining with a neighbouring zoom. That gate + * is what keeps the render cut — an export never composes a frame at those source times, + * and a transition envelope would otherwise reach the kept frames beside the cut. + * Omitted (not `false`) when there is no trim under the region. See issue #216. */ + underTrim?: boolean; } /** A "Full Camera" timeline region (from `legacyEditor.cameraFullscreenRegions`). Times in seconds. */ @@ -80,6 +90,9 @@ export interface SceneCameraFullscreenRegion { endSec: number; /** See `SceneZoomRegion.clipIndex`. */ clipIndex?: number; + /** See `SceneZoomRegion.underTrim`. Full-Camera needs no extra gate — its envelope is + * already contained in `[startSec, endSec]` — so this only carries the intent. */ + underTrim?: boolean; } /** A speed region projected onto each clip's source time. The native compositor matches @@ -121,6 +134,9 @@ export interface SceneAnnotation { endSec: number; /** See `SceneZoomRegion.clipIndex`. */ clipIndex?: number; + /** See `SceneZoomRegion.underTrim`. Annotations need no extra gate — they are already + * drawn only while `startSec <= t < endSec` — so this only carries the intent. */ + underTrim?: boolean; kind: "text" | "image" | "figure" | "blur"; /** Which box `x`/`y`/`w`/`h` — and `text.fontSizeRel` — are fractions of. Absent means * `"screen"`, the historical behaviour and the only one annotations ever use. */ @@ -834,6 +850,7 @@ export function buildSceneDescription( focusMode: settings.autoFocusAll ? "auto" : (region.focusMode ?? null), rotation: region.rotationPreset ?? null, clipIndex: region.clipIndex, + ...(region.underTrim ? { underTrim: true } : {}), })), annotations: projectedAnnotations .map((region) => { @@ -848,6 +865,7 @@ export function buildSceneDescription( startSec: region.startMs / 1000, endSec: region.endMs / 1000, clipIndex: region.clipIndex, + ...(region.underTrim ? { underTrim: true as const } : {}), kind: region.type, ...(space ? { space } : {}), // Authored as percentages of the box named by `space` — the screen rect unless @@ -926,13 +944,21 @@ export function buildSceneDescription( startSec: region.startMs / 1000, endSec: region.endMs / 1000, clipIndex: region.clipIndex, + ...(region.underTrim ? { underTrim: true } : {}), })), - speedRegions: projectedSpeedRegions.map((region) => ({ - startSec: region.startMs / 1000, - endSec: region.endMs / 1000, - speed: region.speed, - clipIndex: region.clipIndex, - })), + // Speed is the one modifier with nothing to show for itself on a parked playhead: a + // still frame has no rate. So the entries under a trim are dropped here rather than + // shipped inert — `speed_at` (regions.rs) matches on clipIndex + time with no window + // to bound it, and the export's frame count is derived from these spans. Nothing to + // gain, an arithmetic to put at risk. + speedRegions: projectedSpeedRegions + .filter((region) => !region.underTrim) + .map((region) => ({ + startSec: region.startMs / 1000, + endSec: region.endMs / 1000, + speed: region.speed, + clipIndex: region.clipIndex, + })), cropByClip, output: { ...pickOutputDims(document, settings.aspectRatio), fps: null }, // Omitted rather than sent as `{mode:"none"}`: the Rust side defaults the field, and diff --git a/technical-documentation/architecture/timeline-model.md b/technical-documentation/architecture/timeline-model.md index b8f92d2b9..e2a6cbe56 100644 --- a/technical-documentation/architecture/timeline-model.md +++ b/technical-documentation/architecture/timeline-model.md @@ -71,8 +71,8 @@ Every public export of | `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` (`:452`) | 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) | RAW/anchored → source + `clipIndex` | -| `resolveNativePosition` (`:556`) | RAW-virtual playhead → `{clip, clipIndex, sourceTimeSec}` for the active native decoder + paired camera (snaps to the next kept segment when the playhead sits over a trimmed-out stretch) | RAW-virtual → source + `clipIndex` | +| `projectRegionsToSource` (`:452`) | 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` (`:556`) | 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: @@ -202,10 +202,21 @@ the contract a reviewer can grade against. Each is asserted in trim at `[2,4]`: a region authored at RAW `[6,8]` lands on source `[6,8]`, not `[8,10]`. (`resolveNativePosition` / `projectRegionsToSource` "keeps a region on its source moment despite a trim before it".) -- **A region fully under a trim is dropped, never leaked.** Two clips of *different* - assets whose source windows overlap numerically: a zoom fully trimmed away on its - own clip must not re-appear on the later clip. (`projectRegionsToSource` "drops a - region a trim removes entirely rather than leaking it onto a later clip".) +- **A region fully under a trim stays on its own clip, never leaks.** Two clips of + *different* assets whose source windows overlap numerically: a zoom fully trimmed away + on its own clip must not re-appear on the later clip. It is not dropped either — a trim + is marked by its pill and skipped during playback, but a user who moves the playhead + onto it themselves sees what is underneath, modifiers included (issue #216). So it is + emitted once with `underTrim`, borrowing the `clipIndex` of the segment the cut + interrupts; the borrowed index is what pins it to one clip. Dropped only when its clip + has no kept segment left to name it. (`projectRegionsToSource` "keeps a fully-trimmed + region on its own clip instead of leaking it onto a later one".) +- **The render still cuts.** `underTrim` tells native (`scene.rs`, `regions.rs`) to gate + the region hard on its own span: full strength inside, nothing outside, no ease-in/out + window and no chaining with a neighbouring zoom. An export never composes a frame at + those source times, so the entry is inert there — without the gate a zoom's ease-in + (1.5 s before its start) would reach the kept frames beside the cut and the preview + would show what the export does not. - **A named clip beats anything inferred from (assetId, sourceTime), and clip ORDER is never an input.** Asserted in [`virtual-preview.test.ts`](../../src/lib/ai-edition/timeline/virtual-preview.test.ts) diff --git a/workbench/lib/oracles.ts b/workbench/lib/oracles.ts index d0435736d..e25f3350d 100644 --- a/workbench/lib/oracles.ts +++ b/workbench/lib/oracles.ts @@ -92,7 +92,10 @@ export function unplayableRegions(document: AxcutDocument): Array<{ kind: string document.timeline.clips, nextId, ); - const alive = new Set(projected.map((r) => r.id)); + // `underTrim` entries are emitted so a playhead parked on the cut can show what is + // underneath (issue #216) — they are precisely the regions playback never emits, so + // they stay DEAD here. Dropping them keeps this oracle's question unchanged. + const alive = new Set(projected.filter((r) => !r.underTrim).map((r) => r.id)); for (const region of family.regions) { // A zero-length span is stored and listed but can never play either. if (!alive.has(region.id) || region.endMs <= region.startMs) { From 44131fc8589961e09f6b240b15c24e936b861aa2 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 1 Sep 2026 10:03:02 +0200 Subject: [PATCH 2/2] test(preview): cover the annotation and Full Camera under-trim payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zoom and speed branches were asserted, the other two were not — and the annotation is the case the issue actually opens on. Also asserts the field is OMITTED, not `false`, when nothing is cut under the region. Refreshes the two stale line anchors in the timeline-model table, which the new helpers above them pushed down. --- src/native/sceneDescription.test.ts | 98 +++++++++++++++++++ .../architecture/timeline-model.md | 4 +- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 61f33d6ad..4fc088e86 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -735,6 +735,36 @@ describe("buildSceneDescription.cameraFullscreenRegions", () => { { startSec: 200, endSec: 202, clipIndex: 1 }, ]); }); + + it("keeps a region a trim removes entirely, marked underTrim", () => { + // Same rule as zoom and annotations (issue #216): addressed by the segment the cut + // interrupts (seg1, source [0,2] → clipIndex 0) instead of dropped. Full Camera needs + // no extra gate native-side — its envelope is already contained in [startSec, endSec]. + const doc = makeDoc({ + assets: [makeAsset({ id: "a", originalPath: "/a.mp4" })], + clips: [ + makeClip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + }), + ], + timeline: { + trimRanges: [ + { id: "t1", assetId: "a", startSec: 2, endSec: 8, reason: "", origin: "user" }, + ], + }, + legacyEditor: { + cameraFullscreenRegions: [{ id: "cf1", startMs: 3000, endMs: 5000 }], + }, + }); + expect(buildSceneDescription(doc).cameraFullscreenRegions).toEqual([ + { startSec: 3, endSec: 5, clipIndex: 0, underTrim: true }, + ]); + }); }); // --- speedRegions ----------------------------------------------------------- @@ -1742,6 +1772,74 @@ describe("buildSceneDescription.annotations", () => { const scene = buildSceneDescription(docWithAnnotations([])); expect(scene.annotations).toEqual([]); }); + + it("keeps an annotation a trim removes entirely, marked underTrim", () => { + // The symptom issue #216 opens on. The trim removes source [2,8]; the annotation at + // raw [3,5] falls entirely inside it, so it reaches native addressed by the segment + // the cut interrupts (seg1, source [0,2] → clipIndex 0) rather than being dropped. + const doc = makeDoc({ + assets: [makeAsset({ id: "a1", originalPath: "/tmp/a1.mp4", durationSec: 10 })], + clips: [ + makeClip({ + id: "c1", + assetId: "a1", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + }), + ], + timeline: { + trimRanges: [ + { id: "t1", assetId: "a1", startSec: 2, endSec: 8, reason: "", origin: "user" }, + ], + }, + annotations: [ + { + id: "ann1", + startMs: 3000, + endMs: 5000, + type: "text", + content: "", + textContent: "Hello", + position: { x: 25, y: 50 }, + size: { width: 40, height: 10 }, + style, + zIndex: 0, + }, + ], + }); + const scene = buildSceneDescription(doc); + expect(scene.annotations).toHaveLength(1); + expect(scene.annotations[0]).toMatchObject({ + id: "ann1", + startSec: 3, + endSec: 5, + clipIndex: 0, + underTrim: true, + }); + }); + + it("omits underTrim entirely when no trim sits under the annotation", () => { + // Not `false`: a payload with nothing cut under it stays byte-for-byte what it was. + const scene = buildSceneDescription( + docWithAnnotations([ + { + id: "ann1", + startMs: 1000, + endMs: 3000, + type: "text", + content: "", + textContent: "Hello", + position: { x: 25, y: 50 }, + size: { width: 40, height: 10 }, + style, + zIndex: 0, + }, + ]), + ); + expect(scene.annotations[0]).not.toHaveProperty("underTrim"); + }); }); // --- captions -------------------------------------------------------------- diff --git a/technical-documentation/architecture/timeline-model.md b/technical-documentation/architecture/timeline-model.md index e2a6cbe56..8170beb50 100644 --- a/technical-documentation/architecture/timeline-model.md +++ b/technical-documentation/architecture/timeline-model.md @@ -71,8 +71,8 @@ Every public export of | `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` (`:452`) | 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` (`:556`) | 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` | +| `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` | The two **universal region rules** every region kind obeys are expressed once in this file rather than re-derived per kind: