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
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:
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user