diff --git a/CLAUDE.md b/CLAUDE.md index 32f3a82..6805fb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,18 @@ src/screen-name/view/ ← all UI components | `WebcamPanel.ts` | Webcam recording dialog | | `KeyboardShortcutsNode.ts` | Keyboard shortcuts | +**Graph** (`src/screen-name/graph/`) powers the configurable X-Y plot panel. Key files: + +| File | Responsibility | +|------|----------------| +| `ConfigurableGraph.ts` | Top-level graph node; owns axis selectors, chart layout, and zoom/reset buttons | +| `GraphDataManager.ts` | Accumulates data points, owns auto-scaling, tick spacing, and trail circle rendering | +| `GraphInteractionHandler.ts` | All pointer/touch/keyboard gestures — pan, pinch-zoom, axis drag, resize, header drag | +| `GraphControlsPanel.ts` | Axis property selector dropdowns (what to plot on each axis) | +| `PlottableProperty.ts` | `PlottableProperty` type — interface any quantity must satisfy to appear in the selector | + +> **Note:** `GraphInteractionHandler.ts` is the largest file in the codebase (~1,150 lines). When modifying gesture logic, read the existing `zoom()` / `pan()` / `rescaleAxes()` helpers before adding new code — many edge cases (pinch center preservation, manual-zoom locking, axis-specific gestures) are already handled. + The other source directories are less frequently modified: - `src/preferences/` — User preferences (color profile, etc.) @@ -57,3 +69,7 @@ npm run fix # fix lint + format issues together - **Model-view transform**: `SimScreenView` computes a `modelViewTransformProperty` from the coordinate system pose and calibration data. Use it to convert between real-world units and video-pixel coordinates. - **Frame rate**: `SimModel.frameRateProperty` (default 30 fps) drives `frameDurationProperty`. The user can change frame rate via `PlaybackControlsNode`; frame stepping and time display use this value. - **SceneryStack layout**: use `HBox` / `VBox` for rows and columns. Prefer `align: 'center'` and explicit `spacing` values. Do not set absolute pixel positions unless absolutely necessary. + +## Testing + +There is currently **no test suite**, and none should be added at this stage. The codebase is evolving rapidly — APIs, model structure, and UI components change frequently enough that maintaining tests would cost more than they save right now. Do not install a test framework or create test files. diff --git a/Recommendations b/Recommendations index 0ef6385..c8d01ed 100644 --- a/Recommendations +++ b/Recommendations @@ -1,103 +1,151 @@ -1. View Writing to Model (Separation of Concern Violation) -File: SimScreenView.ts:91–104 +# TrackLab — Architectural Recommendations -The view's Multilink reads positions from two view nodes (CoordinateSystemNode, CalibrationToolNode) and writes the result into model.modelViewTransformProperty. This inverts the data flow: the view is mutating the model. +**Date:** 2026-02-20 -There are two clean fixes: +The following issues were identified during architectural review. They are ordered by severity. -Option A — keep transform in the model: Move the origin/angle/points/distance into the model as properties, have the tool nodes read from and write to those model properties, and compute the transform as a DerivedProperty inside SimModel. The view never touches modelViewTransformProperty directly. +--- -Option B — keep transform in the view: Remove modelViewTransformProperty from SimModel entirely. Compute it locally in SimScreenView and pass it to any child node that needs it via constructor argument. VideoPlayerNode already receives model and uses model.modelViewTransformProperty — it would instead receive a TReadOnlyProperty. +## 1. View Writing to Model (Separation of Concern Violation) + +**File:** `SimScreenView.ts:91–104` | **Severity:** High + +The view's `Multilink` reads positions from two view nodes (`CoordinateSystemNode`, `CalibrationToolNode`) and writes the result into `model.modelViewTransformProperty`. This inverts the data flow: the view is mutating the model. + +**Option A — keep transform in the model:** Move the origin/angle/points/distance into the model as properties, have the tool nodes read from and write to those model properties, and compute the transform as a `DerivedProperty` inside `SimModel`. The view never touches `modelViewTransformProperty` directly. + +**Option B — keep transform in the view:** Remove `modelViewTransformProperty` from `SimModel` entirely. Compute it locally in `SimScreenView` and pass it to any child node that needs it via constructor argument. `VideoPlayerNode` already receives `model` and uses `model.modelViewTransformProperty` — it would instead receive a `TReadOnlyProperty`. Option A is more consistent with the Axon pattern; Option B keeps the model cleaner. Either is better than the current arrangement. -2. Critical State in View Closure — markData[] -File: VideoPlayerNode.ts:202–210 +--- +## 2. Critical State in View Closure — `markData[]` + +**File:** `VideoPlayerNode.ts:202–210` | **Severity:** High + +```typescript type MarkData = { frame: number; localX: number; localY: number; color: string }; const markData: MarkData[] = []; // plain array in closure +``` This array is the visual ground-truth for digitized positions but lives only in the view closure. Consequences: -Deleting a track does not remove its dots. model.removeTrack() clears the model's TrackPoint[] but markData is never filtered. Dots persist after deletion. -model.reset() does not clear dots. SimScreenView.reset() calls model.reset() and resets tools, but markData survives. -No other view can read them. A DataTableNode or export function can only read model.tracksProperty, not markData. -Fix: Derive the marks from model.tracksProperty. TrackPoint already stores frame, and the view already has the model-view transform to convert model coords back to pixel coords. The view should recompute pixel dots from the model rather than maintaining a parallel array. +- **Deleting a track does not remove its dots.** `model.removeTrack()` clears the model's `TrackPoint[]` but `markData` is never filtered. Dots persist after deletion. +- **`model.reset()` does not clear dots.** `SimScreenView.reset()` calls `model.reset()` and resets tools, but `markData` survives. +- **No other view can read them.** A `DataTableNode` or export function can only read `model.tracksProperty`, not `markData`. + +**Fix:** Derive the marks from `model.tracksProperty`. `TrackPoint` already stores `frame`, and the view already has the model-view transform to convert model coords back to pixel coords. The view should recompute pixel dots from the model rather than maintaining a parallel array. + +--- + +## 3. `VideoPlayerNode` is Doing Too Much + +**File:** `VideoPlayerNode.ts` | **Severity:** Medium -3. VideoPlayerNode is Doing Too Much At 464 lines it contains at least five distinct responsibilities: -Responsibility Lines (approx) -HTML video element + event wiring 40–70 -Manual digitizing overlay (cursor, magnifier, click-to-mark) 85–245 -Playback rate, play/pause, frame stepping 252–297 -Scrubber + time/frame display 300–355 -Video source selector + webcam panel 357–440 -Suggested decomposition: - -DigitizingOverlayNode — cursor, magnifier, click handler (depends on activeTrackIdProperty, modelViewTransformProperty, model mutation) -VideoSourceControlNode — combo box + webcam button + loadUrl logic -PlaybackControlsNode — TimeControlNode + scrubber + info labels -VideoPlayerNode — composes the video element + the three above -4. Hardcoded 30 fps -File: VideoPlayerNode.ts:15 +| Responsibility | Lines (approx) | +|----------------|----------------| +| HTML video element + event wiring | 40–70 | +| Manual digitizing overlay (cursor, magnifier, click-to-mark) | 85–245 | +| Playback rate, play/pause, frame stepping | 252–297 | +| Scrubber + time/frame display | 300–355 | +| Video source selector + webcam panel | 357–440 | + +**Suggested decomposition:** + +- `DigitizingOverlayNode` — cursor, magnifier, click handler (depends on `activeTrackIdProperty`, `modelViewTransformProperty`, model mutation) +- `VideoSourceControlNode` — combo box + webcam button + `loadUrl` logic +- `PlaybackControlsNode` — `TimeControlNode` + scrubber + info labels +- `VideoPlayerNode` — composes the video element + the three above + +--- + +## 4. Hardcoded 30 fps + +**File:** `VideoPlayerNode.ts:15` | **Severity:** Medium +```typescript const FRAME_DURATION = 1 / 30; // assumes 30 fps +``` This constant drives frame counting, frame stepping, and mark filtering. All of the bundled sample videos may be 30 fps, but user-uploaded or webcam recordings may not be. The HTML video element exposes no standard API for frame rate, but a reasonable mitigation is: -Expose a user-settable frameRateProperty on the model (default 30). -Add a "Frame Rate" control in ControlPanel or VideoPlayerNode. -Document clearly that the value is assumed, not detected. -5. videoLoadedProperty Computed Twice -File: SimScreenView.ts:58 and VideoPlayerNode.ts:69–70 +1. Expose a user-settable `frameRateProperty` on the model (default 30). +2. Add a "Frame Rate" control in `ControlPanel` or `VideoPlayerNode`. +3. Document clearly that the value is assumed, not detected. -Both files independently derive a videoLoadedProperty from model.durationProperty. This is harmless but redundant. Moving it to the model as a DerivedProperty (or a simple BooleanProperty maintained by VideoPlayerNode) would give a single source of truth that TrackListPanel and SimScreenView could both consume. +--- -6. Auto-tracker Data Never Enters the Model -File: AutoTrackerNode.ts:166–174 +## 5. `videoLoadedProperty` Computed Twice -The OpenCV tracking loop updates this.trail[] and the crosshair visuals, but never calls model.addPointToTrack(). Auto-tracked positions are purely visual and ephemeral — they cannot be exported, analyzed, or compared with manual tracks. For a tool called "auto-tracking," this is a major gap. The tracked positions should be written to the model in the same way manual digitizing does (with frame, time, x, y in model coordinates). +**File:** `SimScreenView.ts:58` and `VideoPlayerNode.ts:69–70` | **Severity:** Low -This also requires converting video-pixel coordinates to model coordinates using the transform, which means AutoTrackerNode needs access to modelViewTransformProperty. +Both files independently derive a `videoLoadedProperty` from `model.durationProperty`. This is harmless but redundant. Moving it to the model as a `DerivedProperty` (or a simple `BooleanProperty` maintained by `VideoPlayerNode`) would give a single source of truth that `TrackListPanel` and `SimScreenView` could both consume. -7. TrackListPanel Rebuilds All Rows on Every Change -File: TrackListPanel.ts:198–200 +--- +## 6. Auto-tracker Data Never Enters the Model + +**File:** `AutoTrackerNode.ts:166–174` | **Severity:** High + +The OpenCV tracking loop updates `this.trail[]` and the crosshair visuals, but never calls `model.addPointToTrack()`. Auto-tracked positions are purely visual and ephemeral — they cannot be exported, analyzed, or compared with manual tracks. For a tool called "auto-tracking," this is a major gap. The tracked positions should be written to the model in the same way manual digitizing does (with `frame`, `time`, `x`, `y` in model coordinates). + +This also requires converting video-pixel coordinates to model coordinates using the transform, which means `AutoTrackerNode` needs access to `modelViewTransformProperty`. + +--- + +## 7. `TrackListPanel` Rebuilds All Rows on Every Change + +**File:** `TrackListPanel.ts:198–200` | **Severity:** Low + +```typescript model.tracksProperty.link( tracks => { trackListVBox.children = tracks.map( track => new TrackRowNode( track, model ) ); +``` + +Every mutation to `tracksProperty` (add, remove, point added to a track) destroys and recreates all `TrackRowNode` instances. Each row registers observers on `model.activeTrackIdProperty`. At 26 tracks this means 26 listeners added and removed repeatedly. + +**Fix:** `model.addPointToTrack()` replaces the entire tracks array, which triggers this rebuild even though no rows changed. Either: + +- Store points separately (e.g., `Property` per track), so adding a point doesn't trigger a tracks-level notification, or +- Use a key-based diffing approach (only create rows for new track ids). + +--- + +## 8. `TRACK_COLORS` in the Model + +**File:** `SimModel.ts:6–15` | **Severity:** Low + +The palette is an array of CSS hex strings — purely a presentational concern. The model shouldn't own view aesthetics. Move it to `TrackLabColors.ts` or to a shared constants file imported by both the model and any view that needs it. -Every mutation to tracksProperty (add, remove, point added to a track) destroys and recreates all TrackRowNode instances. Each row registers observers on model.activeTrackIdProperty. At 26 tracks this means 26 listeners added and removed repeatedly. +--- -Fix: model.addPointToTrack() replaces the entire tracks array, which triggers this rebuild even though no rows changed. Either: +## 9. Minor Issues -Store points separately (e.g., Property per track), so adding a point doesn't trigger a tracks-level notification, or -Use a key-based diffing approach (only create rows for new track ids). -8. TRACK_COLORS in the Model -File: SimModel.ts:6–15 +- **Webcam panel positioning** (`VideoPlayerNode.ts:436–439`): `WebcamPanel` is a child of `VideoPlayerNode` but is centered over `mainContent` using a `boundsProperty` listener. As a modal dialog it logically belongs at the screen-view level (added to `SimScreenView`, centered over the full view), not buried inside `VideoPlayerNode`. -The palette is an array of CSS hex strings — purely a presentational concern. The model shouldn't own view aesthetics. Move it to TrackLabColors.ts or to a shared constants file imported by both the model and any view that needs it. +- **`canAddTrack()` is imperative, not reactive** (`SimModel.ts:81–83`): called from two places in `TrackListPanel`. It should be a `DerivedProperty` that views can observe, so the enabled state of the "Add Track" button stays in sync automatically. -9. Minor Issues -Webcam panel positioning (VideoPlayerNode.ts:436–439): WebcamPanel is a child of VideoPlayerNode but is centered over mainContent using a boundsProperty listener. As a modal dialog it logically belongs at the screen-view level (added to SimScreenView, centered over the full view), not buried inside VideoPlayerNode. +- **No disposal pattern**: View nodes register `Property` observers (via `link`/`lazyLink`) but don't implement `dispose()`. In a single-screen app this is acceptable, but if screens are ever added or components become reusable, leaked observers will cause bugs. -canAddTrack() is imperative, not reactive (SimModel.ts:81–83): called from two places in TrackListPanel. It should be a DerivedProperty that views can observe, so the enabled state of the "Add Track" button stays in sync automatically. +- **Coordinate space bug risk** (`VideoPlayerNode.ts:235–237`): The digitizing click handler calls `mvt.inversePosition2(globalPt)` using the global (screen) point, then separately stores `localPt` in `markData`. If the video node is ever not at the screen origin, the model point and the visual dot will be in different positions. The inverse transform should be applied to the local video-pixel point, not the global point. -No disposal pattern: View nodes register Property observers (via link/lazyLink) but don't implement dispose(). In a single-screen app this is acceptable, but if screens are ever added or components become reusable, leaked observers will cause bugs. +--- -Coordinate space bug risk (VideoPlayerNode.ts:235–237): The digitizing click handler calls mvt.inversePosition2(globalPt) using the global (screen) point, then separately stores localPt in markData. If the video node is ever not at the screen origin, the model point and the visual dot will be in different positions. The inverse transform should be applied to the local video-pixel point, not the global point. +## Summary Table -Summary Table -Issue Severity Files -Missing DataTableNode file Critical SimScreenView.ts -markData[] not cleared on reset/delete High VideoPlayerNode.ts -View writing to model (transform) High SimScreenView.ts, SimModel.ts -Auto-tracked data never enters model High AutoTrackerNode.ts -VideoPlayerNode god-object Medium VideoPlayerNode.ts -Hardcoded 30 fps Medium VideoPlayerNode.ts -videoLoadedProperty duplicated Low SimScreenView.ts, VideoPlayerNode.ts -Track rows rebuilt on every point add Low TrackListPanel.ts -TRACK_COLORS in model Low SimModel.ts -canAddTrack() not reactive Low SimModel.ts, TrackListPanel.ts -Webcam panel at wrong layer Low VideoPlayerNode.ts -Global vs local point for inverse transform Low VideoPlayerNode.ts:236 +| Issue | Severity | Files | +|-------|----------|-------| +| `markData[]` not cleared on reset/delete | High | `VideoPlayerNode.ts` | +| View writing to model (transform) | High | `SimScreenView.ts`, `SimModel.ts` | +| Auto-tracked data never enters model | High | `AutoTrackerNode.ts` | +| `VideoPlayerNode` god-object | Medium | `VideoPlayerNode.ts` | +| Hardcoded 30 fps | Medium | `VideoPlayerNode.ts` | +| `videoLoadedProperty` duplicated | Low | `SimScreenView.ts`, `VideoPlayerNode.ts` | +| Track rows rebuilt on every point add | Low | `TrackListPanel.ts` | +| `TRACK_COLORS` in model | Low | `SimModel.ts` | +| `canAddTrack()` not reactive | Low | `SimModel.ts`, `TrackListPanel.ts` | +| Webcam panel at wrong layer | Low | `VideoPlayerNode.ts` | +| Global vs local point for inverse transform | Low | `VideoPlayerNode.ts:236` | diff --git a/src/screen-name/graph/GraphInteractionHandler.ts b/src/screen-name/graph/GraphInteractionHandler.ts index 92635d7..b5925a2 100644 --- a/src/screen-name/graph/GraphInteractionHandler.ts +++ b/src/screen-name/graph/GraphInteractionHandler.ts @@ -63,6 +63,22 @@ export interface GraphDimensions { height: number; } +/** + * Handles all pointer, touch, and keyboard interactions for a ConfigurableGraph. + * + * Responsibilities: + * - Mouse-wheel and pinch-to-zoom (preserving the zoom center point) + * - Two-finger and single-axis touch pan + * - Independent X-axis and Y-axis touch controls (tap the axis label to pan/scale that axis only) + * - Header-bar drag to reposition the graph panel + * - Corner resize handles + * - `zoomIn()` / `zoomOut()` / `pan()` methods called by keyboard shortcuts and toolbar buttons + * + * Manual zoom is tracked via `GraphDataManager.setManuallyZoomed()`. When set, auto-rescaling + * is suppressed so the user's zoom is preserved as new data arrives. + * + * Call `initialize()` once after construction to attach all input listeners. + */ export default class GraphInteractionHandler { private readonly chartTransform: ChartTransform; private readonly chartRectangle: ChartRectangle; @@ -86,6 +102,13 @@ export default class GraphInteractionHandler { private graphWidth: number; private graphHeight: number; + /** + * @param chartConfig - Chart transform, chart rectangle, and data manager + * @param uiState - Observable flags for drag/resize in-progress state + * @param uiElements - The visual nodes that receive input listeners + * @param dimensions - Initial graph width/height in view coordinates + * @param onResize - Callback invoked with the new (width, height) after a resize drag + */ public constructor( chartConfig: ChartConfig, uiState: GraphUIState, diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 15b7543..a33bd24 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -282,7 +282,9 @@ function computeTrackKinematics(track: Track): TrackKinematics { export class SimModel { public readonly isPlayingProperty = new BooleanProperty(false); - public readonly currentTimeProperty = new Property(0); + public readonly currentTimeProperty = new NumberProperty(0, { + range: new Range(0, Number.MAX_VALUE), + }); public readonly durationProperty = new Property(0); // ── Frame rate (user-settable, default 30 fps) ───────────────────────── @@ -475,7 +477,6 @@ export class SimModel { this.tracker.dispose(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars public step(_dt: number): void { // video playback is driven by the HTML video element; no model stepping needed } diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index ae726fe..5c7264e 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -1,8 +1,4 @@ -import { - DerivedProperty, - EnumerationProperty, - Property, -} from "scenerystack/axon"; +import { DerivedProperty, EnumerationProperty } from "scenerystack/axon"; import { Dimension2, Range } from "scenerystack/dot"; import { HBox, Text, VBox } from "scenerystack/scenery"; import { @@ -87,7 +83,7 @@ export class PlaybackControlsNode extends HBox { ); const scrubber = new Slider( - model.currentTimeProperty as unknown as Property, + model.currentTimeProperty, rangeProperty, { trackSize: new Dimension2(SCRUBBER_TRACK_WIDTH, SCRUBBER_TRACK_HEIGHT), diff --git a/src/tracking/OpenCVTracker.ts b/src/tracking/OpenCVTracker.ts index 3ca19b4..c254cf8 100644 --- a/src/tracking/OpenCVTracker.ts +++ b/src/tracking/OpenCVTracker.ts @@ -1,15 +1,69 @@ -// cv type is 'any' — the OpenCV.js WASM API is dynamic and not fully typed. -// biome-ignore lint/suspicious/noExplicitAny: OpenCV.js WASM has no TypeScript typings -let cvPromise: Promise | null = null; +/** + * Typed façade over the OpenCV.js WASM module. + * Only covers the API surface used by OpenCVTracker so that version-bump + * breakage is caught by the TypeScript compiler rather than at runtime. + */ + +/** Opaque handle for an OpenCV Mat allocated on the WASM heap. */ +interface CvMat { + readonly rows: number; + readonly cols: number; + roi(rect: CvRect): CvMat; + clone(): CvMat; + delete(): void; +} + +/** OpenCV Rect value. Created with `new cv.Rect()`, passed to `CvMat.roi()`. */ +interface CvRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +interface MinMaxLocResult { + minVal: number; + maxVal: number; + minLoc: { x: number; y: number }; + maxLoc: { x: number; y: number }; +} + +/** Typed surface of the OpenCV.js module used by this tracker. */ +interface CV { + // Constructors + readonly Mat: new () => CvMat; + readonly Rect: new (x: number, y: number, width: number, height: number) => CvRect; + + // Factory from browser ImageData + matFromImageData(imageData: ImageData): CvMat; + + // Color conversion + cvtColor(src: CvMat, dst: CvMat, code: number): void; + readonly COLOR_RGBA2GRAY: number; + + // Template matching + matchTemplate(image: CvMat, templ: CvMat, result: CvMat, method: number): void; + minMaxLoc(src: CvMat): MinMaxLocResult; + readonly TM_CCOEFF_NORMED: number; + + // WASM lifecycle callback set by caller, invoked when Emscripten is ready + onRuntimeInitialized?: () => void; +} + +// ───────────────────────────────────────────────────────────────────────────── + +let cvPromise: Promise | null = null; const CV_LOAD_TIMEOUT_MS = 30_000; -// biome-ignore lint/suspicious/noExplicitAny: OpenCV.js WASM has no TypeScript typings -function loadCV(): Promise { +function loadCV(): Promise { if (!cvPromise) { cvPromise = import("@techstark/opencv-js").then(async (mod) => { + // Single escape hatch at the WASM module boundary: the package ships no + // TypeScript typings, so we extract the runtime object as `unknown` and + // cast to `CV` only after confirming it is initialised. // biome-ignore lint/suspicious/noExplicitAny: OpenCV.js WASM has no TypeScript typings - let cv = (mod as any).default ?? mod; + let cv: unknown = (mod as any).default ?? mod; // The default export may itself be a Promise (v4.12.0+). if (cv instanceof Promise) { @@ -17,21 +71,20 @@ function loadCV(): Promise { } // WASM may already be ready (e.g. in test environments). - if (typeof cv.Mat === "function") { - return cv; + if (typeof (cv as { Mat?: unknown }).Mat === "function") { + return cv as CV; } // Wait for the Emscripten runtime to initialise, with a timeout so we // never hang indefinitely. - // biome-ignore lint/suspicious/noExplicitAny: OpenCV.js WASM has no TypeScript typings - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error("OpenCV WASM initialisation timed out")); }, CV_LOAD_TIMEOUT_MS); - cv.onRuntimeInitialized = () => { + (cv as CV).onRuntimeInitialized = () => { clearTimeout(timer); - resolve(cv); + resolve(cv as CV); }; }); }); @@ -52,10 +105,8 @@ export type TrackerRegion = { x: number; y: number; w: number; h: number }; * each subsequent frame using normalised cross-correlation (TM_CCOEFF_NORMED). */ export class OpenCVTracker { - // biome-ignore lint/suspicious/noExplicitAny: OpenCV.js WASM has no TypeScript typings - private cv: any = null; - // biome-ignore lint/suspicious/noExplicitAny: OpenCV.js WASM has no TypeScript typings - private templateMat: any = null; + private cv: CV | null = null; + private templateMat: CvMat | null = null; private readonly offscreen: HTMLCanvasElement; private readonly ctx: CanvasRenderingContext2D; @@ -98,13 +149,16 @@ export class OpenCVTracker { video: HTMLVideoElement, region: TrackerRegion, ): Promise { - this.cv = await loadCV(); + // Capture into a local const so TypeScript can narrow CV through the + // subsequent captureFrame() call (class fields can't be narrowed across + // method calls). + const cv = (this.cv = await loadCV()); const imageData = this.captureFrame(video); - const frame = this.cv.matFromImageData(imageData); - const gray = new this.cv.Mat(); + const frame = cv.matFromImageData(imageData); + const gray = new cv.Mat(); try { - this.cv.cvtColor(frame, gray, this.cv.COLOR_RGBA2GRAY); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); if (this.templateMat) this.templateMat.delete(); @@ -114,7 +168,7 @@ export class OpenCVTracker { // read outside the source image and crash. const clampedX = Math.round(Math.max(0, region.x)); const clampedY = Math.round(Math.max(0, region.y)); - const roi = new this.cv.Rect( + const roi = new cv.Rect( clampedX, clampedY, Math.round(Math.min(region.w, this.offscreen.width - clampedX)), @@ -132,7 +186,11 @@ export class OpenCVTracker { * Returns the center of the best match in video-pixel coordinates, or null if not ready. */ public track(video: HTMLVideoElement): { x: number; y: number } | null { - if (!this.ready) return null; + // Capture into local consts so TypeScript narrows both to non-null for the + // remainder of the method (class fields can't be narrowed across calls). + const cv = this.cv; + const templateMat = this.templateMat; + if (!cv || !templateMat) return null; let imageData: ImageData; try { @@ -141,22 +199,17 @@ export class OpenCVTracker { // Cross-origin video — silently skip this frame rather than crashing. return null; } - const frame = this.cv.matFromImageData(imageData); - const gray = new this.cv.Mat(); - const result = new this.cv.Mat(); + const frame = cv.matFromImageData(imageData); + const gray = new cv.Mat(); + const result = new cv.Mat(); try { - this.cv.cvtColor(frame, gray, this.cv.COLOR_RGBA2GRAY); - this.cv.matchTemplate( - gray, - this.templateMat, - result, - this.cv.TM_CCOEFF_NORMED, - ); - const { maxLoc } = this.cv.minMaxLoc(result); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + cv.matchTemplate(gray, templateMat, result, cv.TM_CCOEFF_NORMED); + const { maxLoc } = cv.minMaxLoc(result); return { - x: maxLoc.x + this.templateMat.cols / 2, - y: maxLoc.y + this.templateMat.rows / 2, + x: maxLoc.x + templateMat.cols / 2, + y: maxLoc.y + templateMat.rows / 2, }; } finally { frame.delete(); diff --git a/tsconfig.json b/tsconfig.json index 56ed52c..74f493c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "noEmit": true, "strict": true, "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, "exactOptionalPropertyTypes": true },