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) {