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
10 changes: 10 additions & 0 deletions crates/nodes/src/video/compositor/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,16 @@ pub struct ResolvedLayer {
pub y: i32,
pub width: u32,
pub height: u32,
/// Source frame width (from the input slot's latest frame).
/// The client uses this to compute aspect-fit locally for zero-latency
/// feedback on auto-PiP layers.
/// `None` when no frame has been received yet for this input.
#[serde(skip_serializing_if = "Option::is_none")]
pub source_width: Option<u32>,
/// Source frame height (from the input slot's latest frame).
/// `None` when no frame has been received yet for this input.
#[serde(skip_serializing_if = "Option::is_none")]
pub source_height: Option<u32>,
}

/// Server-computed geometry for a single overlay (text or image).
Expand Down
35 changes: 32 additions & 3 deletions crates/nodes/src/video/compositor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ struct InputSlot {
name: String,
rx: mpsc::Receiver<Packet>,
latest_frame: Option<VideoFrame>,
/// Last-seen source dimensions for dirty detection.
/// When the frame resolution changes at runtime (e.g. camera switch),
/// the compositor sets `layer_configs_dirty` to re-emit view data.
last_source_dims: Option<(u32, u32)>,
}

// ── Cached layer config ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -205,7 +209,15 @@ fn resolve_scene(
rect.as_ref()
.map_or((0, 0, config.width, config.height), |r| (r.x, r.y, r.width, r.height))
};
layers.push(ResolvedLayer { id: slot.name.clone(), x: lx, y: ly, width: lw, height: lh });
layers.push(ResolvedLayer {
id: slot.name.clone(),
x: lx,
y: ly,
width: lw,
height: lh,
source_width: slot.latest_frame.as_ref().map(|f| f.width),
source_height: slot.latest_frame.as_ref().map(|f| f.height),
});

configs.push(ResolvedSlotConfig {
rect,
Expand Down Expand Up @@ -481,7 +493,7 @@ impl ProcessorNode for CompositorNode {
});
for (name, rx) in pre_inputs {
tracing::info!("CompositorNode: pre-connected input '{}'", name);
slots.push(InputSlot { name, rx, latest_frame: None });
slots.push(InputSlot { name, rx, latest_frame: None, last_source_dims: None });
}

// Pin management channel (optional).
Expand Down Expand Up @@ -675,6 +687,12 @@ impl ProcessorNode for CompositorNode {
for slot in &mut slots {
if is_oneshot {
if let Ok(Packet::Video(frame)) = slot.rx.try_recv() {
// Detect source dimension changes → trigger layout re-emission.
let new_dims = (frame.width, frame.height);
if slot.last_source_dims != Some(new_dims) {
slot.last_source_dims = Some(new_dims);
layer_configs_dirty = true;
}
slot.latest_frame = Some(frame);
any_new_frame = true;
}
Expand All @@ -692,6 +710,12 @@ impl ProcessorNode for CompositorNode {
stats_tracker.discarded_n(dropped);
}
if let Some(frame) = latest {
// Detect source dimension changes → trigger layout re-emission.
let new_dims = (frame.width, frame.height);
if slot.last_source_dims != Some(new_dims) {
slot.last_source_dims = Some(new_dims);
layer_configs_dirty = true;
}
slot.latest_frame = Some(frame);
}
}
Expand Down Expand Up @@ -1040,7 +1064,12 @@ impl CompositorNode {
},
PinManagementMessage::AddedInputPin { pin, channel } => {
tracing::info!("CompositorNode: activated input pin '{}'", pin.name);
slots.push(InputSlot { name: pin.name, rx: channel, latest_frame: None });
slots.push(InputSlot {
name: pin.name,
rx: channel,
latest_frame: None,
last_source_dims: None,
});
},
PinManagementMessage::RemoveInputPin { pin_name } => {
tracing::info!("CompositorNode: removed input pin '{}'", pin_name);
Expand Down
42 changes: 41 additions & 1 deletion crates/nodes/src/video/compositor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,46 @@ fn test_fit_rect_preserving_aspect() {
assert_eq!(fitted.y, 20);
}

#[test]
fn test_resolved_layer_source_dims() {
// When a slot has a latest_frame, resolve_scene should populate source dims.
let config = CompositorConfig::default();
let (_, rx) = mpsc::channel(1);
let slots = vec![InputSlot {
name: "in_0".to_string(),
rx,
latest_frame: Some(make_rgba_frame(1920, 1080, 0, 0, 0, 255)),
last_source_dims: Some((1920, 1080)),
}];
let image_overlays: Arc<[Arc<DecodedOverlay>]> = Arc::from(vec![]);
let text_overlays: Arc<[Arc<DecodedOverlay>]> = Arc::from(vec![]);

let scene = resolve_scene(&slots, &config, &image_overlays, &text_overlays);
assert_eq!(scene.layout.layers.len(), 1);
assert_eq!(scene.layout.layers[0].source_width, Some(1920));
assert_eq!(scene.layout.layers[0].source_height, Some(1080));
}

#[test]
fn test_resolved_layer_source_dims_none_when_no_frame() {
// When a slot has no latest_frame, source dims should be None.
let config = CompositorConfig::default();
let (_, rx) = mpsc::channel(1);
let slots = vec![InputSlot {
name: "in_0".to_string(),
rx,
latest_frame: None,
last_source_dims: None,
}];
let image_overlays: Arc<[Arc<DecodedOverlay>]> = Arc::from(vec![]);
let text_overlays: Arc<[Arc<DecodedOverlay>]> = Arc::from(vec![]);

let scene = resolve_scene(&slots, &config, &image_overlays, &text_overlays);
assert_eq!(scene.layout.layers.len(), 1);
assert_eq!(scene.layout.layers[0].source_width, None);
assert_eq!(scene.layout.layers[0].source_height, None);
}

#[test]
fn test_config_validate_ok() {
let cfg = CompositorConfig::default();
Expand Down Expand Up @@ -1951,7 +1991,7 @@ fn test_composite_frame_crop_shape_circle_skip_clear() {
/// Helper: build an `InputSlot` with the given name and optional latest frame.
fn make_slot(name: &str, frame: Option<VideoFrame>) -> InputSlot {
let (_tx, rx) = mpsc::channel::<Packet>(1);
InputSlot { name: name.to_string(), rx, latest_frame: frame }
InputSlot { name: name.to_string(), rx, latest_frame: frame, last_source_dims: None }
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion ui/src/hooks/compositorAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ function layerEqual(a: LayerState, b: LayerState): boolean {
a.cropZoom === b.cropZoom &&
a.cropX === b.cropX &&
a.cropY === b.cropY &&
a.cropShape === b.cropShape
a.cropShape === b.cropShape &&
a.serverOnly === b.serverOnly
);
}

Expand Down
55 changes: 54 additions & 1 deletion ui/src/hooks/compositorLayerParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import { describe, it, expect } from 'vitest';

import type { LayerState, TextOverlayState, ImageOverlayState } from './compositorLayerParsers';
import { mergeOverlayState } from './compositorLayerParsers';
import { mergeOverlayState, fitRectPreservingAspect } from './compositorLayerParsers';

// ── Helpers ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -431,3 +431,56 @@ describe('mergeOverlayState', () => {
});
});
});

// ── fitRectPreservingAspect ─────────────────────────────────────────────────
// Test vectors mirror Rust tests in compositor/tests.rs:test_fit_rect_preserving_aspect

describe('fitRectPreservingAspect', () => {
it('4:3 source into 16:9 bounds → pillarboxed', () => {
// Scale = min(426/640, 240/480) = min(0.666, 0.5) = 0.5
// Fitted: 320×240, centred within 426×240
const result = fitRectPreservingAspect(640, 480, { x: 100, y: 50, width: 426, height: 240 });
expect(result.width).toBe(320);
expect(result.height).toBe(240);
expect(result.x).toBe(100 + Math.floor((426 - 320) / 2));
expect(result.y).toBe(50);
});

it('16:9 source into 4:3 bounds → letterboxed', () => {
// Scale = min(400/1280, 400/720) = min(0.3125, 0.555) = 0.3125
// Fitted: 400×225, centred within 400×400
const result = fitRectPreservingAspect(1280, 720, { x: 0, y: 0, width: 400, height: 400 });
expect(result.width).toBe(400);
expect(result.height).toBe(225);
expect(result.x).toBe(0);
expect(result.y).toBe(Math.floor((400 - 225) / 2));
});

it('exact match → no change', () => {
const result = fitRectPreservingAspect(640, 480, { x: 10, y: 20, width: 640, height: 480 });
expect(result.width).toBe(640);
expect(result.height).toBe(480);
expect(result.x).toBe(10);
expect(result.y).toBe(20);
});

it('zero source width → returns bounds unchanged', () => {
const bounds = { x: 5, y: 10, width: 200, height: 100 };
expect(fitRectPreservingAspect(0, 480, bounds)).toEqual(bounds);
});

it('zero source height → returns bounds unchanged', () => {
const bounds = { x: 5, y: 10, width: 200, height: 100 };
expect(fitRectPreservingAspect(640, 0, bounds)).toEqual(bounds);
});

it('zero bounds width → returns bounds unchanged', () => {
const bounds = { x: 5, y: 10, width: 0, height: 100 };
expect(fitRectPreservingAspect(640, 480, bounds)).toEqual(bounds);
});

it('zero bounds height → returns bounds unchanged', () => {
const bounds = { x: 5, y: 10, width: 200, height: 0 };
expect(fitRectPreservingAspect(640, 480, bounds)).toEqual(bounds);
});
});
37 changes: 37 additions & 0 deletions ui/src/hooks/compositorLayerParsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export interface LayerState {
cropY: number;
/** Shape clipping applied to the layer. */
cropShape: 'rect' | 'circle';
/** True for layers materialized from server view data with no config entry
* in params (auto-PiP stubs). These must NOT be serialized back to the
* server — doing so would create explicit config that disables aspect-fit. */
serverOnly?: boolean;
}

/** A text overlay stored in compositor config */
Expand Down Expand Up @@ -310,6 +314,9 @@ export function serializeImageOverlays(overlays: ImageOverlayState[]): ImageOver
export function serializeLayers(layers: LayerState[]): Record<string, LayerConfig> {
const layersMap: Record<string, LayerConfig> = {};
for (const layer of layers) {
// Skip server-only layers (auto-PiP stubs) — serializing them would
// create explicit config that disables aspect-fit on the server.
if (layer.serverOnly) continue;
layersMap[layer.id] = {
rect: serializeRect(layer),
...serializeSpatialFields(layer),
Expand Down Expand Up @@ -472,6 +479,36 @@ export function buildConfig(
};
}

// ── Aspect-fit prediction ───────────────────────────────────────────────────

/** Compute a destination rect that fits `srcW × srcH` within `bounds`
* while preserving the source aspect ratio. The fitted rect is centred
* within the bounds.
*
* Port of Rust `fit_rect_preserving_aspect` (mod.rs:249-266).
* JS `Math.round()` and Rust `.round()` produce matching results for
* non-negative values, which is all this function rounds. */
export function fitRectPreservingAspect(
srcW: number,
srcH: number,
bounds: { x: number; y: number; width: number; height: number }
): { x: number; y: number; width: number; height: number } {
if (srcW === 0 || srcH === 0 || bounds.width === 0 || bounds.height === 0) {
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
}
const scaleW = bounds.width / srcW;
const scaleH = bounds.height / srcH;
const scale = Math.min(scaleW, scaleH);
const fitW = Math.round(srcW * scale);
const fitH = Math.round(srcH * scale);
// Centre within the bounding rect.
// Use integer division (Math.floor on the half-difference) to match
// Rust's saturating_sub / 2 behaviour for non-negative values.
const offsetX = Math.floor((bounds.width - fitW) / 2);
const offsetY = Math.floor((bounds.height - fitH) / 2);
return { x: bounds.x + offsetX, y: bounds.y + offsetY, width: fitW, height: fitH };
}

// ── Snap guide detection ────────────────────────────────────────────────────

/** Which centre snap guides are currently active during a drag. */
Expand Down
Loading
Loading