Merge branch 'main' into via/studio-5433-html-sniff-defense

Both conflicts were import/export unions in the engine package, resolved by
keeping both sides:

- packages/engine/src/index.ts — main widened the urlDownloader re-export
  (fetchPublicHttpsText, safeDownloadUrlIdentity, writeUrlDownloadTelemetry and
  their types) while this branch added the notMediaPayload exports.
- packages/engine/src/services/audioMixer.ts — main added UrlDownloadError and
  writeUrlDownloadTelemetry to the urlDownloader import; this branch added
  isNotMediaPayload.

In audioMixer's prepare path both intents compose in order: main's download
telemetry and typed download failure, then the STUDIO-5433 non-media sniff
before the probe.
This commit is contained in:
Vance Ingalls
2026-08-07 13:51:49 -07:00
506 changed files with 24214 additions and 11658 deletions
@@ -52,6 +52,7 @@ describe("processCompositionAudio", () => {
const tempDirs: string[] = [];
afterEach(() => {
vi.unstubAllGlobals();
runFfmpegMock.mockClear();
extractAudioMetadataMock.mockReset();
extractAudioMetadataMock.mockResolvedValue({
@@ -66,6 +67,44 @@ describe("processCompositionAudio", () => {
}
});
it("classifies an HTML-as-200 audio source as deterministic user input", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
tempDirs.push(baseDir, workDir);
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("<!doctype html><html><body>denied</body></html>"));
vi.stubGlobal("fetch", fetchMock);
const result = await processCompositionAudio(
[
{
id: "remote-voice",
src: "https://cdn.example/voice",
start: 0,
end: 2,
mediaStart: 0,
layer: 0,
volume: 1,
type: "audio",
},
],
baseDir,
workDir,
join(baseDir, "out.m4a"),
2,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.failures).toEqual([
expect.objectContaining({
stage: "download",
owner: "user",
retryable: false,
}),
]);
});
it.each([
{
message: "AbortError: ffprobe operation aborted",
+22 -10
View File
@@ -10,7 +10,12 @@ import { join, dirname } from "path";
import { parseHTML } from "linkedom";
import { extractAudioMetadata } from "../utils/ffprobe.js";
import { isNotMediaPayload } from "../utils/notMediaPayload.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import {
downloadToTemp,
isHttpUrl,
UrlDownloadError,
writeUrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -242,16 +247,23 @@ function probeFailure(message: string, elementId: string): AudioProcessingFailur
};
}
function downloadFailure(message: string, elementId: string): AudioProcessingFailure {
function downloadFailure(error: unknown, elementId: string): AudioProcessingFailure {
const message = error instanceof Error ? error.message : String(error);
const invalidSource =
/(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
message,
);
error instanceof UrlDownloadError
? error.kind === "http_not_found" ||
error.kind === "http_rejected" ||
error.kind === "invalid_payload" ||
error.kind === "cancelled"
: /(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
message,
);
const retryable = error instanceof UrlDownloadError ? error.retryable : !invalidSource;
return {
stage: "download",
reason: "download_failed",
owner: invalidSource ? "user" : "system",
retryable: !invalidSource,
retryable,
elementId,
detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
};
@@ -713,11 +725,11 @@ export async function processCompositionAudio(
if (isHttpUrl(srcPath)) {
try {
srcPath = await downloadToTemp(srcPath, workDir);
srcPath = await downloadToTemp(srcPath, workDir, undefined, signal, undefined, {
onTelemetry: writeUrlDownloadTelemetry,
});
} catch (err: unknown) {
failures.push(
downloadFailure(err instanceof Error ? err.message : String(err), element.id),
);
failures.push(downloadFailure(err, element.id));
return;
}
}
@@ -12,7 +12,10 @@
import { describe, expect, it } from "vitest";
import {
LOCKED_WARMUP_TICKS,
deriveBeginFrameTimelineTicks,
deriveBeginFrameTimeTicks,
driveWarmupTicks,
prepareBeginFrameTimeline,
warmupFrameTimeTicks,
type WarmupTickState,
} from "./frameCapture.js";
@@ -172,3 +175,81 @@ describe("driveWarmupTicks — locked", () => {
expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33);
});
});
describe("deriveBeginFrameTimeTicks", () => {
const warmupIntervalMs = 33;
const state: WarmupTickState = {
running: false,
ticks: LOCKED_WARMUP_TICKS,
};
const expectMonotonicTimeline = (
warmupState: WarmupTickState,
captureIntervalMs: number,
): void => {
const timeline = deriveBeginFrameTimelineTicks(
warmupState,
warmupIntervalMs,
captureIntervalMs,
);
const lastWarmupTick = (warmupState.ticks - 1) * warmupIntervalMs;
expect(timeline.commit).toBeGreaterThan(lastWarmupTick);
expect(timeline.probe).toBeGreaterThan(timeline.commit);
expect(timeline.capture).toBeGreaterThan(timeline.probe);
};
it.each([60, 120, 240, 60_000 / 1001])(
"keeps warmup, commit, probe, and capture monotonic at %ifps",
(fps) => {
expectMonotonicTimeline(state, 1000 / fps);
},
);
it.each([24, 30, 30_000 / 1001, 31, 32])(
"preserves the legacy capture baseline when it is already monotonic at %ifps",
(fps) => {
const captureIntervalMs = 1000 / fps;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
(LOCKED_WARMUP_TICKS + 10) * captureIntervalMs,
);
},
);
it("keeps an unlocked warmup timeline monotonic", () => {
const unlockedState: WarmupTickState = { running: false, ticks: 7 };
expectMonotonicTimeline(unlockedState, 1000 / 60);
});
it("raises the capture baseline only when the warmup clock is ahead", () => {
const captureIntervalMs = 1000 / 60;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs,
);
});
it("raises the baseline just above the safe legacy boundary", () => {
const captureIntervalMs = 1000 / 33;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs,
);
});
it("wires the canonical capture and commit ticks into session initialization", () => {
const session = {
beginFrameIntervalMs: 1000 / 60,
beginFrameTimeTicks: 0,
};
const prepared = prepareBeginFrameTimeline(session, state, warmupIntervalMs);
expect(session.beginFrameTimeTicks).toBe(prepared.timeline.capture);
expect(prepared.commitParams).toEqual({
frameTimeTicks: prepared.timeline.commit,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
});
expect(prepared.timeline.commit).toBeGreaterThan((LOCKED_WARMUP_TICKS - 1) * warmupIntervalMs);
});
});
+106 -9
View File
@@ -632,6 +632,101 @@ export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number)
return state.ticks * intervalMs;
}
const BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS = 10;
const BEGIN_FRAME_COMMIT_LEAD_INTERVALS = 6;
const BEGIN_FRAME_PROBE_LEAD_INTERVALS = 5;
export interface BeginFrameTimelineTicks {
capture: number;
commit: number;
probe: number;
}
export interface PreparedBeginFrameTimeline {
commitParams: {
frameTimeTicks: number;
interval: number;
noDisplayUpdates: false;
};
timeline: BeginFrameTimelineTicks;
}
/**
* Place frame zero after the warmup clock while retaining capture-rate-sized
* headroom for the visual commit and liveness probe ticks that precede it.
*
* The warmup and capture intervals can differ (warmup currently runs at a
* fixed 33ms). Basing both clocks on the capture interval would move time
* backwards whenever the output frame rate is faster than the warmup rate.
*/
export function deriveBeginFrameTimeTicks(
state: WarmupTickState,
warmupIntervalMs: number,
captureIntervalMs: number,
): number {
const legacyCaptureTimeTicks =
(state.ticks + BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS) * captureIntervalMs;
const legacyCommitTimeTicks = deriveBeginFrameCommitTimeTicks(
legacyCaptureTimeTicks,
captureIntervalMs,
);
const lastWarmupTimeTicks = Math.max(0, state.ticks - 1) * warmupIntervalMs;
if (legacyCommitTimeTicks > lastWarmupTimeTicks) return legacyCaptureTimeTicks;
const monotonicCaptureTimeTicks =
warmupFrameTimeTicks(state, warmupIntervalMs) +
BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS * captureIntervalMs;
return monotonicCaptureTimeTicks;
}
function deriveBeginFrameCommitTimeTicks(
captureTimeTicks: number,
captureIntervalMs: number,
): number {
return captureTimeTicks - BEGIN_FRAME_COMMIT_LEAD_INTERVALS * captureIntervalMs;
}
export function deriveBeginFrameProbeTimeTicks(
captureTimeTicks: number,
captureIntervalMs: number,
): number {
return Math.max(0, captureTimeTicks - BEGIN_FRAME_PROBE_LEAD_INTERVALS * captureIntervalMs);
}
export function deriveBeginFrameTimelineTicks(
state: WarmupTickState,
warmupIntervalMs: number,
captureIntervalMs: number,
): BeginFrameTimelineTicks {
const capture = deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs);
return {
capture,
commit: deriveBeginFrameCommitTimeTicks(capture, captureIntervalMs),
probe: deriveBeginFrameProbeTimeTicks(capture, captureIntervalMs),
};
}
export function prepareBeginFrameTimeline(
session: Pick<CaptureSession, "beginFrameIntervalMs" | "beginFrameTimeTicks">,
state: WarmupTickState,
warmupIntervalMs: number,
): PreparedBeginFrameTimeline {
const timeline = deriveBeginFrameTimelineTicks(
state,
warmupIntervalMs,
session.beginFrameIntervalMs,
);
session.beginFrameTimeTicks = timeline.capture;
return {
timeline,
commitParams: {
frameTimeTicks: timeline.commit,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
},
};
}
export async function driveWarmupTicks(
options: WarmupTickOptions,
state: WarmupTickState,
@@ -2277,10 +2372,16 @@ export async function initializeSession(session: CaptureSession): Promise<void>
warmupState.running = false;
await warmupLoopPromise.catch(() => {});
// Set base frame time ticks past warmup range. Locked mode pins to the
// constant so chunk workers on different hosts compute the same baseline.
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
// Preserve the legacy baseline when it is already safe. Otherwise continue
// from the clock actually used by warmup, then reserve capture-rate headroom
// for the commit and probe ticks below. Locked mode still produces an
// identical timeline on every host because its driver ends at exactly
// LOCKED_WARMUP_TICKS.
const preparedBeginFrameTimeline = prepareBeginFrameTimeline(
session,
warmupState,
warmupIntervalMs,
);
// drawElement or transparent-background init — runs after page is fully ready.
// IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame
@@ -2318,11 +2419,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// `-6·interval` the order stays warmup < commit < probe < capture.
await ensureRenderFrameSiblings(page);
const commitCdp = await getCdpSession(page);
await commitCdp.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: session.beginFrameTimeTicks - 6 * session.beginFrameIntervalMs,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
});
await commitCdp.send("HeadlessExperimental.beginFrame", preparedBeginFrameTimeline.commitParams);
session.isInitialized = true;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { classifyFfmpegSpawnError } from "./videoFrameExtractor.js";
import { UrlDownloadError } from "../utils/urlDownloader.js";
import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js";
describe("classifyFfmpegSpawnError", () => {
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
@@ -18,3 +19,21 @@ describe("classifyFfmpegSpawnError", () => {
});
});
});
describe("classifyVideoExtractionError download integrity", () => {
it("keeps deterministic HTML payloads non-retryable and user-owned as invalid media", () => {
const classified = classifyVideoExtractionError(
new UrlDownloadError("invalid_payload", false, "HTML payload"),
);
expect(classified).toMatchObject({ kind: "invalid_media", retryable: false });
});
it.each(["range_protocol", "length_mismatch", "hash_mismatch"] as const)(
"keeps %s retryable after the downloader's one clean refetch is exhausted",
(kind) => {
expect(
classifyVideoExtractionError(new UrlDownloadError(kind, true, "integrity failure")),
).toMatchObject({ kind: "download_transient", retryable: true });
},
);
});
@@ -28,7 +28,12 @@ import {
isHdrColorSpace as isHdrColorSpaceUtil,
type HdrTransfer,
} from "../utils/hdr.js";
import { downloadToTemp, isHttpUrl, UrlDownloadError } from "../utils/urlDownloader.js";
import {
downloadToTemp,
isHttpUrl,
UrlDownloadError,
writeUrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -342,6 +347,14 @@ export function classifyVideoExtractionError(error: unknown): VideoSourceExtract
diagnostic,
);
}
if (error.kind === "invalid_payload") {
return new VideoSourceExtractionError(
"invalid_media",
false,
"Video source download returned a non-media payload",
diagnostic,
);
}
if (error.retryable) {
return new VideoSourceExtractionError(
"download_transient",
@@ -1424,8 +1437,13 @@ export async function extractAllVideoFrames(
if (isHttpUrl(videoPath)) {
const downloadDir = join(options.outputDir, "_downloads");
mkdirSync(downloadDir, { recursive: true });
videoPath = await downloadToTemp(videoPath, downloadDir, undefined, signal, () =>
recordTransientRetries(1),
videoPath = await downloadToTemp(
videoPath,
downloadDir,
undefined,
signal,
() => recordTransientRetries(1),
{ onTelemetry: writeUrlDownloadTelemetry },
);
}