Skip to content
Merged
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
7 changes: 2 additions & 5 deletions crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2926,18 +2926,15 @@ impl Compositor {
&& g.shape_fade > 0.0
&& webcam_draw.is_some()
&& !is_cutout
&& !matches!(
g.scene_preset.as_deref(),
Some("dual-frame") | Some("vertical-stack")
))
&& g.webcam_shadow > 0.0)
.then(|| {
let cb = self.shadow_cb(
g.w_dst,
g.w_px,
g.w_radius,
crate::frame_geometry::WEBCAM_SHADOW_SPREAD_FRAC * g.frame_min_px,
[0.0, crate::frame_geometry::WEBCAM_SHADOW_OFFSET_FRAC * g.frame_min_px],
crate::frame_geometry::WEBCAM_SHADOW_OPACITY * g.shape_fade,
crate::frame_geometry::WEBCAM_SHADOW_OPACITY * g.shape_fade * g.webcam_shadow,
);
self.make_bind(&cb, None, &dummy)
});
Expand Down
8 changes: 2 additions & 6 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2986,10 +2986,6 @@ impl Compositor {
g.w_px[0] / g.w_px[1].max(0.0001),
);
let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) };
let webcam_is_block = matches!(
g.scene_preset.as_deref(),
Some("dual-frame") | Some("vertical-stack")
);
// Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de
// l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam
// invisible en détourage, donc tant que rien n'a été segmenté on dessine la
Expand All @@ -3008,15 +3004,15 @@ impl Compositor {
// part lui aussi en détourage ci-dessous, mais sa bulle, elle, est bien peinte et
// garde donc son ombre.
let is_cutout = matches!(effect, Some((code, _)) if code == 1.0);
if cfg.shadow && !webcam_is_block && !is_cutout && g.shape_fade > 0.0 {
if cfg.shadow && g.webcam_shadow > 0.0 && !is_cutout && g.shape_fade > 0.0 {
self.draw_shadow(
enc,
g.w_dst,
g.w_px,
g.w_radius,
WEBCAM_SHADOW_SPREAD_FRAC * g.frame_min_px,
[0.0, WEBCAM_SHADOW_OFFSET_FRAC * g.frame_min_px],
WEBCAM_SHADOW_OPACITY * g.shape_fade,
WEBCAM_SHADOW_OPACITY * g.shape_fade * g.webcam_shadow,
);
}

Expand Down
9 changes: 2 additions & 7 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2379,7 +2379,6 @@ impl Compositor {
programme_time: *self.programme_time.borrow(),
});
self.footage.set(Some(g.footage_quad([self.rw(), self.rh()])));
let scene_preset = g.scene_preset.clone();
let mb_taps = g.mb_taps;
let mb_amount = g.mb_amount;
let source_t = g.source_t;
Expand Down Expand Up @@ -2813,18 +2812,14 @@ impl Compositor {
// que l'écran (`WEBCAM_SHADOW_OPACITY`, pas `shadow_scale`) — et propre au PiP : les
// blocs side-by-side / top-bottom soudent la caméra à l'écran et n'en portent aucune
// (parité `preset.shadow` web, `null` hors PiP dans `compositeLayout.ts`).
let webcam_is_block = matches!(
scene_preset.as_deref(),
Some("dual-frame") | Some("vertical-stack"),
);
// L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une
// ombre portée par un rectangle invisible se lit comme un artefact.
let is_cutout = matches!(
scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()),
Some(e) if e.shader_code() == 1.0
) && self.webcam_mask.borrow().is_some();
if cfg.shadow && !webcam_is_block && !is_cutout && shape_fade > 0.0 {
let strength = WEBCAM_SHADOW_OPACITY * shape_fade;
if cfg.shadow && g.webcam_shadow > 0.0 && !is_cutout && shape_fade > 0.0 {
let strength = WEBCAM_SHADOW_OPACITY * shape_fade * g.webcam_shadow;
self.draw_shadow(
w_dst,
w_px,
Expand Down
398 changes: 371 additions & 27 deletions crates/compositor/src/frame_geometry.rs

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions crates/compositor/src/regions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,37 @@ fn connected_pairs(
pairs
}

/// Whether the same uninterrupted zoom is still running after `since` (source time).
/// Uses the zoom sampler's envelope duration, screen clock and connected-pan graph.
/// Once the image settles, a later independent zoom must not inherit a former layout's clip.
pub fn zoom_continues_since(
regions: &[SceneZoomRegion],
since: f32,
t: f32,
clock: &ScreenClock,
) -> bool {
if regions.is_empty() || t < since { return false; }
let (since, t) = (clock.at(since), clock.at(t));
let mut spans: Vec<(f32, f32)> = regions.iter().map(|r| {
let window = if r.under_trim { 0.0 } else { zoom_transition_s(r.scale) };
(clock.at(r.start_sec as f32) - window, clock.at(r.end_sec as f32) + window)
}).collect();
spans.extend(connected_pairs(regions, clock).into_iter().map(|(_, ni, start, end)|
(start, end.max(clock.at(regions[ni].start_sec as f32)))));
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut covered_to: Option<f32> = None;
for (start, end) in spans {
match covered_to {
Some(until) if start > until => return false,
Some(until) => covered_to = Some(until.max(end)),
None if start < since && since < end => covered_to = Some(end),
None => continue,
}
if covered_to.is_some_and(|until| t < until) { return true; }
}
false
}

/// `zoom_state_in` sans speed region ni piste pour l'orbite : ce que les tests comparent.
#[cfg(test)]
pub fn zoom_state_at(
Expand Down Expand Up @@ -1703,6 +1734,53 @@ mod zoom_focus_tests {
}
}

#[test]
fn layout_zoom_continuity_stops_before_a_later_independent_zoom() {
let first = region(1.8, 0.5);
let mut later = region(1.8, 0.5);
later.start_sec = 14.0;
later.end_sec = 16.0;
let window = zoom_transition_s(first.scale);
let regions = [first, later];
let clock = ScreenClock::default();
assert!(zoom_continues_since(&regions, 4.0, 8.0 + window * 0.9, &clock));
assert!(!zoom_continues_since(&regions, 4.0, 8.0 + window, &clock));
assert!(!zoom_continues_since(&regions, 4.0, 15.0, &clock));
assert!(!zoom_continues_since(&regions, 4.0, 3.0, &clock));
assert!(!zoom_continues_since(&regions, 0.0, 4.0, &clock));
}

#[test]
fn layout_zoom_continuity_includes_the_connected_pan_and_hold() {
let mut first = region(1.25, 0.5);
first.end_sec = 3.0;
let mut next = region(1.25, 0.7);
next.start_sec = 4.49;
next.end_sec = 6.0;
let regions = [first, next];
let clock = ScreenClock::default();
// The independent envelopes leave a hole; the connected pan fills it.
let middle = 3.745;
assert_eq!(zoom_region_strength(&regions[0], middle, &clock), 0.0);
assert_eq!(zoom_region_strength(&regions[1], middle, &clock), 0.0);
assert!(zoom_state_at(&regions, middle, None).strength > 0.0);
assert!(zoom_continues_since(&regions, 2.5, middle, &clock));
assert!(zoom_continues_since(&regions, 2.5, 6.2, &clock));
}

#[test]
fn layout_zoom_continuity_uses_the_same_speed_clock_and_trim_gate() {
let zoom = region(1.8, 0.5);
let window = zoom_transition_s(zoom.scale);
let speeds = [SceneSpeedRegion { start_sec: 0.0, end_sec: 30.0, speed: 2.0, clip_index: Some(0) }];
let clock = ScreenClock::new(&speeds, 0);
assert!(zoom_continues_since(&[zoom.clone()], 4.0, 8.0 + window * 1.5, &clock));
assert!(!zoom_continues_since(&[zoom.clone()], 4.0, 8.0 + window * 2.0 + 0.001, &clock));
let trimmed = SceneZoomRegion { under_trim: true, ..zoom };
assert!(zoom_continues_since(&[trimmed.clone()], 4.0, 7.99, &clock));
assert!(!zoom_continues_since(&[trimmed], 4.0, 8.01, &clock));
}

/// Le focus automatique ne « perd » jamais la piste : hors de ses bornes, il tient la
/// dernière (ou la première) position suivie au lieu de retomber sur le point statique de la
/// région. Sans ça, le plan net sauterait d'un bout de l'écran à l'autre à la fin de la
Expand Down
96 changes: 96 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,95 @@ pub struct ResolvedClipLayout {
pub screen_cover: bool,
}

/// One source-time fragment of the layout partition, resolved by the same TS geometry as
/// the default per-clip layout. Boundaries survive trims without restarting transitions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneLayoutRegion {
pub clip_index: usize,
pub start_sec: f32,
pub end_sec: f32,
pub preset: String,
pub layout: ResolvedClipLayout,
pub webcam_reactive_zoom: bool,
#[serde(default)]
pub from_layout: Option<ResolvedClipLayout>,
#[serde(default)]
pub from_preset: Option<String>,
#[serde(default)]
pub from_webcam_reactive_zoom: Option<bool>,
/// Last contained-layout exit in source time. A zoom crossing it keeps its viewport.
#[serde(default)]
pub keep_screen_clip_from_sec: Option<f32>,
pub transition_start_sec: f32,
pub transition_sec: f32,
}

pub struct SampledLayout<'a> {
pub preset: &'a str,
pub layout: ResolvedClipLayout,
pub from_layout: Option<&'a ResolvedClipLayout>,
pub hard_cut: bool,
pub progress: f32,
pub screen_cover_mix: f32,
pub webcam_reactive_zoom: f32,
pub webcam_shadow: f32,
pub keep_screen_clip_from_sec: Option<f32>,
}

impl SceneLayoutRegion {
pub fn sample(&self, t: f32) -> SampledLayout<'_> {
let linear = if self.from_layout.is_some() && self.transition_sec > 0.0 {
((t - self.transition_start_sec) / self.transition_sec).clamp(0.0, 1.0)
} else { 1.0 };
let progress = linear * linear * (3.0 - 2.0 * linear);
let mix = |a: f32, b: f32| if progress <= 0.0 { a } else if progress >= 1.0 { b }
else { a + (b - a) * progress };
let enabled = |on: bool| on as u8 as f32;
let shadow = |preset: &str| if preset == "picture-in-picture" { 1.0 } else { 0.0 };
let mut layout = self.layout.clone();
let mut screen_cover_mix = enabled(layout.screen_cover);
let mut reactive = enabled(self.webcam_reactive_zoom);
let mut webcam_shadow = shadow(&self.preset);
if let Some(from) = self.from_layout.as_ref() {
screen_cover_mix = mix(enabled(from.screen_cover), screen_cover_mix);
reactive = mix(enabled(self.from_webcam_reactive_zoom.unwrap_or(self.webcam_reactive_zoom)), reactive);
webcam_shadow = mix(shadow(self.from_preset.as_deref().unwrap_or(&self.preset)), webcam_shadow);
if linear < 1.0 {
let rect = |a: SceneRect, b: SceneRect| SceneRect {
x: mix(a.x, b.x), y: mix(a.y, b.y),
width: mix(a.width, b.width), height: mix(a.height, b.height),
};
let collapsed = |r: SceneRect| SceneRect { x: r.x + r.width / 2.0,
y: r.y + r.height / 2.0, width: 0.0, height: 0.0 };
layout.screen_rect = rect(from.screen_rect, layout.screen_rect);
layout.webcam_rect = match (from.webcam_rect, layout.webcam_rect) {
(Some(a), Some(b)) => Some(rect(a, b)),
(None, Some(b)) => Some(rect(collapsed(b), b)),
(Some(a), None) => Some(rect(a, collapsed(a))),
_ => None,
};
// Keep the crop policy through the morph. Its mask is blended separately;
// switching the flag at the boundary exposes the entire zoomed footage.
layout.screen_cover = from.screen_cover || layout.screen_cover;
// A missing camera is a scaled-down endpoint of the visible one, not a
// square mask. Keep its relative roundness while its rectangle collapses.
layout.webcam_radius_frac = match (from.webcam_rect, self.layout.webcam_rect) {
(Some(_), None) => from.webcam_radius_frac,
(None, Some(_)) => self.layout.webcam_radius_frac,
_ => Some(mix(from.webcam_radius_frac.unwrap_or(0.0),
layout.webcam_radius_frac.unwrap_or(0.0))),
};
layout.webcam_shape = Some("rectangle".to_owned());
}
}
SampledLayout { preset: &self.preset, layout, from_layout: self.from_layout.as_ref(),
hard_cut: self.from_layout.is_none() || self.transition_sec <= 0.0,
keep_screen_clip_from_sec: self.keep_screen_clip_from_sec,
progress, screen_cover_mix, webcam_reactive_zoom: reactive, webcam_shadow }
}
}

/// Rect normalisé 0..1 du cadre de sortie : x, y en haut-gauche ; width, height.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -847,6 +936,8 @@ pub struct Scene {
/// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants.
#[serde(default)]
pub camera_fullscreen_regions: Vec<SceneCameraFullscreenRegion>,
#[serde(default)]
pub layout_regions: Vec<SceneLayoutRegion>,
pub cursor: SceneCursor,
/// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible.
#[serde(default)]
Expand Down Expand Up @@ -875,6 +966,10 @@ pub struct Scene {
}

impl Scene {
pub fn layout_at(&self, t: f32) -> Option<SampledLayout<'_>> {
self.layout_regions.iter().find(|r| r.clip_index == self.active_clip_index
&& t >= r.start_sec && t < r.end_sec).map(|r| r.sample(t))
}
/// Parse le JSON produit par `buildSceneDescription` (TS).
pub fn from_json(json: &str) -> anyhow::Result<Scene> {
Ok(serde_json::from_str(json)?)
Expand Down Expand Up @@ -912,6 +1007,7 @@ impl Scene {
scene.camera_fullscreen_regions.retain(|region| {
belongs(region.clip_index, region.start_sec, region.end_sec)
});
scene.layout_regions.retain(|region| belongs(Some(region.clip_index), region.start_sec as f64, region.end_sec as f64));
scene.annotations.retain(|annotation| {
belongs(annotation.clip_index, annotation.start_sec, annotation.end_sec)
});
Expand Down
Loading
Loading