diff --git a/src/TrackLabConstants.ts b/src/TrackLabConstants.ts index aaaf3a4..b251a55 100644 --- a/src/TrackLabConstants.ts +++ b/src/TrackLabConstants.ts @@ -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; diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 733aed9..b240b8d 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -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"; @@ -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 = { @@ -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.ZERO); + public readonly videoTransformProperty: TReadOnlyProperty = 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(TAPE_P1_INITIAL.copy()); public readonly tapPoint2Property = new Property(TAPE_P2_INITIAL.copy()); @@ -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(); diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index 843f5b0..ed941e7 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -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); diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index e28773e..f8b0ccf 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -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); diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index 262e66c..b499189 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -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"; @@ -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. */ @@ -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 ─────────────────────────────────────────────────────── @@ -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); }; @@ -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) => { @@ -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);