Skip to content
6 changes: 6 additions & 0 deletions apps/skit/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,7 @@ async fn get_pipeline_handler(

// Fetch current node states without holding the pipeline lock.
let node_states = session.get_node_states().await.unwrap_or_default();
let node_view_data = session.get_node_view_data().await.unwrap_or_default();

// Clone pipeline (short lock hold) and add runtime state to nodes.
let mut api_pipeline = {
Expand All @@ -1936,6 +1937,11 @@ async fn get_pipeline_handler(
node.state = node_states.get(id).cloned();
}

// Attach resolved view data so clients have accurate positions on initial load.
if !node_view_data.is_empty() {
api_pipeline.view_data = Some(node_view_data);
}

info!("Fetched pipeline with states for session '{}' via HTTP", session_id);
Ok(Json(api_pipeline))
}
Expand Down
13 changes: 13 additions & 0 deletions apps/skit/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,19 @@ impl Session {
pub async fn get_node_stats(&self) -> Result<HashMap<String, NodeStats>, String> {
self.engine_handle.get_node_stats().await
}

/// Gets the current view data for all nodes in this session's pipeline.
///
/// View data contains resolved runtime state that differs from the static
/// config params (e.g., compositor resolved layout with aspect-fit adjustments).
///
/// # Errors
///
/// Returns an error if the engine handle's oneshot channel fails to receive a response,
/// which typically indicates the engine actor has stopped or panicked.
pub async fn get_node_view_data(&self) -> Result<HashMap<String, serde_json::Value>, String> {
self.engine_handle.get_node_view_data().await
}
}

/// A thread-safe manager for all active sessions.
Expand Down
6 changes: 6 additions & 0 deletions apps/skit/src/websocket_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,7 @@ async fn handle_get_pipeline(
}

let node_states = session.get_node_states().await.unwrap_or_default();
let node_view_data = session.get_node_view_data().await.unwrap_or_default();

// Clone pipeline (short lock hold) and add runtime state to nodes.
let mut api_pipeline = {
Expand All @@ -1038,6 +1039,11 @@ async fn handle_get_pipeline(
node.state = node_states.get(id).cloned();
}

// Attach resolved view data so clients have accurate positions on initial load.
if !node_view_data.is_empty() {
api_pipeline.view_data = Some(node_view_data);
}

info!(
session_id = %session_id,
node_count = api_pipeline.nodes.len(),
Expand Down
7 changes: 7 additions & 0 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//! contract exclusively uses JSON for consistency and TypeScript compatibility.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use ts_rs::TS;

// YAML pipeline format compilation
Expand Down Expand Up @@ -526,6 +527,12 @@ pub struct Pipeline {
#[ts(type = "Record<string, Node>")]
pub nodes: indexmap::IndexMap<String, Node>,
pub connections: Vec<Connection>,
/// Resolved per-node view data (e.g., compositor layout).
/// Only populated in API responses; absent from pipeline definitions.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(type = "Record<string, JsonValue> | null")]
pub view_data: Option<HashMap<String, serde_json::Value>>,
}

// Type aliases for backwards compatibility
Expand Down
4 changes: 2 additions & 2 deletions crates/api/src/yaml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ fn compile_steps(
nodes.insert(node_name, Node { kind: step.kind, params: step.params, state: None });
}

Pipeline { name, description, mode, nodes, connections }
Pipeline { name, description, mode, nodes, connections, view_data: None }
}

/// Known bidirectional node kinds that are allowed to participate in cycles.
Expand Down Expand Up @@ -409,7 +409,7 @@ fn compile_dag(
})
.collect();

Ok(Pipeline { name, description, mode, nodes, connections })
Ok(Pipeline { name, description, mode, nodes, connections, view_data: None })
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions crates/engine/benches/compositor_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ fn build_pipeline(width: u32, height: u32, fps: u32, frame_count: u32) -> stream
mode: EngineMode::OneShot,
nodes,
connections,
view_data: None,
}
}

Expand Down
134 changes: 134 additions & 0 deletions ui/src/hooks/compositorDragResize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
//
// SPDX-License-Identifier: MPL-2.0

/**
* Unit tests for the zero-delta click guard in useCompositorDragResize.
*
* Verifies that a pointer-up at the exact same position as pointer-down
* (i.e. a click-to-select with zero movement) does NOT fire the config
* change callback, preventing stale client positions from overwriting
* the server's resolved layout.
*/

import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';

import type { DragResizeDeps } from './compositorDragResize';
import { useCompositorDragResize } from './compositorDragResize';
import type { LayerState } from './compositorLayerParsers';

/** Build a minimal layer for testing. */
function makeLayer(id: string): LayerState {
return {
id,
x: 100,
y: 100,
width: 200,
height: 150,
opacity: 1,
zIndex: 0,
visible: true,
rotationDegrees: 0,
mirrorHorizontal: false,
mirrorVertical: false,
cropZoom: 1.0,
cropX: 0.5,
cropY: 0.5,
};
}

function makeDeps(overrides: Partial<DragResizeDeps> = {}): DragResizeDeps {
const layer = makeLayer('layer-1');
return {
canvasWidth: 1280,
canvasHeight: 720,
dragStateRef: { current: null },
layerRefs: { current: new Map() },
layersRef: { current: [layer] },
textOverlaysRef: { current: [] },
imageOverlaysRef: { current: [] },
setLayers: vi.fn(),
setTextOverlays: vi.fn(),
setImageOverlays: vi.fn(),
setSelectedLayerId: vi.fn(),
setIsDragging: vi.fn(),
findAnyLayer: (id: string) => {
if (id === 'layer-1') return { state: layer, kind: 'video' as const };
return null;
},
throttledConfigChange: vi.fn(),
commitOverlaysRef: { current: vi.fn() },
snapGuideRefs: { current: { vertical: null, horizontal: null } },
...overrides,
};
}

describe('useCompositorDragResize zero-delta guard', () => {
let deps: DragResizeDeps;

beforeEach(() => {
deps = makeDeps();
});

it('should NOT fire throttledConfigChange on zero-delta click (video layer)', () => {
const { result } = renderHook(() => useCompositorDragResize(deps));

// Simulate pointer-down at (500, 300)
act(() => {
result.current.handleLayerPointerDown('layer-1', {
button: 0,
clientX: 500,
clientY: 300,
stopPropagation: vi.fn(),
preventDefault: vi.fn(),
} as unknown as React.PointerEvent);
});

// Verify drag state was set
expect(deps.dragStateRef.current).not.toBeNull();

// Simulate pointer-up at the exact same position (zero delta)
act(() => {
const pointerUpEvent = new PointerEvent('pointerup', {
clientX: 500,
clientY: 300,
});
document.dispatchEvent(pointerUpEvent);
});

// throttledConfigChange must NOT have been called
expect(deps.throttledConfigChange).not.toHaveBeenCalled();

// setLayers must NOT have been called (no state update)
expect(deps.setLayers).not.toHaveBeenCalled();
});

it('should fire throttledConfigChange on actual drag (video layer)', () => {
const { result } = renderHook(() => useCompositorDragResize(deps));

// Simulate pointer-down at (500, 300)
act(() => {
result.current.handleLayerPointerDown('layer-1', {
button: 0,
clientX: 500,
clientY: 300,
stopPropagation: vi.fn(),
preventDefault: vi.fn(),
} as unknown as React.PointerEvent);
});

// Simulate pointer-up at a DIFFERENT position (non-zero delta)
act(() => {
const pointerUpEvent = new PointerEvent('pointerup', {
clientX: 520,
clientY: 310,
});
document.dispatchEvent(pointerUpEvent);
});

// throttledConfigChange SHOULD have been called
expect(deps.throttledConfigChange).toHaveBeenCalled();
});
});
85 changes: 52 additions & 33 deletions ui/src/hooks/compositorDragResize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,43 +173,62 @@ export function useCompositorDragResize(deps: DragResizeDeps) {
const updated = computeLayerFromPointer(state, e.clientX, e.clientY);
setIsDragging(false);

// Pure click-to-select (zero movement) — don't commit to server.
// Committing here would send potentially stale config-parsed positions
// for ALL layers, overwriting the server's resolved layout.
const dx = e.clientX - state.startX;
const dy = e.clientY - state.startY;
const isZeroDelta = dx === 0 && dy === 0;

if (state.layerKind === 'video') {
setLayers((prev) => prev.map((l) => (l.id === updated.id ? updated : l)));
const newLayers = layersRef.current.map((l) => (l.id === updated.id ? updated : l));
throttledConfigChange?.(newLayers);
if (!isZeroDelta) {
setLayers((prev) => prev.map((l) => (l.id === updated.id ? updated : l)));
const newLayers = layersRef.current.map((l) => (l.id === updated.id ? updated : l));
throttledConfigChange?.(newLayers);
}
} else if (state.layerKind === 'text') {
const isResize = state.type === 'resize';
const origFontSize = state.origFontSize;
setTextOverlays((prev) => {
const next = prev.map((o) => {
if (o.id !== updated.id) return o;
const patch: Partial<TextOverlayState> = {
x: updated.x,
y: updated.y,
width: updated.width,
height: updated.height,
};
if (isResize && origFontSize != null && state.origLayer.width > 0) {
patch.fontSize = Math.max(
8,
Math.round(origFontSize * (updated.width / state.origLayer.width))
);
}
return { ...o, ...patch };
if (!isZeroDelta) {
const isResize = state.type === 'resize';
const origFontSize = state.origFontSize;
setTextOverlays((prev) => {
const next = prev.map((o) => {
if (o.id !== updated.id) return o;
const patch: Partial<TextOverlayState> = {
x: updated.x,
y: updated.y,
width: updated.width,
height: updated.height,
};
if (isResize && origFontSize != null && state.origLayer.width > 0) {
patch.fontSize = Math.max(
8,
Math.round(origFontSize * (updated.width / state.origLayer.width))
);
}
return { ...o, ...patch };
});
commitOverlaysRef.current(next, imageOverlaysRef.current);
return next;
});
commitOverlaysRef.current(next, imageOverlaysRef.current);
return next;
});
}
} else if (state.layerKind === 'image') {
setImageOverlays((prev) => {
const next = prev.map((o) =>
o.id === updated.id
? { ...o, x: updated.x, y: updated.y, width: updated.width, height: updated.height }
: o
);
commitOverlaysRef.current(textOverlaysRef.current, next);
return next;
});
if (!isZeroDelta) {
setImageOverlays((prev) => {
const next = prev.map((o) =>
o.id === updated.id
? {
...o,
x: updated.x,
y: updated.y,
width: updated.width,
height: updated.height,
}
: o
);
commitOverlaysRef.current(textOverlaysRef.current, next);
return next;
});
}
}

dragStateRef.current = null;
Expand Down
Loading