Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion crates/compositor/src/regions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<usize> = (0..regions.len()).collect();
let mut order: Vec<usize> = (0..regions.len()).filter(|&i| !regions[i].under_trim).collect();
order.sort_by(|&a, &b| regions[a].start_sec.partial_cmp(&regions[b].start_sec).unwrap());
let mut pairs = Vec::new();
for w in order.windows(2) {
Expand Down Expand Up @@ -628,6 +641,7 @@ mod zoom_focus_tests {
focus_y: 0.5,
focus_mode: Some("manual".into()),
rotation: None,
under_trim: false,
}
}

Expand Down Expand Up @@ -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(&regions, 1.5, None).scale, 1.0);
assert_eq!(zoom_state_at(&regions, 2.0, None).scale, 2.5);
assert_eq!(zoom_state_at(&regions, 7.9, None).scale, 2.5);
assert_eq!(zoom_state_at(&regions, 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)]
Expand Down
48 changes: 44 additions & 4 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,18 @@ pub struct SceneZoomRegion {
pub focus_mode: Option<String>,
/// "iso" | "left" | "right" | null.
pub rotation: Option<String>,
/// 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.
Expand Down Expand Up @@ -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,
Expand All @@ -517,7 +536,9 @@ impl Scene {
) -> Scene {
let belongs = |region_clip_index: Option<usize>, 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| {
Expand Down Expand Up @@ -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<_>>(),
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<_>>(),
vec!["keep"]
vec!["in-window"]
);
}
}
131 changes: 119 additions & 12 deletions src/lib/ai-edition/timeline/timelineMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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 ---
Expand Down Expand Up @@ -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",
Expand All @@ -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();
});
Expand Down
Loading
Loading