chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
@@ -0,0 +1,104 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
buildRmsEnvelope,
compareAudioEnvelopes,
computeAudioResidualRmsDb,
} from "./audioRegression.js";
describe("compareAudioEnvelopes", () => {
it("treats silent-vs-silent audio as a perfect match", () => {
const silentSamples = new Int16Array(4096);
const rendered = buildRmsEnvelope(silentSamples);
const snapshot = buildRmsEnvelope(silentSamples);
expect(compareAudioEnvelopes(rendered, snapshot, 120)).toEqual({
correlation: 1,
lagWindows: 0,
});
});
});
// Skip the spawn-based tests entirely on hosts without ffmpeg. The
// regression harness only runs in environments where ffmpeg is present
// (`Dockerfile.test`, dev boxes with apt's ffmpeg), so an absent ffmpeg
// is a developer-laptop fact, not a producer regression.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"], { encoding: "utf-8" }).status === 0;
describe.skipIf(!HAS_FFMPEG)("computeAudioResidualRmsDb", () => {
let tmp: string;
beforeAll(() => {
tmp = mkdtempSync(join(tmpdir(), "hf-audio-residual-test-"));
// Two test wavs: identical 1-second 440 Hz sine, and a 880 Hz sine
// that's audibly different from the 440 reference.
for (const [name, freq] of [
["sine-440-a.wav", 440],
["sine-440-b.wav", 440],
["sine-880.wav", 880],
] as const) {
const result = spawnSync(
"ffmpeg",
[
"-nostdin",
"-v",
"error",
"-f",
"lavfi",
"-i",
`sine=frequency=${freq}:duration=1:sample_rate=48000`,
"-ac",
"2",
"-c:a",
"pcm_s16le",
join(tmp, name),
],
{ encoding: "utf-8" },
);
if (result.status !== 0) {
throw new Error(`ffmpeg setup failed for ${name}: ${result.stderr}`);
}
}
});
afterAll(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("returns -inf (or very low dBFS) for two identical streams", () => {
const result = computeAudioResidualRmsDb(
join(tmp, "sine-440-a.wav"),
join(tmp, "sine-440-b.wav"),
);
expect(result.ok).toBe(true);
// 440-vs-440 PCM cancels to silence; ffmpeg reports -inf which we
// normalize to NEGATIVE_INFINITY, OR a value well below -90 if the
// resampler introduces sub-bit-quantization noise.
expect(result.overallDb).toBeLessThan(-80);
});
it("fails when streams are audibly different (440 Hz vs 880 Hz)", () => {
const result = computeAudioResidualRmsDb(
join(tmp, "sine-440-a.wav"),
join(tmp, "sine-880.wav"),
);
expect(result.ok).toBe(false);
// The residual of two uncorrelated unit-amplitude sines is roughly
// the sum of both signals at near-full level — typically around
// -3 dBFS in this resampled-stereo configuration.
expect(result.overallDb).toBeGreaterThan(-30);
});
it("reports ok=false when an input has no audio stream", () => {
// A bare empty file: ffmpeg can't probe it, so the function reports
// a parse failure (ok=false, NaN). Callers decide whether to treat
// that as a pass (no-audio fixture) or a fail (audio expected).
const result = computeAudioResidualRmsDb("/dev/null", join(tmp, "sine-440-a.wav"));
expect(result.ok).toBe(false);
expect(Number.isNaN(result.overallDb)).toBe(true);
});
});
@@ -0,0 +1,372 @@
export function buildRmsEnvelope(samples: Int16Array, windowSize = 2048, hopSize = 1024): number[] {
if (samples.length < windowSize) return [];
const envelope: number[] = [];
for (let start = 0; start + windowSize <= samples.length; start += hopSize) {
let energy = 0;
for (let i = 0; i < windowSize; i += 1) {
const normalized = (samples[start + i] ?? 0) / 32768;
energy += normalized * normalized;
}
envelope.push(Math.sqrt(energy / windowSize));
}
return envelope;
}
function correlationAtLag(a: number[], b: number[], lag: number): number {
const startA = Math.max(0, lag);
const startB = Math.max(0, -lag);
const length = Math.min(a.length - startA, b.length - startB);
if (length <= 32) return -1;
let meanA = 0;
let meanB = 0;
for (let i = 0; i < length; i += 1) {
meanA += a[startA + i] ?? 0;
meanB += b[startB + i] ?? 0;
}
meanA /= length;
meanB /= length;
let numerator = 0;
let denA = 0;
let denB = 0;
for (let i = 0; i < length; i += 1) {
const da = (a[startA + i] ?? 0) - meanA;
const db = (b[startB + i] ?? 0) - meanB;
numerator += da * db;
denA += da * da;
denB += db * db;
}
if (denA <= 1e-12 || denB <= 1e-12) return -1;
return numerator / Math.sqrt(denA * denB);
}
function bestEnvelopeCorrelation(
rendered: number[],
snapshot: number[],
maxLagWindows: number,
): { correlation: number; lagWindows: number } {
let best = -1;
let bestLag = 0;
for (let lag = -maxLagWindows; lag <= maxLagWindows; lag += 1) {
const corr = correlationAtLag(rendered, snapshot, lag);
if (corr > best) {
best = corr;
bestLag = lag;
}
}
return { correlation: best, lagWindows: bestLag };
}
function isSilentEnvelope(envelope: number[]): boolean {
return envelope.length > 0 && envelope.every((sample) => Math.abs(sample) <= 1e-9);
}
export function compareAudioEnvelopes(
rendered: number[],
snapshot: number[],
maxLagWindows: number,
): { correlation: number; lagWindows: number } {
if (rendered.length === 0 || snapshot.length === 0) {
return { correlation: 1, lagWindows: 0 };
}
if (isSilentEnvelope(rendered) && isSilentEnvelope(snapshot)) {
return { correlation: 1, lagWindows: 0 };
}
return bestEnvelopeCorrelation(rendered, snapshot, maxLagWindows);
}
// ── Sample-level residual RMS ───────────────────────────────────────────────
//
// Precise sample-cancellation equivalence check: subtract one audio
// stream from the other, run `astats`, read the residual Overall RMS in
// dBFS. Perfectly-equivalent streams produce silence (≤ -90 dBFS in
// practice for AAC-vs-AAC); ≤ -50 dBFS is the conventional threshold
// for treating two streams as effectively identical.
//
// This catches level/phase drift the envelope-correlation check cannot.
// Correlation measures shape similarity at envelope granularity (2048-
// sample windows by default); residual RMS measures sample-level
// cancellation, so it falls out as soon as the two streams disagree by
// a fraction of a sample in alignment or by a fraction of a dB in
// level.
//
// `astats` is invoked via `ffmpeg` spawned in-process. We require ffmpeg
// on PATH — the regression harness already requires it for encode +
// envelope extraction.
import { spawnSync } from "node:child_process";
/**
* Result of {@link computeAudioResidualRmsDb}.
*
* `overallDb` is the residual Overall RMS reading from astats. For
* exact-cancellation (truly identical streams), ffmpeg returns `-inf`;
* this helper normalizes that to `Number.NEGATIVE_INFINITY` so callers
* don't have to special-case the literal string.
*/
export interface AudioResidualRms {
overallDb: number;
ok: boolean;
/** Raw stderr lines that mention `RMS level` (one per channel + overall). Useful for debugging unexpected drift. */
rmsLines: string[];
/**
* Diagnostic when the helper could not produce a residual reading
* (ffmpeg missing, ffprobe duration mismatch, astats output unparseable,
* etc.). When set, callers should treat it as a hard failure even though
* `overallDb` may be `NaN`.
*/
error?: string;
}
/**
* Compute the residual Overall RMS (dBFS) of `rendered - snapshot`.
*
* Both inputs are paths to media files containing an audio stream.
* They're resampled to 48 kHz stereo, the snapshot is phase-inverted,
* the two are summed via `amix`, and `astats` reports the residual
* level.
*
* Returns `{ ok: false, overallDb: NaN }` if either input lacks an
* audio stream, or if ffmpeg's output didn't contain a parseable RMS
* line — the caller decides whether that's a pass (no-audio fixture)
* or a fail (audio expected but missing).
*
* `maxResidualRmsDb` defaults to `-50`. Pass `-Infinity`
* to compute the value without gating it.
*/
export function computeAudioResidualRmsDb(
rendered: string,
snapshot: string,
maxResidualRmsDb = -50,
): AudioResidualRms {
// Pre-probe both inputs' audio durations. `amix=duration=shortest`
// truncates at the shorter input, which means trailing audio on the
// longer side never enters astats — a fixture that drops the last
// half-second of audio would still report a clean residual. Fail
// up-front instead. One-frame tolerance @ 48 kHz ≈ 20.83 µs (one
// audio frame); we widen to 5 ms (~240 samples) so trivial container
// muxer rounding doesn't trip the gate.
const renderedDur = probeAudioDuration(rendered);
const snapshotDur = probeAudioDuration(snapshot);
if (renderedDur.error || snapshotDur.error) {
return {
overallDb: Number.NaN,
ok: false,
rmsLines: [],
error: renderedDur.error ?? snapshotDur.error,
};
}
const delta = Math.abs(renderedDur.seconds - snapshotDur.seconds);
const TOLERANCE_SECONDS = 0.005;
if (delta > TOLERANCE_SECONDS) {
return {
overallDb: Number.NaN,
ok: false,
rmsLines: [],
error: `audio duration mismatch: rendered=${renderedDur.seconds.toFixed(
4,
)}s, snapshot=${snapshotDur.seconds.toFixed(4)}s (Δ=${delta.toFixed(
4,
)}s > ${TOLERANCE_SECONDS}s) — amix=duration=shortest would hide the trailing difference`,
};
}
const proc = spawnSync(
"ffmpeg",
[
"-nostdin",
"-v",
"info",
"-i",
rendered,
"-i",
snapshot,
"-filter_complex",
// Align both streams (resample + stereo + zero-based PTS), invert the
// snapshot, sum via amix, run astats. Avoids amix's `normalize`
// option (not available on ffmpeg 4.x) — we use volume=-1 + amix to
// subtract.
[
"[0:a]aresample=48000,pan=stereo|c0=c0|c1=c1,asetpts=N/SR/TB[a0]",
"[1:a]aresample=48000,pan=stereo|c0=c0|c1=c1,asetpts=N/SR/TB,volume=-1[a1]",
"[a0][a1]amix=inputs=2:duration=shortest:dropout_transition=0,astats=metadata=1:reset=1[out]",
].join(";"),
"-map",
"[out]",
"-f",
"null",
"-",
],
{ encoding: "utf-8" },
);
// `spawnSync` swallows `ENOENT`, signal kills, and non-zero exits
// silently — without surfacing them, every failure mode collapses
// into "no RMS line found, NaN, fail". Surface the actual cause so
// CI logs are actionable.
if (proc.error) {
return {
overallDb: Number.NaN,
ok: false,
rmsLines: [],
error: `ffmpeg spawn failed: ${(proc.error as NodeJS.ErrnoException).code ?? proc.error.message}`,
};
}
if (proc.signal) {
return {
overallDb: Number.NaN,
ok: false,
rmsLines: [],
error: `ffmpeg killed by signal ${proc.signal}`,
};
}
if (typeof proc.status === "number" && proc.status !== 0) {
return {
overallDb: Number.NaN,
ok: false,
rmsLines: [],
error: `ffmpeg exited with status ${proc.status}: ${tailStderr(proc.stderr ?? "")}`,
};
}
const stderr = proc.stderr || "";
// Modern ffmpeg's astats emits per-channel stats first, then an
// `Overall` section header on its own line, then overall stats.
// Example (ffmpeg 6.x / 7.x / 8.x):
// [Parsed_astats_0 @ 0x...] RMS level dB: -21.43 ← channel 1
// [Parsed_astats_0 @ 0x...] ...
// [Parsed_astats_0 @ 0x...] Overall ← section header (no value)
// [Parsed_astats_0 @ 0x...] DC offset: ...
// [Parsed_astats_0 @ 0x...] RMS level dB: -21.43 ← overall value
// A single-line `Overall RMS level dB:` regex never fires on these
// builds — the `Overall` token and `RMS level` token are on different
// lines. We do a stateful scan: find the `Overall` header, take the
// first `RMS level dB:` line that follows. Older ffmpeg builds (4.x)
// do emit `Overall RMS level dB:` on a single line; the
// single-line fallback regex covers those.
const lines = stderr.split(/\r?\n/);
const rmsLines = lines.filter((line) => /RMS level/.test(line));
const overallDb = parseOverallRms(lines) ?? parseInlineOverallRms(rmsLines);
// Fallback to per-channel max if the Overall section is missing
// (unusual ffmpeg build, or astats truncated). For a 2-channel mix
// this is the more pessimistic of the two channels, which is a
// strictly tighter gate than Overall.
const channelMax =
pickRms(rmsLines, /RMS level\s*dB:\s*(-?inf|[-\d.]+)/i, "max") ??
pickRms(rmsLines, /RMS level:\s*(-?inf|[-\d.]+)/i, "max");
const value = overallDb ?? channelMax;
if (value === null) {
return { overallDb: Number.NaN, ok: false, rmsLines };
}
return {
overallDb: value,
ok: value <= maxResidualRmsDb,
rmsLines,
};
}
/** Stateful parse: find an `Overall` header line, return the first `RMS level dB:` value after it. */
function parseOverallRms(lines: string[]): number | null {
let inOverall = false;
for (const line of lines) {
// The `Overall` header is the literal token at end of an astats
// prefix; match on word boundary so `Overall RMS level...` (the
// inline form for older ffmpeg) isn't accidentally consumed here.
if (!inOverall && /\bOverall\s*$/.test(line)) {
inOverall = true;
continue;
}
if (inOverall) {
const m = /RMS level\s*dB:\s*(-?inf|[-\d.]+)/i.exec(line);
if (m && m[1] !== undefined) {
return m[1] === "-inf" || m[1] === "inf"
? Number.NEGATIVE_INFINITY
: Number.parseFloat(m[1]);
}
}
}
return null;
}
/** Single-line `Overall RMS level dB: <value>` parser for older ffmpeg builds (4.x). */
function parseInlineOverallRms(rmsLines: string[]): number | null {
return pickRms(rmsLines, /Overall RMS level(?:\s*dB)?:\s*(-?inf|[-\d.]+)/i);
}
/**
* Probe a media file's audio-stream duration via `ffprobe`. Returns
* `{ seconds: NaN, error }` if the file has no audio stream or
* `ffprobe` can't be invoked.
*/
function probeAudioDuration(file: string): { seconds: number; error?: string } {
const proc = spawnSync(
"ffprobe",
[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file,
],
{ encoding: "utf-8" },
);
if (proc.error) {
return {
seconds: Number.NaN,
error: `ffprobe spawn failed for ${file}: ${(proc.error as NodeJS.ErrnoException).code ?? proc.error.message}`,
};
}
if (typeof proc.status === "number" && proc.status !== 0) {
return {
seconds: Number.NaN,
error: `ffprobe exited ${proc.status} for ${file}: ${tailStderr(proc.stderr ?? "")}`,
};
}
const raw = (proc.stdout ?? "").trim();
if (!raw || raw === "N/A") {
return { seconds: Number.NaN, error: `no audio stream in ${file}` };
}
const seconds = Number.parseFloat(raw);
if (!Number.isFinite(seconds)) {
return {
seconds: Number.NaN,
error: `ffprobe returned unparseable duration "${raw}" for ${file}`,
};
}
return { seconds };
}
function tailStderr(stderr: string, lines = 5): string {
const trimmed = stderr.trim();
if (!trimmed) return "<empty>";
const tail = trimmed.split(/\r?\n/).slice(-lines).join(" | ");
return tail.length > 500 ? `${tail.slice(0, 500)}` : tail;
}
function pickRms(lines: string[], re: RegExp, mode: "first" | "max" = "first"): number | null {
const values: number[] = [];
for (const line of lines) {
const m = re.exec(line);
if (!m) continue;
const raw = m[1];
if (raw === "-inf" || raw === "inf") {
values.push(Number.NEGATIVE_INFINITY);
} else {
const n = Number.parseFloat(raw ?? "");
if (!Number.isNaN(n)) values.push(n);
}
if (mode === "first") break;
}
if (values.length === 0) return null;
if (mode === "max") return Math.max(...values);
return values[0] ?? null;
}
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { normalizeErrorMessage } from "./errorMessage.js";
describe("normalizeErrorMessage", () => {
it("extracts message from Error instances", () => {
expect(normalizeErrorMessage(new Error("boom"))).toBe("boom");
});
it("passes through strings", () => {
expect(normalizeErrorMessage("oops")).toBe("oops");
});
it("extracts .message from plain objects", () => {
expect(normalizeErrorMessage({ message: "hidden error" })).toBe("hidden error");
});
it("JSON-stringifies objects without .message", () => {
expect(normalizeErrorMessage({ code: 42 })).toBe('{"code":42}');
});
it("handles null", () => {
expect(normalizeErrorMessage(null)).toBe("unknown error");
});
it("handles undefined", () => {
expect(normalizeErrorMessage(undefined)).toBe("unknown error");
});
it("handles numbers", () => {
expect(normalizeErrorMessage(42)).toBe("42");
});
it("handles objects with non-string .message", () => {
expect(normalizeErrorMessage({ message: 123 })).toBe('{"message":123}');
});
it("handles circular references gracefully", () => {
const obj: Record<string, unknown> = {};
obj.self = obj;
// Falls through JSON.stringify failure to Object.keys()
expect(normalizeErrorMessage(obj)).toBe("{self}");
});
});
@@ -0,0 +1,31 @@
/**
* Normalize an unknown thrown value into a human-readable string.
*
* The default `String(error)` pattern produces `[object Object]` when the
* thrown value is a plain object — masking the real error in telemetry.
* This utility tries, in order:
* 1. `Error.message`
* 2. Raw string pass-through
* 3. `.message` property on a plain object
* 4. `JSON.stringify` for any other object
* 5. `String()` fallback for primitives (number, boolean, symbol, bigint)
* 6. `"unknown error"` for null / undefined
*/
export function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (typeof error === "object" && error !== null) {
const msg = (error as Record<string, unknown>).message;
if (typeof msg === "string") return msg;
try {
return JSON.stringify(error);
} catch {
try {
return `{${Object.keys(error as object).join(", ")}}`;
} catch {
/* truly opaque object */
}
}
}
return String(error ?? "unknown error");
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Re-exported from @hyperframes/engine.
* @see engine/src/utils/ffprobe.ts for implementation.
*/
export {
extractMediaMetadata,
extractVideoMetadata,
extractAudioMetadata,
type VideoMetadata,
type AudioMetadata,
} from "@hyperframes/engine";
@@ -0,0 +1,5 @@
/**
* Re-exported from @hyperframes/engine.
* @see engine/src/utils/parityContract.ts for implementation.
*/
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/engine";
+164
View File
@@ -0,0 +1,164 @@
/**
* Cross-platform containment + external-asset-key tests.
*
* Regression coverage for GH #321 — on Windows, every external asset was
* wrongly rejected as "unsafe path" because the containment check used
* `startsWith(parent + "/")` and the safe key carried a drive-letter
* colon that made the downstream `path.join` absolute.
*
* We exercise both OS layouts by posing the hypothetical paths the
* respective platforms would generate — the logic itself is expressed
* using `path.relative()` so it works regardless of the runtime OS.
*/
import { describe, expect, it } from "vitest";
import { resolve, win32 } from "node:path";
import {
isPathInside,
toExternalAssetKey,
formatCaptureFrameName,
formatExportFrameName,
} from "./paths.js";
describe("isPathInside", () => {
it("returns true when child is directly inside parent", () => {
expect(isPathInside(resolve("/foo/bar/baz.wav"), resolve("/foo/bar"))).toBe(true);
});
it("returns true when child is deeply nested inside parent", () => {
expect(isPathInside(resolve("/foo/bar/a/b/c/d.wav"), resolve("/foo/bar"))).toBe(true);
});
it("returns true when child equals parent (a dir contains itself)", () => {
expect(isPathInside(resolve("/foo/bar"), resolve("/foo/bar"))).toBe(true);
});
it("returns false when child is a sibling whose name starts with parent", () => {
// Regression: the old `startsWith(parent + "/")` accidentally worked for
// this case, but a naive rewrite without the trailing separator would
// admit `/foo/bar-sibling` as a child of `/foo/bar`. Verify we don't.
expect(isPathInside(resolve("/foo/bar-sibling/x"), resolve("/foo/bar"))).toBe(false);
});
it("returns false when child is outside parent", () => {
expect(isPathInside(resolve("/tmp/evil/file.wav"), resolve("/foo/bar"))).toBe(false);
});
it("returns false when child resolves above parent via ..", () => {
expect(isPathInside(resolve("/foo/bar/../../etc/passwd"), resolve("/foo/bar"))).toBe(false);
});
it("normalises trailing slashes on parent", () => {
expect(isPathInside(resolve("/foo/bar/baz"), resolve("/foo/bar/"))).toBe(true);
});
it("handles Windows paths under the parent directory", () => {
expect(
isPathInside(
win32.resolve("C:\\compiled\\__hyperframes_video_frames\\video\\frame_000001.jpg"),
win32.resolve("C:\\compiled"),
{ pathModule: win32 },
),
).toBe(true);
});
});
describe("toExternalAssetKey", () => {
it("prefixes with hf-ext/ and keeps a Unix absolute path", () => {
expect(toExternalAssetKey("/Users/miguel/assets/segment.wav")).toBe(
"hf-ext/Users/miguel/assets/segment.wav",
);
});
it("converts Windows drive-letter paths to a colonless, slash-delimited key", () => {
// GH #321: `D:\coder\reactGin\hyperframes\reading\assets\segment_001.wav`
// used to become `hf-ext/D:\coder\...`, which makes the downstream
// `path.join(compileDir, key)` absolute on Windows (drive letter wins).
expect(
toExternalAssetKey("D:\\coder\\reactGin\\hyperframes\\reading\\assets\\segment_001.wav"),
).toBe("hf-ext/D/coder/reactGin/hyperframes/reading/assets/segment_001.wav");
});
it("handles Windows paths with forward slashes (mixed separators)", () => {
expect(toExternalAssetKey("C:/Users/Alice/Downloads/clip.mp4")).toBe(
"hf-ext/C/Users/Alice/Downloads/clip.mp4",
);
});
it("lowercases / uppercases drive letters faithfully (we don't munge)", () => {
expect(toExternalAssetKey("e:\\data\\a.wav")).toBe("hf-ext/e/data/a.wav");
expect(toExternalAssetKey("Z:\\data\\a.wav")).toBe("hf-ext/Z/data/a.wav");
});
it("is truly idempotent — double-wrap short-circuits on the hf-ext/ prefix", () => {
// Earlier revision of this test claimed "idempotent" but actually
// produced `hf-ext/hf-ext/...` — a silent doubling. The short-circuit
// on the hf-ext/ prefix makes the helper exactly idempotent now, so
// the invariant test matches the label.
const once = toExternalAssetKey("/foo/bar.mp3");
const twice = toExternalAssetKey(once);
expect(twice).toBe(once);
});
it("strips the Windows extended-length prefix (\\\\?\\)", () => {
expect(toExternalAssetKey("\\\\?\\D:\\very\\long\\path\\clip.mp4")).toBe(
"hf-ext/D/very/long/path/clip.mp4",
);
});
it("collapses UNC paths to unc/<server>/<share>/... so cross-server names can't collide", () => {
expect(toExternalAssetKey("\\\\server\\share\\file.wav")).toBe(
"hf-ext/unc/server/share/file.wav",
);
});
it("handles UNC extended-length form (\\\\?\\UNC\\server\\...)", () => {
expect(toExternalAssetKey("\\\\?\\UNC\\server\\share\\file.wav")).toBe(
"hf-ext/unc/server/share/file.wav",
);
});
it("treats leading double-slash as UNC (the Windows-correct reading)", () => {
// A leading `//host/share/...` is the Windows UNC form — NOT a Unix
// absolute path with an extra slash. The sanitiser now preserves the
// host/share boundary instead of collapsing it, matching the actual
// meaning of the input on the platform that produces these paths.
expect(toExternalAssetKey("//foo/bar.mp3")).toBe("hf-ext/unc/foo/bar.mp3");
});
it("produces a key that path.join(compileDir, key) keeps inside compileDir", () => {
// The real failure mode from #321: on Windows, join(compileDir, key) with
// a key containing a drive letter silently escaped compileDir. Our key
// must be a pure relative path — no `:`, no leading separator — so
// `isPathInside(join(compileDir, key), compileDir)` is always true.
const key = toExternalAssetKey("D:\\evil\\x.wav");
// Key cannot start with a separator or drive letter.
expect(key.startsWith("/")).toBe(false);
expect(/^[A-Za-z]:/.test(key)).toBe(false);
});
});
describe("formatCaptureFrameName", () => {
it("returns zero-padded filename for zero-based index", () => {
expect(formatCaptureFrameName(0, "jpg")).toBe("frame_000000.jpg");
});
it("pads to 6 digits for large indices", () => {
expect(formatCaptureFrameName(999999, "png")).toBe("frame_999999.png");
});
it("handles mid-range indices", () => {
expect(formatCaptureFrameName(42, "jpg")).toBe("frame_000042.jpg");
});
});
describe("formatExportFrameName", () => {
it("returns one-based filename from zero-based input", () => {
expect(formatExportFrameName(0, "png")).toBe("frame_000001.png");
});
it("increments index by 1", () => {
expect(formatExportFrameName(4, "png")).toBe("frame_000005.png");
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Path resolution utilities for the render pipeline.
*/
import {
basename,
join,
resolve as nodeResolve,
relative as nodeRelative,
isAbsolute as nodeIsAbsolute,
} from "node:path";
import { fileURLToPath } from "node:url";
export interface RenderPaths {
absoluteProjectDir: string;
absoluteOutputPath: string;
}
const DEFAULT_RENDERS_DIR =
process.env.PRODUCER_RENDERS_DIR ??
// fileURLToPath (not URL.pathname): on Windows .pathname is "/D:/..." which
// resolves to a bogus renders dir.
nodeResolve(fileURLToPath(import.meta.url), "../../..", "renders");
type PathModuleLike = {
resolve: (...segments: string[]) => string;
relative: (from: string, to: string) => string;
isAbsolute: (path: string) => boolean;
};
type IsPathInsideOptions = {
pathModule?: PathModuleLike;
};
/**
* Cross-platform containment check.
*
* `child.startsWith(parent + "/")` breaks on Windows because the path
* separator is `\`, not `/`. This helper uses `path.relative()` which
* normalises separators per-platform and returns `..`-prefixed output
* for out-of-tree paths — the canonical way to ask "is `child` inside
* `parent`?" on every supported OS.
*
* Both inputs are normalised via `resolve()` so callers don't need to.
* Equality counts as "inside" (a directory contains itself).
*/
export function isPathInside(
childPath: string,
parentPath: string,
options: IsPathInsideOptions = {},
): boolean {
const resolvePath = options.pathModule?.resolve ?? nodeResolve;
const relativePath = options.pathModule?.relative ?? nodeRelative;
const isPathAbsolute = options.pathModule?.isAbsolute ?? nodeIsAbsolute;
const absChild = resolvePath(childPath);
const absParent = resolvePath(parentPath);
if (absChild === absParent) return true;
const rel = relativePath(absParent, absChild);
// `relative()` returns "" when paths are equal, ".." or "..\\foo" when child
// is above the parent, and an absolute path when they live on different
// drives/volumes (Windows) — none of which count as "inside".
return rel !== "" && !rel.startsWith("..") && !isPathAbsolute(rel);
}
/**
* Build a safe, cross-platform relative key for an absolute asset path
* that lives outside the project directory.
*
* Windows absolute paths (`D:\coder\assets\segment.wav`) break two
* downstream assumptions when passed as-is to `path.join(compileDir, key)`:
* 1. The drive letter makes the path absolute, so `join()` silently
* discards `compileDir`.
* 2. The backslashes and colon are invalid inside some OS sandboxes
* and HTTP URL encodings.
*
* We sanitise into `hf-ext/...` form using forward slashes, stripping
* the colon after drive letters, the Windows extended-length prefix
* (`\\?\`), and the UNC prefix (`\\server\share\`). The result is a
* pure relative path that joins cleanly on every platform.
*
* Caller contract: `absPath` is expected to be canonical — typically
* produced by `path.resolve()` upstream. This helper does NOT strip
* `..` components on its own. `isPathInside` at copy time is the
* defensive backstop.
*/
export function toExternalAssetKey(absPath: string): string {
// Short-circuit if already a sanitised key — prevents double-wrap
// producing `hf-ext/hf-ext/...`.
if (absPath.startsWith("hf-ext/")) return absPath;
// Normalise to forward slashes first so every subsequent pattern is
// separator-agnostic.
let normalised = absPath.replace(/\\/g, "/");
// Windows extended-length prefix: `//?/` (was `\\?\`). Strip entirely —
// the actual path follows. `//?/UNC/server/share/...` is the UNC
// extended-length form; normalise to match the UNC branch below.
normalised = normalised.replace(/^\/\/\?\/UNC\//i, "//");
normalised = normalised.replace(/^\/\/\?\//, "");
// UNC paths (`\\server\share\file`). Collapse to
// `unc/server/share/file` so two different servers can't collide
// under the same relative key.
normalised = normalised.replace(/^\/\/([^/]+)\//, "unc/$1/");
// Strip remaining leading forward slashes (Unix absolute).
normalised = normalised.replace(/^\/+/, "");
// Strip a leading drive-letter colon (Windows: "D:/coder" → "D/coder").
normalised = normalised.replace(/^([A-Za-z]):\/?/, "$1/");
return "hf-ext/" + normalised;
}
export function formatCaptureFrameName(index: number, ext: string): string {
return `frame_${String(index).padStart(6, "0")}.${ext}`;
}
export function formatExportFrameName(index: number, ext: string): string {
return `frame_${String(index + 1).padStart(6, "0")}.${ext}`;
}
export function resolveRenderPaths(
projectDir: string,
outputPath: string | null | undefined,
rendersDir: string = DEFAULT_RENDERS_DIR,
): RenderPaths {
const absoluteProjectDir = nodeResolve(projectDir);
const projectName = basename(absoluteProjectDir);
const resolvedOutputPath = outputPath ?? join(rendersDir, `${projectName}.mp4`);
const absoluteOutputPath = nodeResolve(resolvedOutputPath);
return { absoluteProjectDir, absoluteOutputPath };
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Simple async semaphore for limiting concurrent operations.
*/
export class Semaphore {
private queue: Array<() => void> = [];
private active = 0;
constructor(private readonly maxConcurrent: number) {}
async acquire(): Promise<() => void> {
if (this.active < this.maxConcurrent) {
this.active++;
return () => this.release();
}
return new Promise<() => void>((resolve) => {
this.queue.push(() => {
this.active++;
resolve(() => this.release());
});
});
}
private release(): void {
this.active--;
const next = this.queue.shift();
if (next) next();
}
/** Current number of active slots. */
get activeCount(): number {
return this.active;
}
/** Number of waiters in the queue. */
get waitingCount(): number {
return this.queue.length;
}
}
@@ -0,0 +1,157 @@
import { mkdtempSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getFfmpegBinary } from "@hyperframes/engine";
import { checkStreamDurationParity, MAX_STREAM_DRIFT_SECONDS } from "../regression-harness.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function mktmp(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-parity-"));
tempDirs.push(dir);
return dir;
}
function ffmpeg(args: string[]): void {
execFileSync(getFfmpegBinary(), ["-y", "-hide_banner", "-loglevel", "error", ...args], {
timeout: 30_000,
});
}
function muxWithMatchingDurations(dir: string): string {
const out = join(dir, "matched.mp4");
const video = join(dir, "v.mp4");
const audio = join(dir, "a.aac");
ffmpeg([
"-f",
"lavfi",
"-i",
"color=c=blue:s=64x64:d=5:r=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
video,
]);
ffmpeg([
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=5",
"-c:a",
"aac",
"-b:a",
"64k",
audio,
]);
ffmpeg([
"-i",
video,
"-i",
audio,
"-c:v",
"copy",
"-c:a",
"copy",
"-movflags",
"+faststart",
out,
]);
return out;
}
function muxWithTruncatedVideo(dir: string): string {
const out = join(dir, "truncated.mp4");
const video = join(dir, "v-short.mp4");
const audio = join(dir, "a-long.aac");
ffmpeg([
"-f",
"lavfi",
"-i",
"color=c=red:s=64x64:d=2:r=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
video,
]);
ffmpeg([
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=10",
"-c:a",
"aac",
"-b:a",
"64k",
audio,
]);
ffmpeg([
"-i",
video,
"-i",
audio,
"-c:v",
"copy",
"-c:a",
"copy",
"-movflags",
"+faststart",
out,
]);
return out;
}
describe("checkStreamDurationParity", () => {
it("passes when video and audio durations match", async () => {
const dir = mktmp();
const video = muxWithMatchingDurations(dir);
const result = await checkStreamDurationParity(video);
expect(result).not.toBeNull();
expect(result!.passed).toBe(true);
expect(result!.driftSeconds).toBeLessThanOrEqual(MAX_STREAM_DRIFT_SECONDS);
});
it("fails when video is truncated relative to audio (regression #1648)", async () => {
const dir = mktmp();
const video = muxWithTruncatedVideo(dir);
const result = await checkStreamDurationParity(video);
expect(result).not.toBeNull();
expect(result!.passed).toBe(false);
expect(result!.videoDurationSeconds).toBeLessThan(3);
expect(result!.audioDurationSeconds).toBeGreaterThan(9);
expect(result!.driftSeconds).toBeGreaterThan(MAX_STREAM_DRIFT_SECONDS);
});
it("returns null for video-only files (no audio stream)", async () => {
const dir = mktmp();
const video = join(dir, "silent.mp4");
ffmpeg([
"-f",
"lavfi",
"-i",
"color=c=green:s=64x64:d=3:r=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
video,
]);
const result = await checkStreamDurationParity(video);
expect(result).toBeNull();
});
});
@@ -0,0 +1,5 @@
/**
* Re-exported from @hyperframes/engine.
* @see engine/src/utils/urlDownloader.ts for implementation.
*/
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "@hyperframes/engine";