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
4 changes: 2 additions & 2 deletions src/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import "./splash.js";
import type { TBrand } from "scenerystack/brand";
import { brand, madeWithSceneryStackOnDark, madeWithSceneryStackOnLight } from "scenerystack/brand";

const Brand: TBrand = {
const Brand = {
// Nickname for the brand, which should match the brand subdirectory name, grunt option for --brand as well as the
// query parameter for ?brand. This is used in Joist to provide brand-specific logic, such as what to show in the
// About dialog, decorative text around the PhET button, and whether to check for updates.
Expand All @@ -32,6 +32,6 @@ const Brand: TBrand = {
getLinks: () => [],
logoOnBlackBackground: madeWithSceneryStackOnDark,
logoOnWhiteBackground: madeWithSceneryStackOnLight,
};
} satisfies TBrand;

brand.register("Brand", Brand);
6 changes: 3 additions & 3 deletions src/screen-name/graph/ConfigurableGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import GraphControlsPanel from "./GraphControlsPanel.js";
import GraphDataManager from "./GraphDataManager.js";
import GraphInteractionHandler from "./GraphInteractionHandler.js";
import GraphRenderer from "./GraphRenderer.js";
import type { PlottableProperty } from "./PlottableProperty.js";
import { isRecordPlottable, type PlottableProperty } from "./PlottableProperty.js";

// Line-plot stroke width — only ConfigurableGraph creates LinePlots
const PLOT_LINE_WIDTH = 2;
Expand All @@ -80,8 +80,8 @@ function mapDataPoints(
): Array<{ x: number; y: number }> {
const result: Array<{ x: number; y: number }> = [];
for (const point of dataPoints) {
const x = "accessor" in xProperty ? xProperty.accessor(point) : 0;
const y = "accessor" in yProperty ? yProperty.accessor(point) : 0;
const x = isRecordPlottable(xProperty) ? xProperty.accessor(point) : 0;
const y = isRecordPlottable(yProperty) ? yProperty.accessor(point) : 0;
if (!(Number.isNaN(x) || Number.isNaN(y))) {
result.push({ x, y });
}
Expand Down
6 changes: 6 additions & 0 deletions src/screen-name/graph/PlottableProperty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,9 @@ export type LivePlottable = PlottableBase & {

/** Union of the two concrete plottable variants. */
export type PlottableProperty = RecordPlottable | LivePlottable;

/** True when {@link p} sources its value from a data-record accessor. */
export const isRecordPlottable = (p: PlottableProperty): p is RecordPlottable => "accessor" in p;

/** True when {@link p} sources its value from a live reactive property. */
export const isLivePlottable = (p: PlottableProperty): p is LivePlottable => "property" in p;
12 changes: 8 additions & 4 deletions src/screen-name/model/OverlayToolsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { buildModelViewTransform } from "./ModelViewTransformFactory.js";
// ── Calibration unit type ──────────────────────────────────────────────────
export const CALIBRATION_UNITS = ["mm", "cm", "m", "km", "in", "ft"] as const;
export type CalibrationUnit = (typeof CALIBRATION_UNITS)[number];
/** Velocity unit derived from the calibration unit (e.g. `"m/s"`). */
export type VelocityUnit = `${CalibrationUnit}/s`;
/** Acceleration unit derived from the calibration unit (e.g. `"m/s²"`). */
export type AccelerationUnit = `${CalibrationUnit}/s²`;
export const CALIBRATION_DISTANCE_RANGE = new Range(0.001, 100000);

// ── Video-local coordinate helpers ──────────────────────────────────────────
Expand Down Expand Up @@ -83,13 +87,13 @@ export class OverlayToolsModel {
public readonly calibUnitProperty = new Property<CalibrationUnit>("m");

// ── Derived unit strings (for display in graphs and tables) ───────────────
public readonly velocityUnitProperty: TReadOnlyProperty<string> = new DerivedProperty(
public readonly velocityUnitProperty: TReadOnlyProperty<VelocityUnit> = new DerivedProperty(
[this.calibUnitProperty],
(unit) => `${unit}/s`,
(unit): VelocityUnit => `${unit}/s`,
);
public readonly accelerationUnitProperty: TReadOnlyProperty<string> = new DerivedProperty(
public readonly accelerationUnitProperty: TReadOnlyProperty<AccelerationUnit> = new DerivedProperty(
[this.calibUnitProperty],
(unit) => `${unit}/s²`,
(unit): AccelerationUnit => `${unit}/s²`,
);

// ── Model-view transform (derived; the view never writes to this) ─────────
Expand Down
8 changes: 4 additions & 4 deletions src/screen-name/model/TrackingModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ export class TrackingModel {
points: [],
};

const tracks = [...this.tracksProperty.value, track];
tracks.sort((a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0));
this.tracksProperty.value = tracks;
this.tracksProperty.value = [...this.tracksProperty.value, track].toSorted(
(a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0),
);
}

/**
Expand Down Expand Up @@ -151,7 +151,7 @@ export class TrackingModel {
public addTrackAndActivate(): void {
this.addTrack();
const tracks = this.tracksProperty.value;
const newest = tracks[tracks.length - 1];
const newest = tracks.at(-1);
if (newest) {
this.activeTrackIdProperty.value = newest.id;
}
Expand Down
8 changes: 4 additions & 4 deletions src/screen-name/view/AutoTrackerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,9 @@ export class AutoTrackerNode extends Node {
this.pendingFrameId = requestAnimationFrame(processFrame);
}
};
videoElement.addEventListener("timeupdate", onFrame);
videoElement.addEventListener("seeked", onFrame);
const listenerController = new AbortController();
videoElement.addEventListener("timeupdate", onFrame, { signal: listenerController.signal });
videoElement.addEventListener("seeked", onFrame, { signal: listenerController.signal });

// ── Show/hide based on combined "video loaded && autoTracking" ────────
const autoTrackingShownListener = (shown: boolean) => {
Expand All @@ -341,8 +342,7 @@ export class AutoTrackerNode extends Node {

// ── Centralised cleanup (mirrors the disposeXxx pattern used elsewhere) ─
this.disposeAutoTrackerNode = () => {
videoElement.removeEventListener("timeupdate", onFrame);
videoElement.removeEventListener("seeked", onFrame);
listenerController.abort();
this.cancelPendingFrame();
autoTrackingShownProperty.unlink(autoTrackingShownListener);
videoDimensionsProperty.unlink(videoDimensionsListener);
Expand Down
31 changes: 15 additions & 16 deletions src/screen-name/view/VideoPlayerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,14 @@ export class VideoPlayerNode extends Node {
const onVideoLoadStart = () => {
videoErrorText.visible = false;
};
this.videoElement.addEventListener("error", onVideoError);
this.videoElement.addEventListener("loadstart", onVideoLoadStart);

// AbortController lets dispose() cancel all video-element listeners with a
// single controller.abort() instead of a matching removeEventListener call.
const listenerController = new AbortController();
const { signal } = listenerController;

this.videoElement.addEventListener("error", onVideoError, { signal });
this.videoElement.addEventListener("loadstart", onVideoLoadStart, { signal });

const updateDuration = () => {
const d = this.videoElement.duration;
Expand All @@ -138,13 +144,13 @@ export class VideoPlayerNode extends Node {
this.videoElement.currentTime = Number.MAX_SAFE_INTEGER;
}
};
this.videoElement.addEventListener("loadedmetadata", onLoadedMetadata);
this.videoElement.addEventListener("durationchange", updateDuration);
this.videoElement.addEventListener("loadedmetadata", onLoadedMetadata, { signal });
this.videoElement.addEventListener("durationchange", updateDuration, { signal });

const onEnded = () => {
model.playback.isPlayingProperty.value = false;
};
this.videoElement.addEventListener("ended", onEnded);
this.videoElement.addEventListener("ended", onEnded, { signal });

// ── Auto-tracking overlay ──────────────────────────────────────────────
const autoTrackingShownProperty = new DerivedProperty(
Expand Down Expand Up @@ -354,15 +360,15 @@ export class VideoPlayerNode extends Node {
this.videoSourceControlNode.centerX = scaledW / 2;
resizeHandle.center = this.videoContentWrapper.rightBottom;
};
this.videoElement.addEventListener("loadedmetadata", onDimensionsLoaded);
this.videoElement.addEventListener("loadedmetadata", onDimensionsLoaded, { signal });

// Sync model time from video during playback (event-driven, not polled)
const onTimeUpdate = () => {
if (!this.playbackControlsNode.scrubbing) {
model.playback.currentTimeProperty.value = this.videoElement.currentTime;
}
};
this.videoElement.addEventListener("timeupdate", onTimeUpdate);
this.videoElement.addEventListener("timeupdate", onTimeUpdate, { signal });

// ── Apply video transform (translate + uniform scale) ────────────────
const videoTransformListener = (matrix: import("scenerystack/dot").Matrix3) => {
Expand All @@ -372,11 +378,11 @@ export class VideoPlayerNode extends Node {

// ── Keyboard shortcuts ─────────────────────────────────────────────────
const onKeyDown = this.createKeyboardHandler(model);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keydown", onKeyDown, { signal });

// Store cleanup function
this.disposeVideoPlayer = () => {
document.removeEventListener("keydown", onKeyDown);
listenerController.abort(); // removes keydown + all videoElement listeners at once
model.overlayTools.videoContentVisibleProperty.unlink(videoContentVisibleListener);
TrackLabColors.videoBackgroundColorProperty.unlink(videoBackgroundListener);
TrackLabColors.panelHeaderColorProperty.unlink(panelHeaderColorListener);
Expand All @@ -385,13 +391,6 @@ export class VideoPlayerNode extends Node {
model.playback.playbackRateProperty.unlink(playbackRateListener);
model.playback.videoTransformProperty.unlink(videoTransformListener);
model.playback.panelSizeScaleProperty.unlink(panelSizeScaleListener);
this.videoElement.removeEventListener("loadedmetadata", onLoadedMetadata);
this.videoElement.removeEventListener("loadedmetadata", onDimensionsLoaded);
this.videoElement.removeEventListener("durationchange", updateDuration);
this.videoElement.removeEventListener("ended", onEnded);
this.videoElement.removeEventListener("timeupdate", onTimeUpdate);
this.videoElement.removeEventListener("error", onVideoError);
this.videoElement.removeEventListener("loadstart", onVideoLoadStart);
if (this.currentBlobUrl) {
URL.revokeObjectURL(this.currentBlobUrl);
this.currentBlobUrl = null;
Expand Down
5 changes: 3 additions & 2 deletions src/tracking/OpenCVTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ export class OpenCVTracker {
try {
return this.ctx.getImageData(x, y, w, h);
} catch (e) {
const err = new Error("Cannot read video pixels: the video source may be cross-origin without CORS headers.");
throw Object.assign(err, { cause: e });
throw new Error("Cannot read video pixels: the video source may be cross-origin without CORS headers.", {
cause: e,
});
}
}

Expand Down
91 changes: 45 additions & 46 deletions src/webcam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,25 +327,22 @@ export class WebcamRecorder {
* Stop recording and return the recorded video as a Blob.
*/
public stopRecording(): Promise<Blob> {
return new Promise((resolve, reject) => {
if (!this.mediaRecorder) {
reject(new Error("No active recording."));
return;
}

this.mediaRecorder.onstop = () => {
const mimeType = this.mediaRecorder?.mimeType || "video/webm";
const blob = new Blob(this.recordedChunks, { type: mimeType });
this.recordedChunks = [];
resolve(blob);
};

this.mediaRecorder.onerror = (event) => {
reject(event);
};

this.mediaRecorder.stop();
});
const { promise, resolve, reject } = Promise.withResolvers<Blob>();
if (!this.mediaRecorder) {
reject(new Error("No active recording."));
return promise;
}
this.mediaRecorder.onstop = () => {
const mimeType = this.mediaRecorder?.mimeType || "video/webm";
const blob = new Blob(this.recordedChunks, { type: mimeType });
this.recordedChunks = [];
resolve(blob);
};
this.mediaRecorder.onerror = (event) => {
reject(event);
};
this.mediaRecorder.stop();
return promise;
}

/**
Expand Down Expand Up @@ -420,6 +417,10 @@ export class WebcamRecorder {
this.mediaRecorder = null;
this.recordedChunks = [];
}

public [Symbol.dispose](): void {
this.cleanup();
}
}

/**
Expand All @@ -432,28 +433,24 @@ export class WebcamRecorder {
* @returns Promise resolving to the measured FPS
*/
export function measureEmpiricalFrameRate(video: HTMLVideoElement, durationMs: number = 1000): Promise<number> {
return new Promise((resolve, reject) => {
if (video.readyState < 2) {
reject(new Error("Video must be playing and have enough data"));
const { promise, resolve, reject } = Promise.withResolvers<number>();
if (video.readyState < 2) {
reject(new Error("Video must be playing and have enough data"));
return promise;
}
let frameCount = 0;
const startTime = performance.now();
function countFrames(): void {
frameCount++;
const elapsed = performance.now() - startTime;
if (elapsed >= durationMs) {
resolve((frameCount / elapsed) * 1000);
return;
}

let frameCount = 0;
const startTime = performance.now();

function countFrames(): void {
frameCount++;
const elapsed = performance.now() - startTime;
if (elapsed >= durationMs) {
const fps = (frameCount / elapsed) * 1000;
resolve(fps);
return;
}
requestAnimationFrame(countFrames);
}

requestAnimationFrame(countFrames);
});
}
requestAnimationFrame(countFrames);
return promise;
}

export type FPSEstimate = {
Expand Down Expand Up @@ -543,6 +540,15 @@ export async function estimateVideoFrameRate(

const WEBP_DEFAULT_FPS = 30;

/** Wraps any object with a `.close()` method so it can be used with `using`. */
function withDispose<T extends { close(): void }>(resource: T): T & Disposable {
return Object.assign(resource, {
[Symbol.dispose]() {
resource.close();
},
});
}

/**
* Returns frame count, total duration (seconds), and average fps for an
* animated WebP image using the ImageDecoder API (Chrome 94+).
Expand All @@ -556,20 +562,14 @@ export async function getAnimatedWebPInfo(
return null;
}
try {
const decoder = new ImageDecoder({
data: blob.stream(),
type: "image/webp",
preferAnimation: true,
});
using decoder = withDispose(new ImageDecoder({ data: blob.stream(), type: "image/webp", preferAnimation: true }));
await decoder.tracks.ready;
const track = decoder.tracks.selectedTrack;
if (!track) {
decoder.close();
return null;
}
const frameCount = track.frameCount;
if (frameCount <= 0) {
decoder.close();
return null;
}
// Sum per-frame durations (microseconds) to get total duration in seconds.
Expand All @@ -579,7 +579,6 @@ export async function getAnimatedWebPInfo(
totalMicroseconds += result.image.duration ?? 0;
result.image.close();
}
decoder.close();
const duration = totalMicroseconds / 1_000_000;
const fps = duration > 0 ? frameCount / duration : WEBP_DEFAULT_FPS;
return { frameCount, duration, fps };
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2024", "ESNext.Disposable", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
Expand Down