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
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