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
15 changes: 2 additions & 13 deletions src/TrackLabConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,13 @@
// Shared corner radius used by the main side panels.
export const PANEL_CORNER_RADIUS = 8;

// ── Screen layout bounds ───────────────────────────────────────────────────────
// SceneryStack's ScreenView.DEFAULT_LAYOUT_BOUNDS = Bounds2(0, 0, 1024, 618).
const LAYOUT_WIDTH = 1024;

// ── Video display dimensions ───────────────────────────────────────────────────
// The video element is always rendered at this fixed pixel size.
// Maximum display dimensions for the video element. The actual rendered size
// may be smaller to preserve the source video's aspect ratio.
// Both the OpenCV tracker and all overlay nodes depend on these values.
export const VIDEO_WIDTH = 768;
export const VIDEO_HEIGHT = 432;

// ── Video position in screen (layout) coordinates ────────────────────────────
// The video player VBox is top-anchored at y=10. The source-control row
// (~40 px) plus MAIN_CONTENT_SPACING (10 px) places the video top at ~60 px,
// so the video centre sits at approximately y=276 (keeping same top position as before).
export const VIDEO_PLAYER_Y_OFFSET = -20; // kept for reference; no longer used for positioning
export const VIDEO_CENTER_X = LAYOUT_WIDTH / 2; // 512
export const VIDEO_CENTER_Y = 276; // approximate video centre with top-anchored layout

// ── Initial calibration tool geometry ─────────────────────────────────────────
// Half-length of the default calibration segment (pixels from centre to each endpoint).
export const CALIB_HALF_LENGTH = 100;
Expand Down
58 changes: 39 additions & 19 deletions src/screen-name/model/SimModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@
*/

import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon";
import { Dimension2, Range, type Transform3, Vector2 } from "scenerystack/dot";
import { Dimension2, Matrix3, Range, type Transform3, Vector2 } from "scenerystack/dot";
import { TRACK_COLORS } from "../../TrackLabColors.js";
import {
CALIB_HALF_LENGTH,
MAX_TRACKS,
TRACK_SYMBOL_FIRST_CODE,
TRACK_SYMBOL_LAST_CODE,
VIDEO_CENTER_X,
VIDEO_CENTER_Y,
VIDEO_HEIGHT,
VIDEO_WIDTH,
} from "../../TrackLabConstants.js";
Expand All @@ -40,29 +38,36 @@ export const FRAME_RATE_RANGE = new Range(1, 120);
export const DEFAULT_PLAYBACK_RATE = 1;
export const PLAYBACK_RATE_RANGE = new Range(0.1, 4);

// ── Initial tool positions (view / pixel space) ───────────────────────────
// These default positions are computed from the shared video layout constants.
const COORD_ORIGIN_INITIAL = new Vector2(VIDEO_CENTER_X - VIDEO_WIDTH / 4, VIDEO_CENTER_Y);
const CALIB_CENTER_INITIAL = new Vector2(VIDEO_CENTER_X, VIDEO_CENTER_Y + VIDEO_HEIGHT / 4);
// ── Video-local coordinate helpers ──────────────────────────────────────────
// All tool positions are in video-local coordinates: (0,0) = top-left of the
// video element, (VIDEO_WIDTH, VIDEO_HEIGHT) = bottom-right. This ensures
// every overlay shares the same coordinate space as the video and can be
// uniformly transformed (scaled/translated) via videoTransformProperty.
const VIDEO_LOCAL_CENTER_X = VIDEO_WIDTH / 2;
const VIDEO_LOCAL_CENTER_Y = VIDEO_HEIGHT / 2;

// ── Initial tool positions (video-local coordinates) ───────────────────────
const COORD_ORIGIN_INITIAL = new Vector2(VIDEO_WIDTH / 4, VIDEO_LOCAL_CENTER_Y);
const CALIB_CENTER_INITIAL = new Vector2(VIDEO_LOCAL_CENTER_X, (VIDEO_HEIGHT * 3) / 4);
const CALIB_P1_INITIAL = CALIB_CENTER_INITIAL.plusXY(-CALIB_HALF_LENGTH, 0);
const CALIB_P2_INITIAL = CALIB_CENTER_INITIAL.plusXY(CALIB_HALF_LENGTH, 0);

// ── Initial measuring tape positions (view / pixel space) ─────────────────
const TAPE_P1_INITIAL = new Vector2(VIDEO_CENTER_X - 90, VIDEO_CENTER_Y + 100);
const TAPE_P2_INITIAL = new Vector2(VIDEO_CENTER_X + 90, VIDEO_CENTER_Y + 100);
// ── Initial measuring tape positions (video-local coordinates) ─────────────
const TAPE_P1_INITIAL = new Vector2(VIDEO_LOCAL_CENTER_X - 90, VIDEO_LOCAL_CENTER_Y + 100);
const TAPE_P2_INITIAL = new Vector2(VIDEO_LOCAL_CENTER_X + 90, VIDEO_LOCAL_CENTER_Y + 100);

// ── Initial angle tool positions (view / pixel space) ─────────────────────
const ANGLE_VERTEX_INITIAL = new Vector2(VIDEO_CENTER_X, VIDEO_CENTER_Y + 80);
const ANGLE_ARM1_INITIAL = new Vector2(VIDEO_CENTER_X + 90, VIDEO_CENTER_Y + 20);
const ANGLE_ARM2_INITIAL = new Vector2(VIDEO_CENTER_X + 90, VIDEO_CENTER_Y + 140);
// ── Initial angle tool positions (video-local coordinates) ─────────────────
const ANGLE_VERTEX_INITIAL = new Vector2(VIDEO_LOCAL_CENTER_X, VIDEO_LOCAL_CENTER_Y + 80);
const ANGLE_ARM1_INITIAL = new Vector2(VIDEO_LOCAL_CENTER_X + 90, VIDEO_LOCAL_CENTER_Y + 20);
const ANGLE_ARM2_INITIAL = new Vector2(VIDEO_LOCAL_CENTER_X + 90, VIDEO_LOCAL_CENTER_Y + 140);

// ── Bounds for clamping the coordinate-system origin ─────────────────────────
// The origin must stay within the video area so the axes are always visible.
// These are layout / pixel-space bounds, matching the view-layer video rectangle.
const COORD_ORIGIN_BOUNDS_MIN_X = VIDEO_CENTER_X - VIDEO_WIDTH / 2;
const COORD_ORIGIN_BOUNDS_MAX_X = VIDEO_CENTER_X + VIDEO_WIDTH / 2;
const COORD_ORIGIN_BOUNDS_MIN_Y = VIDEO_CENTER_Y - VIDEO_HEIGHT / 2;
const COORD_ORIGIN_BOUNDS_MAX_Y = VIDEO_CENTER_Y + VIDEO_HEIGHT / 2;
// Bounds are in video-local coordinates.
const COORD_ORIGIN_BOUNDS_MIN_X = 0;
const COORD_ORIGIN_BOUNDS_MAX_X = VIDEO_WIDTH;
const COORD_ORIGIN_BOUNDS_MIN_Y = 0;
const COORD_ORIGIN_BOUNDS_MAX_Y = VIDEO_HEIGHT;

// ── Webcam recording entry ────────────────────────────────────────────────
export type WebcamRecording = {
Expand Down Expand Up @@ -148,6 +153,19 @@ export class SimModel {
public readonly measuringTapeVisibleProperty = new BooleanProperty(false);
public readonly angleToolVisibleProperty = new BooleanProperty(false);

// ── Video display transform (translate + uniform scale) ───────────────
// Applied to the video content layer so the video and all overlays
// (tools, digitized points) can be dragged and magnified together while
// keeping the same aspect ratio.
public readonly videoScaleProperty = new NumberProperty(1, {
range: new Range(0.5, 4),
});
public readonly videoOffsetProperty = new Property<Vector2>(Vector2.ZERO);
public readonly videoTransformProperty: TReadOnlyProperty<Matrix3> = new DerivedProperty(
[this.videoScaleProperty, this.videoOffsetProperty],
(scale, offset) => Matrix3.translationFromVector(offset).timesMatrix(Matrix3.scaling(scale)),
);

// ── Measuring tape endpoint positions (view / pixel space) ────────────
public readonly tapPoint1Property = new Property<Vector2>(TAPE_P1_INITIAL.copy());
public readonly tapPoint2Property = new Property<Vector2>(TAPE_P2_INITIAL.copy());
Expand Down Expand Up @@ -478,6 +496,8 @@ export class SimModel {
this.autoTrackingProperty.reset();
this.measuringTapeVisibleProperty.reset();
this.angleToolVisibleProperty.reset();
this.videoScaleProperty.reset();
this.videoOffsetProperty.reset();
this.tapPoint1Property.reset();
this.tapPoint2Property.reset();
this.angleVertexProperty.reset();
Expand Down
4 changes: 2 additions & 2 deletions src/screen-name/view/AutoTrackerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ export class AutoTrackerNode extends Node {

// O(1) duplicate-frame check via Set (vs O(n) linear scan).
if (!this.recordedFrames.has(frame)) {
// pt is already in local (video-pixel) coordinates — the same space
// pixelToModelCoords expects, matching how DigitizingOverlayNode records points.
// Convert video-local pixel coords directly to model coords.
// The MVT operates in video-local space, matching these coordinates.
const modelPt = model.pixelToModelCoords(new Vector2(pt.x, pt.y));
model.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y);
this.recordedFrames.add(frame);
Expand Down
18 changes: 7 additions & 11 deletions src/screen-name/view/SimScreenView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,20 @@ export class SimScreenView extends ScreenView {
this.videoPlayerNode.top = this.layoutBounds.top + SCREEN_TOP_MARGIN;
this.addChild(this.videoPlayerNode);

// ── Coordinate system overlay (above video, below camera modal) ─────────
// Reads/writes model.coordOriginProperty and model.coordAngleProperty.
// ── Overlay tools (children of videoContentLayer, video-local coords) ──
// All overlay tools are added to the video content layer so they share
// the same video-local coordinate space and transform with the video.
const coordinateSystemNode = new CoordinateSystemNode(axesShownProperty, model);
this.addChild(coordinateSystemNode);
this.videoPlayerNode.videoContentLayer.addChild(coordinateSystemNode);

// ── Calibration tool overlay (above video, below camera modal) ─────────
// Reads/writes model.calibPoint1/2Property, model.calibDistanceProperty,
// and model.calibUnitProperty.
const calibrationToolNode = new CalibrationToolNode(calibrationShownProperty, this, model);
this.addChild(calibrationToolNode);
this.videoPlayerNode.videoContentLayer.addChild(calibrationToolNode);

// ── Measuring tape overlay (above video) ──────────────────────────────
const measuringTapeNode = new MeasuringTapeNode(measuringTapeShownProperty, model);
this.addChild(measuringTapeNode);
this.videoPlayerNode.videoContentLayer.addChild(measuringTapeNode);

// ── Angle tool overlay (above video) ─────────────────────────────────
const angleToolNode = new AngleToolNode(angleToolShownProperty, model);
this.addChild(angleToolNode);
this.videoPlayerNode.videoContentLayer.addChild(angleToolNode);

// ── Data table (top right, shifts left when window is wider than layoutBounds) ─
const dataTableNode = new DataTableNode(model, model.videoLoadedProperty, model.calibUnitProperty);
Expand Down
44 changes: 35 additions & 9 deletions src/screen-name/view/VideoPlayerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
*/

import { DerivedProperty } from "scenerystack/axon";
import { Dimension2 } from "scenerystack/dot";
import { DOM, Node, VBox } from "scenerystack/scenery";
import { Bounds2, Dimension2 } from "scenerystack/dot";
import { DOM, Node } from "scenerystack/scenery";
import TrackLabColors from "../../TrackLabColors.js";
import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js";
import type { SimModel } from "../model/SimModel.js";
Expand All @@ -33,6 +33,14 @@ export class VideoPlayerNode extends Node {
public readonly webcamPanel: WebcamPanel;
/** Playback controls bar; positioned by SimScreenView at the bottom of the screen. */
public readonly playbackControlsNode: PlaybackControlsNode;
/**
* Video content layer containing the video element and all overlays (auto-tracker,
* digitizing, coordinate system, calibration, measurement tools). All children
* share video-local coordinates (0,0 = top-left of video). SimScreenView adds
* additional overlay nodes as children. The videoTransformProperty is applied to
* this layer so the video and overlays can be dragged/magnified as a unit.
*/
public readonly videoContentLayer: Node;
private readonly model: SimModel;
private readonly disposeVideoPlayer: () => void;
/** Tracks the current blob URL so it can be revoked when a new one is loaded. */
Expand Down Expand Up @@ -107,8 +115,12 @@ export class VideoPlayerNode extends Node {
// ── Manual digitizing overlay ─────────────────────────────────────────
const digitizingOverlayNode = new DigitizingOverlayNode(this.videoElement, model, () => this.stepForward());

const videoLayer = new Node({
this.videoContentLayer = new Node({
children: [videoNode, autoTrackerNode, digitizingOverlayNode],
// Explicit bounds prevent overlay children (coordinate system arrows,
// calibration tool, etc.) from inflating the layer's bounds and
// disrupting the parent layout.
localBounds: new Bounds2(0, 0, VIDEO_WIDTH, VIDEO_HEIGHT),
});

// ── Play / Pause ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -172,6 +184,9 @@ export class VideoPlayerNode extends Node {
this.videoElement.width = displayW;
this.videoElement.height = displayH;
model.videoDimensionsProperty.value = new Dimension2(displayW, displayH);
// Keep content layer bounds in sync so layout doesn't shift.
this.videoContentLayer.localBounds = new Bounds2(0, 0, displayW, displayH);
this.videoSourceControlNode.centerX = displayW / 2;
this.playbackControlsNode.preferredWidth = displayW;
model.tracker.resize(displayW, displayH);
};
Expand Down Expand Up @@ -213,14 +228,24 @@ export class VideoPlayerNode extends Node {
);

// ── Layout ─────────────────────────────────────────────────────────────
const mainContent = new VBox({
children: [this.videoSourceControlNode, videoLayer],
spacing: MAIN_CONTENT_SPACING,
align: "center",
});
// Manual positioning replaces the VBox so that overlay nodes added to
// videoContentLayer (by SimScreenView) don't affect the centering of
// the source control row.
this.videoContentLayer.top = 0;
this.addChild(this.videoContentLayer);

this.videoSourceControlNode.centerX = this.videoContentLayer.width / 2;
this.videoSourceControlNode.bottom = -MAIN_CONTENT_SPACING;
this.addChild(this.videoSourceControlNode);

this.webcamPanel = this.videoSourceControlNode.webcamPanel;
this.addChild(mainContent);

// ── Apply video transform (translate + uniform scale) ────────────────
// Driven by the model so the video and all overlays move/zoom together.
const videoTransformListener = (matrix: import("scenerystack/dot").Matrix3) => {
this.videoContentLayer.matrix = matrix;
};
model.videoTransformProperty.link(videoTransformListener);

// ── Home key → rewind to start ────────────────────────────────────────
const onKeyDown = (e: KeyboardEvent) => {
Expand All @@ -239,6 +264,7 @@ export class VideoPlayerNode extends Node {
TrackLabColors.videoBackgroundColorProperty.unlink(videoBackgroundListener);
model.isPlayingProperty.unlink(isPlayingListener);
model.playbackRateProperty.unlink(playbackRateListener);
model.videoTransformProperty.unlink(videoTransformListener);
this.videoElement.removeEventListener("loadedmetadata", onLoadedMetadata);
this.videoElement.removeEventListener("loadedmetadata", onDimensionsLoaded);
this.videoElement.removeEventListener("durationchange", updateDuration);
Expand Down