mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user