@@ -26,7 +26,11 @@ import {
2626import { ComboBox , type ComboBoxItem , Slider } from "scenerystack/sun" ;
2727import { Tandem } from "scenerystack/tandem" ;
2828import TrackLabColors from "../../TrackLabColors.js" ;
29- import type { SimModel } from "../model/SimModel.js" ;
29+ import {
30+ VIDEO_HEIGHT ,
31+ VIDEO_WIDTH ,
32+ type SimModel ,
33+ } from "../model/SimModel.js" ;
3034import { AutoTrackerNode } from "./AutoTrackerNode.js" ;
3135import { WebcamPanel } from "./WebcamPanel.js" ;
3236
@@ -84,8 +88,8 @@ export class VideoPlayerNode extends Node {
8488
8589 // ── HTML video element ─────────────────────────────────────────────────
8690 this . videoElement = document . createElement ( "video" ) ;
87- this . videoElement . width = 640 ;
88- this . videoElement . height = 360 ;
91+ this . videoElement . width = VIDEO_WIDTH ;
92+ this . videoElement . height = VIDEO_HEIGHT ;
8993 this . videoElement . preload = "metadata" ;
9094 this . videoElement . crossOrigin = "anonymous" ;
9195 this . videoElement . style . display = "block" ;
@@ -112,6 +116,13 @@ export class VideoPlayerNode extends Node {
112116 model . isPlayingProperty . value = false ;
113117 } ) ;
114118
119+ // Sync model time from video during playback (event-driven, not polled)
120+ this . videoElement . addEventListener ( "timeupdate" , ( ) => {
121+ if ( ! this . isScrubbing ) {
122+ model . currentTimeProperty . value = this . videoElement . currentTime ;
123+ }
124+ } ) ;
125+
115126 // ── Auto-tracking overlay ──────────────────────────────────────────────
116127 // Layered directly on top of the video element at (0,0), so its local
117128 // coordinates correspond 1:1 to video-pixel coordinates.
@@ -165,13 +176,20 @@ export class VideoPlayerNode extends Node {
165176 // ── Magnifier (zoomed view near the cursor) ─────────────────────────────
166177 const MAG_SIZE = 100 ; // Canvas diameter in pixels
167178 const MAG_ZOOM = 4 ; // Magnification factor
179+ const MAG_BORDER_WIDTH = 2 ;
180+ const MAG_CROSSHAIR_RADIUS = 8 ;
181+ const MAG_CROSSHAIR_GAP = 2 ;
182+ const MAG_CROSSHAIR_LINE_WIDTH = 1 ;
183+ const MAG_CROSSHAIR_COLOR = "rgba(255,255,255,0.8)" ;
168184
169185 const magCanvas = document . createElement ( "canvas" ) ;
170186 magCanvas . width = MAG_SIZE ;
171187 magCanvas . height = MAG_SIZE ;
188+ // borderRadius + boxShadow give a round shadow; no CSS border so the
189+ // element's layout box stays exactly MAG_SIZE × MAG_SIZE and aligns
190+ // pixel-perfectly with the Scenery cursor node.
172191 Object . assign ( magCanvas . style , {
173192 borderRadius : "50%" ,
174- border : "2px solid white" ,
175193 boxShadow : "0 2px 8px rgba(0,0,0,0.5)" ,
176194 } ) ;
177195 const magCtx = magCanvas . getContext ( "2d" ) ;
@@ -182,25 +200,67 @@ export class VideoPlayerNode extends Node {
182200 magnifierNode . visible = false ;
183201 magnifierNode . pickable = false ;
184202
185- /** Redraws the magnifier canvas showing a zoomed region of the video. */
186- const updateMagnifier = ( localX : number , localY : number ) => {
187- // drawImage uses the video's intrinsic (natural) resolution as source
188- // coordinates, but localX/localY are in display space (0–640, 0–360).
189- // Scale to intrinsic pixels so the source rectangle is centered correctly.
190- const scaleX =
191- this . videoElement . videoWidth > 0
192- ? this . videoElement . videoWidth / this . videoElement . width
193- : 1 ;
194- const scaleY =
195- this . videoElement . videoHeight > 0
196- ? this . videoElement . videoHeight / this . videoElement . height
197- : 1 ;
198-
199- // Source rectangle in intrinsic pixels, centered on the cursor position.
200- const srcW = ( MAG_SIZE / MAG_ZOOM ) * scaleX ;
201- const srcH = ( MAG_SIZE / MAG_ZOOM ) * scaleY ;
202- const sx = localX * scaleX - srcW / 2 ;
203- const sy = localY * scaleY - srcH / 2 ;
203+ /**
204+ * Computes the rendered video bounds within the display element,
205+ * accounting for letterboxing/pillarboxing when aspect ratios differ.
206+ */
207+ const getRenderedVideoBounds = ( ) => {
208+ const displayW = this . videoElement . width ;
209+ const displayH = this . videoElement . height ;
210+ const videoW = this . videoElement . videoWidth || displayW ;
211+ const videoH = this . videoElement . videoHeight || displayH ;
212+
213+ const displayAspect = displayW / displayH ;
214+ const videoAspect = videoW / videoH ;
215+
216+ let renderedW : number ;
217+ let renderedH : number ;
218+ let offsetX : number ;
219+ let offsetY : number ;
220+
221+ if ( videoAspect > displayAspect ) {
222+ // Video is wider than display: letterboxing (bars on top/bottom)
223+ renderedW = displayW ;
224+ renderedH = displayW / videoAspect ;
225+ offsetX = 0 ;
226+ offsetY = ( displayH - renderedH ) / 2 ;
227+ } else {
228+ // Video is taller than display: pillarboxing (bars on sides)
229+ renderedH = displayH ;
230+ renderedW = displayH * videoAspect ;
231+ offsetX = ( displayW - renderedW ) / 2 ;
232+ offsetY = 0 ;
233+ }
234+
235+ return { renderedW, renderedH, offsetX, offsetY, videoW, videoH } ;
236+ } ;
237+
238+ /** Redraws the magnifier canvas showing a zoomed region of the video.
239+ * @param localX - cursor X in overlay coordinates (video region to magnify)
240+ * @param localY - cursor Y in overlay coordinates (video region to magnify)
241+ * @param crosshairX - X position to draw crosshair within the canvas
242+ * @param crosshairY - Y position to draw crosshair within the canvas
243+ */
244+ const updateMagnifier = (
245+ localX : number ,
246+ localY : number ,
247+ crosshairX : number ,
248+ crosshairY : number ,
249+ ) => {
250+ const { renderedW, renderedH, offsetX, offsetY, videoW, videoH } =
251+ getRenderedVideoBounds ( ) ;
252+
253+ // Convert overlay coordinates to video intrinsic coordinates,
254+ // accounting for letterboxing/pillarboxing offset and scale.
255+ const videoX = ( ( localX - offsetX ) / renderedW ) * videoW ;
256+ const videoY = ( ( localY - offsetY ) / renderedH ) * videoH ;
257+
258+ // Source rectangle size in intrinsic pixels for the desired zoom level.
259+ // The magnifier shows (MAG_SIZE / MAG_ZOOM) display pixels worth of video.
260+ const srcW = ( MAG_SIZE / MAG_ZOOM ) * ( videoW / renderedW ) ;
261+ const srcH = ( MAG_SIZE / MAG_ZOOM ) * ( videoH / renderedH ) ;
262+ const sx = videoX - srcW / 2 ;
263+ const sy = videoY - srcH / 2 ;
204264
205265 // Clear and draw the magnified portion
206266 magCtx . clearRect ( 0 , 0 , MAG_SIZE , MAG_SIZE ) ;
@@ -226,31 +286,48 @@ export class VideoPlayerNode extends Node {
226286
227287 magCtx . restore ( ) ;
228288
229- // Draw crosshair on top (center of magnifier)
230- const center = MAG_SIZE / 2 ;
231- const crossR = 8 ;
232- const crossGap = 2 ;
233- magCtx . strokeStyle = "rgba(255,255,255,0.8)" ;
234- magCtx . lineWidth = 1 ;
289+ // White border ring drawn on-canvas so it doesn't affect the DOM
290+ // element's layout box size (avoids a CSS border offset).
291+ magCtx . strokeStyle = "white" ;
292+ magCtx . lineWidth = MAG_BORDER_WIDTH ;
293+ magCtx . beginPath ( ) ;
294+ magCtx . arc (
295+ MAG_SIZE / 2 ,
296+ MAG_SIZE / 2 ,
297+ MAG_SIZE / 2 - MAG_BORDER_WIDTH / 2 ,
298+ 0 ,
299+ Math . PI * 2 ,
300+ ) ;
301+ magCtx . stroke ( ) ;
302+
303+ // Draw crosshair at the cursor position (aligns with the cursor on screen)
304+ magCtx . strokeStyle = MAG_CROSSHAIR_COLOR ;
305+ magCtx . lineWidth = MAG_CROSSHAIR_LINE_WIDTH ;
235306 magCtx . beginPath ( ) ;
236307 // Horizontal segments
237- magCtx . moveTo ( center - crossR , center ) ;
238- magCtx . lineTo ( center - crossGap , center ) ;
239- magCtx . moveTo ( center + crossGap , center ) ;
240- magCtx . lineTo ( center + crossR , center ) ;
308+ magCtx . moveTo ( crosshairX - MAG_CROSSHAIR_RADIUS , crosshairY ) ;
309+ magCtx . lineTo ( crosshairX - MAG_CROSSHAIR_GAP , crosshairY ) ;
310+ magCtx . moveTo ( crosshairX + MAG_CROSSHAIR_GAP , crosshairY ) ;
311+ magCtx . lineTo ( crosshairX + MAG_CROSSHAIR_RADIUS , crosshairY ) ;
241312 // Vertical segments
242- magCtx . moveTo ( center , center - crossR ) ;
243- magCtx . lineTo ( center , center - crossGap ) ;
244- magCtx . moveTo ( center , center + crossGap ) ;
245- magCtx . lineTo ( center , center + crossR ) ;
313+ magCtx . moveTo ( crosshairX , crosshairY - MAG_CROSSHAIR_RADIUS ) ;
314+ magCtx . lineTo ( crosshairX , crosshairY - MAG_CROSSHAIR_GAP ) ;
315+ magCtx . moveTo ( crosshairX , crosshairY + MAG_CROSSHAIR_GAP ) ;
316+ magCtx . lineTo ( crosshairX , crosshairY + MAG_CROSSHAIR_RADIUS ) ;
246317 magCtx . stroke ( ) ;
247318 } ;
248319
249- const digitizingOverlay = new Rectangle ( 0 , 0 , 640 , 360 , {
250- fill : "transparent" ,
251- cursor : "none" , // system cursor hidden; cursorNode takes its place
252- visible : false ,
253- } ) ;
320+ const digitizingOverlay = new Rectangle (
321+ 0 ,
322+ 0 ,
323+ VIDEO_WIDTH ,
324+ VIDEO_HEIGHT ,
325+ {
326+ fill : "transparent" ,
327+ cursor : "none" , // system cursor hidden; cursorNode takes its place
328+ visible : false ,
329+ } ,
330+ ) ;
254331 digitizingOverlay . addChild ( cursorNode ) ;
255332 digitizingOverlay . addChild ( magnifierNode ) ;
256333
@@ -266,17 +343,20 @@ export class VideoPlayerNode extends Node {
266343 // Centre the magnifier on the cursor, clamped to stay within the overlay
267344 const magX = Math . max (
268345 0 ,
269- Math . min ( localPt . x - MAG_SIZE / 2 , 640 - MAG_SIZE ) ,
346+ Math . min ( localPt . x - MAG_SIZE / 2 , VIDEO_WIDTH - MAG_SIZE ) ,
270347 ) ;
271348 const magY = Math . max (
272349 0 ,
273- Math . min ( localPt . y - MAG_SIZE / 2 , 360 - MAG_SIZE ) ,
350+ Math . min ( localPt . y - MAG_SIZE / 2 , VIDEO_HEIGHT - MAG_SIZE ) ,
274351 ) ;
275352 magnifierNode . x = magX ;
276353 magnifierNode . y = magY ;
277354
278355 if ( model . magnifyVideoProperty . value ) {
279- updateMagnifier ( localPt . x , localPt . y ) ;
356+ // Crosshair position within the magnifier canvas (cursor relative to magnifier)
357+ const crosshairX = localPt . x - magX ;
358+ const crosshairY = localPt . y - magY ;
359+ updateMagnifier ( localPt . x , localPt . y , crosshairX , crosshairY ) ;
280360 magnifierNode . visible = true ;
281361 } else {
282362 magnifierNode . visible = false ;
@@ -560,18 +640,6 @@ export class VideoPlayerNode extends Node {
560640 } ) ;
561641 }
562642
563- public step ( ) : void {
564- if ( ! this . isScrubbing ) {
565- const t = this . videoElement . currentTime ;
566- if (
567- Number . isFinite ( t ) &&
568- Math . abs ( this . model . currentTimeProperty . value - t ) > 0.016
569- ) {
570- this . model . currentTimeProperty . value = t ;
571- }
572- }
573- }
574-
575643 /** Pause playback and advance by exactly one frame (1/30 s). */
576644 public stepForward ( ) : void {
577645 this . model . isPlayingProperty . value = false ;
0 commit comments