mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(producer): reject asset media type mismatches (#2937)
* fix(producer): reject asset media type mismatches * fix(engine): document read-only AVIF probe * fix(engine): bound read-only AVIF brand probe * fix(producer): make media preflight lifecycle-safe * fix(producer): reconcile runtime media before preflight * fix(engine): avoid writable file-open detection * fix(producer): close runtime media preflight gaps
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractSafeRenderErrorCode } from "./server.js";
|
||||
import { extractSafeRenderErrorCode, extractSafeRenderErrorMetadata } from "./server.js";
|
||||
import { VideoExtractionStageError } from "./services/render/stages/extractVideosStage.js";
|
||||
import { AssetMediaTypeMismatchError } from "./services/assetMediaType.js";
|
||||
|
||||
describe("extractSafeRenderErrorCode", () => {
|
||||
it("preserves allowlisted typed extraction codes", () => {
|
||||
@@ -24,6 +25,21 @@ describe("extractSafeRenderErrorCode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("transports stable ownership and retry policy for media-type mismatches", () => {
|
||||
const error = new AssetMediaTypeMismatchError([
|
||||
{ expected: "video", detected: "image", elementFingerprint: "0123456789abcdef" },
|
||||
]);
|
||||
expect(error.code).toBe("ASSET_MEDIA_TYPE_MISMATCH");
|
||||
expect(error.owner).toBe("user");
|
||||
expect(error.retryable).toBe(false);
|
||||
expect(extractSafeRenderErrorCode(error)).toBe("ASSET_MEDIA_TYPE_MISMATCH");
|
||||
expect(extractSafeRenderErrorMetadata(error)).toEqual({
|
||||
errorCode: "ASSET_MEDIA_TYPE_MISMATCH",
|
||||
errorOwner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not forward arbitrary codes or parse message text", () => {
|
||||
expect(extractSafeRenderErrorCode({ code: "INTERNAL_ERROR" })).toBeUndefined();
|
||||
expect(
|
||||
|
||||
@@ -120,6 +120,7 @@ interface PreparedRenderInput {
|
||||
|
||||
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
|
||||
const SAFE_RENDER_ERROR_CODES = new Set<string>([
|
||||
"ASSET_MEDIA_TYPE_MISMATCH",
|
||||
"INVALID_VIDEO_METADATA",
|
||||
"VIDEO_SOURCE_UNRENDERABLE",
|
||||
"VIDEO_EXTRACTION_FAILED",
|
||||
@@ -135,6 +136,27 @@ export function extractSafeRenderErrorCode(error: unknown): string | undefined {
|
||||
return typeof code === "string" && SAFE_RENDER_ERROR_CODES.has(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export interface SafeRenderErrorMetadata {
|
||||
errorCode: string;
|
||||
errorOwner?: "system" | "user";
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
/** Additive bounded metadata for typed producer failures. */
|
||||
export function extractSafeRenderErrorMetadata(
|
||||
error: unknown,
|
||||
): SafeRenderErrorMetadata | undefined {
|
||||
const errorCode = extractSafeRenderErrorCode(error);
|
||||
if (!errorCode || typeof error !== "object" || error === null) return undefined;
|
||||
const owner = "owner" in error ? error.owner : undefined;
|
||||
const retryable = "retryable" in error ? error.retryable : undefined;
|
||||
return {
|
||||
errorCode,
|
||||
errorOwner: owner === "user" || owner === "system" ? owner : undefined,
|
||||
retryable: typeof retryable === "boolean" ? retryable : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseServerFps(value: unknown): RenderInput["fps"] {
|
||||
if (typeof value !== "number" && typeof value !== "string") return DEFAULT_SERVER_FPS;
|
||||
const parsed = parseFps(value);
|
||||
@@ -585,7 +607,7 @@ async function writeRenderStreamFailure(input: {
|
||||
return;
|
||||
}
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorCode = extractSafeRenderErrorCode(error);
|
||||
const safeError = extractSafeRenderErrorMetadata(error);
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
log.error("render-stream failed", {
|
||||
requestId,
|
||||
@@ -598,7 +620,9 @@ async function writeRenderStreamFailure(input: {
|
||||
type: "error",
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
errorCode,
|
||||
errorCode: safeError?.errorCode,
|
||||
errorOwner: safeError?.errorOwner,
|
||||
retryable: safeError?.retryable,
|
||||
stage: job.currentStage,
|
||||
elapsedMs,
|
||||
errorDetails: job.errorDetails ?? null,
|
||||
@@ -747,7 +771,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - t0;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorCode = extractSafeRenderErrorCode(error);
|
||||
const safeError = extractSafeRenderErrorMetadata(error);
|
||||
log.error("render failed", {
|
||||
requestId,
|
||||
durationMs,
|
||||
@@ -759,7 +783,9 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
||||
success: false,
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
errorCode,
|
||||
errorCode: safeError?.errorCode,
|
||||
errorOwner: safeError?.errorOwner,
|
||||
retryable: safeError?.retryable,
|
||||
stage: job.currentStage,
|
||||
durationMs,
|
||||
errorDetails: job.errorDetails ?? null,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ASSET_MEDIA_TYPE_MISMATCH,
|
||||
AssetMediaTypeMismatchError,
|
||||
preflightCompositionAssetMediaTypes,
|
||||
} from "./assetMediaType.js";
|
||||
import { synthesizeMediaFixture } from "./mediaTypeTestFixtures.js";
|
||||
|
||||
describe("preflightCompositionAssetMediaTypes", () => {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "hf-media-type-preflight-"));
|
||||
const projectDir = join(fixtureDir, "project");
|
||||
const compiledDir = join(fixtureDir, "compiled");
|
||||
const stillPath = join(projectDir, "extensionless-still");
|
||||
const videoPath = join(projectDir, "extensionless-video");
|
||||
const audioPath = join(projectDir, "extensionless-audio");
|
||||
const mixedPath = join(projectDir, "extensionless-mixed");
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
mkdirSync(compiledDir, { recursive: true });
|
||||
synthesizeMediaFixture([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=32x32:d=0.1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-c:v",
|
||||
"png",
|
||||
"-f",
|
||||
"image2",
|
||||
stillPath,
|
||||
]);
|
||||
synthesizeMediaFixture([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=32x32:d=1:r=30",
|
||||
"-c:v",
|
||||
"mpeg4",
|
||||
"-f",
|
||||
"mp4",
|
||||
videoPath,
|
||||
]);
|
||||
synthesizeMediaFixture([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
"-f",
|
||||
"wav",
|
||||
audioPath,
|
||||
]);
|
||||
synthesizeMediaFixture([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=32x32:d=1:r=30",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=880:duration=1",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
"mpeg4",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-f",
|
||||
"mp4",
|
||||
mixedPath,
|
||||
]);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (existsSync(fixtureDir)) rmSync(fixtureDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function composition(input: { videoSrc?: string; audioSrc?: string; imageSrc?: string }) {
|
||||
return {
|
||||
videos: input.videoSrc
|
||||
? [
|
||||
{
|
||||
id: "video-element",
|
||||
src: input.videoSrc,
|
||||
start: 0,
|
||||
end: 1,
|
||||
mediaStart: 0,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
audios: input.audioSrc
|
||||
? [
|
||||
{
|
||||
id: "audio-element",
|
||||
src: input.audioSrc,
|
||||
start: 0,
|
||||
end: 1,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
type: "audio" as const,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
images: input.imageSrc
|
||||
? [{ id: "image-element", src: input.imageSrc, start: 0, end: 1 }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function run(
|
||||
input: { videoSrc?: string; audioSrc?: string; imageSrc?: string },
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return preflightCompositionAssetMediaTypes({
|
||||
projectDir,
|
||||
compiledDir,
|
||||
composition: composition(input),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts valid extensionless image, video, and audio assets", async () => {
|
||||
await expect(
|
||||
run({
|
||||
imageSrc: "extensionless-still",
|
||||
videoSrc: "extensionless-video",
|
||||
audioSrc: "extensionless-audio",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a mixed audio/video container for an audio element", async () => {
|
||||
await expect(run({ audioSrc: "extensionless-mixed" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "image under video", input: { videoSrc: "extensionless-still" }, detected: "image" },
|
||||
{ name: "audio under video", input: { videoSrc: "extensionless-audio" }, detected: "audio" },
|
||||
{ name: "video under image", input: { imageSrc: "extensionless-video" }, detected: "video" },
|
||||
{ name: "audio under image", input: { imageSrc: "extensionless-audio" }, detected: "audio" },
|
||||
{ name: "image under audio", input: { audioSrc: "extensionless-still" }, detected: "image" },
|
||||
{
|
||||
name: "silent video under audio",
|
||||
input: { audioSrc: "extensionless-video" },
|
||||
detected: "video",
|
||||
},
|
||||
])("fails deterministically for $name", async ({ input, detected }) => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await run(input);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AssetMediaTypeMismatchError);
|
||||
expect(caught).toMatchObject({
|
||||
code: ASSET_MEDIA_TYPE_MISMATCH,
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
mismatches: [expect.objectContaining({ detected })],
|
||||
});
|
||||
const serialized = JSON.stringify(caught);
|
||||
expect(serialized).not.toContain(fixtureDir);
|
||||
expect(serialized).not.toContain("extensionless-");
|
||||
expect((caught as Error).message).not.toContain("video-element");
|
||||
expect((caught as Error).message).not.toContain("audio-element");
|
||||
expect((caught as Error).message).not.toContain("image-element");
|
||||
});
|
||||
|
||||
it("catches the zero-video image-probe shape before extraction", async () => {
|
||||
const mismatched = composition({ imageSrc: "extensionless-audio" });
|
||||
expect(mismatched.videos).toHaveLength(0);
|
||||
await expect(
|
||||
preflightCompositionAssetMediaTypes({ projectDir, compiledDir, composition: mismatched }),
|
||||
).rejects.toMatchObject({
|
||||
code: ASSET_MEDIA_TYPE_MISMATCH,
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not relabel deterministic missing or corrupt media as a type mismatch", async () => {
|
||||
writeFileSync(join(projectDir, "corrupt-media"), "not a media container");
|
||||
await expect(run({ videoSrc: "missing-media" })).resolves.toBeUndefined();
|
||||
await expect(run({ imageSrc: "corrupt-media" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-probes a path after its media contents are replaced", async () => {
|
||||
const mutablePath = join(projectDir, "mutable-media");
|
||||
const signal = new AbortController().signal;
|
||||
copyFileSync(audioPath, mutablePath);
|
||||
await expect(run({ videoSrc: "mutable-media" }, signal)).rejects.toMatchObject({
|
||||
code: ASSET_MEDIA_TYPE_MISMATCH,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
copyFileSync(videoPath, mutablePath);
|
||||
await expect(run({ videoSrc: "mutable-media" }, signal)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
probeMediaProfile,
|
||||
resolveProjectRelativeSrc,
|
||||
type AudioElement,
|
||||
type ImageElement,
|
||||
type MediaProbeProfile,
|
||||
type VideoElement,
|
||||
} from "@hyperframes/engine";
|
||||
import { withMediaProbeSlot } from "../utils/mediaProbeConcurrency.js";
|
||||
|
||||
export const ASSET_MEDIA_TYPE_MISMATCH = "ASSET_MEDIA_TYPE_MISMATCH" as const;
|
||||
|
||||
export type AssetElementMediaType = "audio" | "image" | "video";
|
||||
export type DetectedAssetMediaType = AssetElementMediaType | "unknown";
|
||||
|
||||
export interface AssetMediaTypeMismatch {
|
||||
expected: AssetElementMediaType;
|
||||
detected: DetectedAssetMediaType;
|
||||
/** Stable, bounded correlation key. Never the raw element id or source. */
|
||||
elementFingerprint: string;
|
||||
}
|
||||
|
||||
export class AssetMediaTypeMismatchError extends Error {
|
||||
readonly code = ASSET_MEDIA_TYPE_MISMATCH;
|
||||
readonly owner = "user" as const;
|
||||
readonly retryable = false as const;
|
||||
readonly mismatches: readonly AssetMediaTypeMismatch[];
|
||||
|
||||
constructor(mismatches: readonly AssetMediaTypeMismatch[]) {
|
||||
const expectedKinds = [...new Set(mismatches.map((item) => item.expected))].sort().join(",");
|
||||
super(
|
||||
`${mismatches.length} composition asset(s) do not match their authored media element type` +
|
||||
(expectedKinds ? ` (expected: ${expectedKinds})` : ""),
|
||||
);
|
||||
this.name = "AssetMediaTypeMismatchError";
|
||||
this.mismatches = mismatches;
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprintElementId(elementId: string): string {
|
||||
return createHash("sha256").update(elementId).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function detectedAssetMediaType(profile: MediaProbeProfile): DetectedAssetMediaType {
|
||||
if (profile.visualKind === "moving") return "video";
|
||||
if (profile.visualKind === "still") return "image";
|
||||
if (profile.hasAudioStream) return "audio";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function mediaProfileMatchesElementType(
|
||||
expected: AssetElementMediaType,
|
||||
profile: MediaProbeProfile,
|
||||
): boolean {
|
||||
if (expected === "audio") return profile.hasAudioStream;
|
||||
if (expected === "image") return profile.visualKind === "still";
|
||||
return profile.visualKind === "moving";
|
||||
}
|
||||
|
||||
export function assertAssetMediaTypeProfile(
|
||||
expected: AssetElementMediaType,
|
||||
profile: MediaProbeProfile,
|
||||
elementIdentity: string,
|
||||
): void {
|
||||
if (mediaProfileMatchesElementType(expected, profile)) return;
|
||||
throw new AssetMediaTypeMismatchError([
|
||||
{
|
||||
expected,
|
||||
detected: detectedAssetMediaType(profile),
|
||||
elementFingerprint: fingerprintElementId(elementIdentity),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
interface CompositionMediaAssets {
|
||||
videos: readonly VideoElement[];
|
||||
audios: readonly AudioElement[];
|
||||
images: readonly ImageElement[];
|
||||
}
|
||||
|
||||
interface MediaReference {
|
||||
id: string;
|
||||
src: string;
|
||||
expected: AssetElementMediaType;
|
||||
}
|
||||
|
||||
function isRemoteOrInlineSource(src: string): boolean {
|
||||
return /^(?:https?:|data:|blob:|about:)/i.test(src.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail early when a successfully-probed local asset cannot satisfy the HTML
|
||||
* element that owns it. Missing, corrupt, remote-at-runtime, and unprobeable
|
||||
* inputs deliberately remain untouched so their existing source/download/
|
||||
* invalid-media classification is preserved downstream.
|
||||
*/
|
||||
export async function preflightCompositionAssetMediaTypes(input: {
|
||||
projectDir: string;
|
||||
compiledDir: string;
|
||||
composition: CompositionMediaAssets;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const references: MediaReference[] = [
|
||||
...input.composition.videos.map((asset) => ({
|
||||
id: asset.id,
|
||||
src: asset.src,
|
||||
expected: "video" as const,
|
||||
})),
|
||||
...input.composition.audios.map((asset) => ({
|
||||
id: asset.id,
|
||||
src: asset.src,
|
||||
expected: "audio" as const,
|
||||
})),
|
||||
...input.composition.images.map((asset) => ({
|
||||
id: asset.id,
|
||||
src: asset.src,
|
||||
expected: "image" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
const byPath = new Map<string, MediaReference[]>();
|
||||
for (const reference of references) {
|
||||
if (!reference.src || isRemoteOrInlineSource(reference.src)) continue;
|
||||
const resolvedPath = resolveProjectRelativeSrc(
|
||||
reference.src,
|
||||
input.projectDir,
|
||||
input.compiledDir,
|
||||
);
|
||||
if (!existsSync(resolvedPath)) continue;
|
||||
byPath.set(resolvedPath, [...(byPath.get(resolvedPath) ?? []), reference]);
|
||||
}
|
||||
|
||||
const mismatches: AssetMediaTypeMismatch[] = [];
|
||||
const entries = [...byPath];
|
||||
await Promise.all(
|
||||
entries.map(([resolvedPath, pathReferences]) =>
|
||||
withMediaProbeSlot(async () => {
|
||||
let profile: MediaProbeProfile;
|
||||
try {
|
||||
profile = await probeMediaProfile(resolvedPath, { signal: input.signal });
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted) throw input.signal.reason ?? error;
|
||||
return;
|
||||
}
|
||||
for (const reference of pathReferences) {
|
||||
if (mediaProfileMatchesElementType(reference.expected, profile)) continue;
|
||||
mismatches.push({
|
||||
expected: reference.expected,
|
||||
detected: detectedAssetMediaType(profile),
|
||||
elementFingerprint: fingerprintElementId(reference.id),
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (mismatches.length > 0) throw new AssetMediaTypeMismatchError(mismatches);
|
||||
}
|
||||
@@ -989,6 +989,7 @@ export async function buildLocalExecutionPlan(
|
||||
forceScreenshot,
|
||||
log,
|
||||
assertNotAborted,
|
||||
abortSignal,
|
||||
compiled,
|
||||
composition,
|
||||
width,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
import { synthesizeMediaFixture } from "./mediaTypeTestFixtures.js";
|
||||
import { Semaphore } from "../utils/semaphore.js";
|
||||
import { sharedMediaProbeSemaphore } from "../utils/mediaProbeConcurrency.js";
|
||||
|
||||
describe("compileForRender media-type ownership", () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-compiler-media-type-"));
|
||||
const downloadDir = join(projectDir, "downloads");
|
||||
const stillPath = join(projectDir, "extensionless-still");
|
||||
const videoPath = join(projectDir, "extensionless-video");
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(downloadDir, { recursive: true });
|
||||
synthesizeMediaFixture([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=blue:s=32x32:d=0.1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-c:v",
|
||||
"png",
|
||||
"-f",
|
||||
"image2",
|
||||
stillPath,
|
||||
]);
|
||||
synthesizeMediaFixture([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=32x32:d=1:r=30",
|
||||
"-c:v",
|
||||
"mpeg4",
|
||||
"-f",
|
||||
"mp4",
|
||||
videoPath,
|
||||
]);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (existsSync(projectDir)) rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function compile(mediaMarkup: string) {
|
||||
const htmlPath = join(projectDir, "index.html");
|
||||
writeFileSync(
|
||||
htmlPath,
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180" data-duration="1">
|
||||
${mediaMarkup}
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
return compileForRender(projectDir, htmlPath, downloadDir);
|
||||
}
|
||||
|
||||
it("does not leak image-under-video through the generic no-video-stream path", async () => {
|
||||
await expect(
|
||||
compile('<video id="clip" src="extensionless-still" data-start="0"></video>'),
|
||||
).rejects.toMatchObject({
|
||||
code: "ASSET_MEDIA_TYPE_MISMATCH",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop a silent video authored as audio", async () => {
|
||||
await expect(
|
||||
compile('<audio id="voice" src="extensionless-video" data-start="0"></audio>'),
|
||||
).rejects.toMatchObject({
|
||||
code: "ASSET_MEDIA_TYPE_MISMATCH",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shares a four-wide media-probe limiter across parallel sub-compositions", async () => {
|
||||
const originalAcquire = Semaphore.prototype.acquire;
|
||||
const limiterInstances = new Set<Semaphore>();
|
||||
let maxActive = 0;
|
||||
const acquireSpy = vi
|
||||
.spyOn(Semaphore.prototype, "acquire")
|
||||
.mockImplementation(async function (this: Semaphore) {
|
||||
const release = await originalAcquire.call(this);
|
||||
limiterInstances.add(this);
|
||||
maxActive = Math.max(maxActive, this.activeCount);
|
||||
return release;
|
||||
});
|
||||
|
||||
try {
|
||||
const subCompositions: string[] = [];
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const name = `probe-${index}.html`;
|
||||
writeFileSync(
|
||||
join(projectDir, name),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="sub-${index}" data-width="32" data-height="32" data-duration="1">
|
||||
<video id="video-${index}" src="extensionless-video" data-start="0"></video>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
subCompositions.push(
|
||||
`<div data-composition-id="sub-${index}" data-composition-src="${name}" data-start="0" data-duration="1"></div>`,
|
||||
);
|
||||
}
|
||||
const htmlPath = join(projectDir, "index.html");
|
||||
writeFileSync(
|
||||
htmlPath,
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180" data-duration="1">
|
||||
${subCompositions.join("\n")}
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
await compileForRender(projectDir, htmlPath, downloadDir);
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(sharedMediaProbeSemaphore.activeCount).toBe(0);
|
||||
expect(sharedMediaProbeSemaphore.waitingCount).toBe(0);
|
||||
},
|
||||
{ timeout: 30_000, interval: 5 },
|
||||
);
|
||||
|
||||
expect(limiterInstances.size).toBe(1);
|
||||
expect(maxActive).toBe(4);
|
||||
} finally {
|
||||
acquireSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
detectRenderModeHints,
|
||||
detectShaderTransitionUsage,
|
||||
detectThreeDTransformUsage,
|
||||
discoverMediaFromBrowser,
|
||||
discoverAudioVolumeAutomationFromTimeline,
|
||||
inlineExternalScripts,
|
||||
localizeRemoteMediaSources,
|
||||
@@ -23,6 +24,67 @@ import {
|
||||
} from "./htmlCompiler.js";
|
||||
import { validateNoSystemFonts } from "./render/planValidation.js";
|
||||
|
||||
describe("discoverMediaFromBrowser", () => {
|
||||
async function discover(html: string, currentSrcById: Record<string, string>) {
|
||||
const { document } = parseHTML(html);
|
||||
for (const [id, currentSrc] of Object.entries(currentSrcById)) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) Object.defineProperty(element, "currentSrc", { value: currentSrc });
|
||||
}
|
||||
const previousDocument = Reflect.get(globalThis, "document");
|
||||
Reflect.set(globalThis, "document", document);
|
||||
try {
|
||||
return await discoverMediaFromBrowser({ evaluate: async (collect) => collect() } as never);
|
||||
} finally {
|
||||
if (previousDocument === undefined) Reflect.deleteProperty(globalThis, "document");
|
||||
else Reflect.set(globalThis, "document", previousDocument);
|
||||
}
|
||||
}
|
||||
|
||||
it("uses the selected currentSrc from a variable-bound nested source", async () => {
|
||||
const media = await discover(
|
||||
`<video id="clip" data-start="0" data-end="1">
|
||||
<source src="fallback.mp4" data-var-src="clip_src" />
|
||||
</video>`,
|
||||
{ clip: "https://cdn.example/runtime.webm" },
|
||||
);
|
||||
|
||||
expect(media).toHaveLength(1);
|
||||
expect(media[0]).toMatchObject({
|
||||
id: "clip",
|
||||
tagName: "video",
|
||||
src: "https://cdn.example/runtime.webm",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers variable-bound images with the same generated id as the static parser", async () => {
|
||||
const media = await discover(
|
||||
`<img src="first.png" /><img src="fallback.png" data-var-src="hero_src" />`,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(media).toHaveLength(1);
|
||||
expect(media[0]).toMatchObject({ id: "hf-img-1", tagName: "image" });
|
||||
});
|
||||
|
||||
it("discovers the owning image for a variable-bound picture source", async () => {
|
||||
const media = await discover(
|
||||
`<picture>
|
||||
<source src="fallback.webp" data-var-src="hero_src" />
|
||||
<img id="hero" src="fallback.png" />
|
||||
</picture>`,
|
||||
{ hero: "https://cdn.example/runtime.avif" },
|
||||
);
|
||||
|
||||
expect(media).toHaveLength(1);
|
||||
expect(media[0]).toMatchObject({
|
||||
id: "hero",
|
||||
tagName: "image",
|
||||
src: "https://cdn.example/runtime.avif",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectSdkPositionEditsRenderScript", () => {
|
||||
it("injects before </body> when SDK position-edit markers are present", () => {
|
||||
const html =
|
||||
|
||||
@@ -49,7 +49,9 @@ import {
|
||||
parseAudioElements,
|
||||
type AudioElement,
|
||||
type AudioVolumeKeyframe,
|
||||
type MediaProbeProfile,
|
||||
analyzeKeyframeIntervals,
|
||||
probeMediaProfile,
|
||||
} from "@hyperframes/engine";
|
||||
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import type { Page } from "puppeteer-core";
|
||||
@@ -61,6 +63,8 @@ import { prepareAnimatedGifInputs } from "./animatedGifPrep.js";
|
||||
import { createStudioPositionSeekReapplyScript } from "@hyperframes/studio-server/manual-edits-render-script";
|
||||
import { getPositionEditsRenderScript } from "@hyperframes/core/runtime/position-edits-render";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
import { assertAssetMediaTypeProfile } from "./assetMediaType.js";
|
||||
import { withMediaProbeSlot } from "../utils/mediaProbeConcurrency.js";
|
||||
|
||||
export interface CompiledComposition {
|
||||
html: string;
|
||||
@@ -408,6 +412,7 @@ async function resolveMediaDuration(
|
||||
baseDir: string,
|
||||
downloadDir: string,
|
||||
tagName: string,
|
||||
elementIdentity: string,
|
||||
): Promise<{ duration: number; resolvedPath: string }> {
|
||||
let filePath = src;
|
||||
|
||||
@@ -428,25 +433,39 @@ async function resolveMediaDuration(
|
||||
return { duration: 0, resolvedPath: filePath };
|
||||
}
|
||||
|
||||
let metadata: { durationSeconds: number };
|
||||
if (tagName === "video") {
|
||||
metadata = await extractMediaMetadata(filePath);
|
||||
} else {
|
||||
return withMediaProbeSlot(async () => {
|
||||
let profile: MediaProbeProfile;
|
||||
try {
|
||||
metadata = await extractAudioMetadata(filePath);
|
||||
} catch {
|
||||
// Source file has no audio stream (e.g. a silent video used as an audio src).
|
||||
// Return duration 0 so the element is excluded from the composition gracefully,
|
||||
// matching how missing files and failed downloads are already handled above.
|
||||
return { duration: 0, resolvedPath: filePath };
|
||||
profile = await probeMediaProfile(filePath);
|
||||
} catch (error) {
|
||||
// Preserve the historical split: invalid video sources surface their
|
||||
// probe failure, while invalid/unreadable audio sources resolve to zero
|
||||
// duration and are excluded by the compiler.
|
||||
if (tagName !== "video") return { duration: 0, resolvedPath: filePath };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
assertAssetMediaTypeProfile(tagName === "video" ? "video" : "audio", profile, elementIdentity);
|
||||
|
||||
const fileDuration = metadata.durationSeconds;
|
||||
const effectiveDuration = fileDuration - mediaStart;
|
||||
const duration = effectiveDuration > 0 ? effectiveDuration : fileDuration;
|
||||
let metadata: { durationSeconds: number };
|
||||
if (tagName === "video") {
|
||||
metadata = await extractMediaMetadata(filePath);
|
||||
} else {
|
||||
try {
|
||||
metadata = await extractAudioMetadata(filePath);
|
||||
} catch {
|
||||
// Source file has no audio stream (e.g. a silent video used as an audio src).
|
||||
// Return duration 0 so the element is excluded from the composition gracefully,
|
||||
// matching how missing files and failed downloads are already handled above.
|
||||
return { duration: 0, resolvedPath: filePath };
|
||||
}
|
||||
}
|
||||
|
||||
return { duration, resolvedPath: filePath };
|
||||
const fileDuration = metadata.durationSeconds;
|
||||
const effectiveDuration = fileDuration - mediaStart;
|
||||
const duration = effectiveDuration > 0 ? effectiveDuration : fileDuration;
|
||||
|
||||
return { duration, resolvedPath: filePath };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,7 +489,7 @@ async function compileHtmlFile(
|
||||
// Phase 1: Resolve missing durations (parallel ffprobe)
|
||||
const resolvedResults = await Promise.all(
|
||||
mediaUnresolved.map((el) =>
|
||||
resolveMediaDuration(el.src!, el.mediaStart, baseDir, downloadDir, el.tagName).then(
|
||||
resolveMediaDuration(el.src!, el.mediaStart, baseDir, downloadDir, el.tagName, el.id).then(
|
||||
({ duration }) => ({ id: el.id, duration }),
|
||||
),
|
||||
),
|
||||
@@ -493,6 +512,7 @@ async function compileHtmlFile(
|
||||
baseDir,
|
||||
downloadDir,
|
||||
el.tagName,
|
||||
el.id,
|
||||
);
|
||||
return { id: el.id, tagName: el.tagName, duration: el.duration, maxDuration, src: el.src! };
|
||||
}),
|
||||
@@ -1992,7 +2012,10 @@ export async function compileForRender(
|
||||
if (isHttpUrl(video.src)) continue;
|
||||
const videoPath = resolve(projectDir, video.src);
|
||||
const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
|
||||
Promise.all([analyzeKeyframeIntervals(videoPath), extractMediaMetadata(videoPath)])
|
||||
Promise.all([
|
||||
withMediaProbeSlot(() => analyzeKeyframeIntervals(videoPath)),
|
||||
withMediaProbeSlot(() => extractMediaMetadata(videoPath)),
|
||||
])
|
||||
.then(([analysis, metadata]) => {
|
||||
if (analysis.isProblematic) {
|
||||
defaultLogger.warn(
|
||||
@@ -2056,7 +2079,7 @@ export async function compileForRender(
|
||||
*/
|
||||
export interface BrowserMediaElement {
|
||||
id: string;
|
||||
tagName: "video" | "audio";
|
||||
tagName: "video" | "audio" | "image";
|
||||
src: string;
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -2090,13 +2113,29 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
|
||||
muted: boolean;
|
||||
}[] = [];
|
||||
|
||||
const mediaEls = document.querySelectorAll("video[data-start], audio[data-start]");
|
||||
const autoImageIds = new Map<Element, string>();
|
||||
let autoImageId = 0;
|
||||
document.querySelectorAll("img[src]").forEach((image) => {
|
||||
if (!image.id) autoImageIds.set(image, `hf-img-${autoImageId++}`);
|
||||
});
|
||||
|
||||
const mediaEls = new Set<Element>(
|
||||
document.querySelectorAll("video[data-start], audio[data-start], img[data-var-src]"),
|
||||
);
|
||||
// A variable-bound <picture><source> changes the owning image's currentSrc;
|
||||
// the <img> fallback itself does not necessarily carry data-var-src.
|
||||
document.querySelectorAll("picture source[data-var-src]").forEach((source) => {
|
||||
const image = source.closest("picture")?.querySelector("img");
|
||||
if (image) mediaEls.add(image);
|
||||
});
|
||||
mediaEls.forEach((el) => {
|
||||
const htmlEl = el as HTMLVideoElement | HTMLAudioElement;
|
||||
const id = htmlEl.id;
|
||||
const htmlEl = el as HTMLVideoElement | HTMLAudioElement | HTMLImageElement;
|
||||
const isImage = htmlEl.tagName.toLowerCase() === "img";
|
||||
const id = htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : undefined);
|
||||
if (!id) return;
|
||||
|
||||
const src = htmlEl.src || htmlEl.getAttribute("src") || "";
|
||||
// currentSrc is authoritative for <video>/<audio><source> and responsive images.
|
||||
const src = htmlEl.currentSrc || htmlEl.src || htmlEl.getAttribute("src") || "";
|
||||
const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
|
||||
const end = parseFloat(htmlEl.getAttribute("data-end") || "0");
|
||||
const duration = parseFloat(htmlEl.getAttribute("data-duration") || "0");
|
||||
@@ -2104,11 +2143,13 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
|
||||
const loop = htmlEl.hasAttribute("loop");
|
||||
const hasAudio = htmlEl.getAttribute("data-has-audio") === "true";
|
||||
const volume = parseFloat(htmlEl.getAttribute("data-volume") || "1");
|
||||
const muted = htmlEl.hasAttribute("muted") || htmlEl.muted;
|
||||
const muted =
|
||||
!isImage &&
|
||||
(htmlEl.hasAttribute("muted") || (htmlEl as HTMLVideoElement | HTMLAudioElement).muted);
|
||||
|
||||
results.push({
|
||||
id,
|
||||
tagName: htmlEl.tagName.toLowerCase(),
|
||||
tagName: isImage ? "image" : htmlEl.tagName.toLowerCase(),
|
||||
src,
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const probeTracker = vi.hoisted(() => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let started = 0;
|
||||
let releaseGate = () => {};
|
||||
let gate: Promise<void>;
|
||||
|
||||
const reset = () => {
|
||||
active = 0;
|
||||
maxActive = 0;
|
||||
started = 0;
|
||||
gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
};
|
||||
reset();
|
||||
|
||||
return {
|
||||
reset,
|
||||
release: () => releaseGate(),
|
||||
run: async <T>(value: T): Promise<T> => {
|
||||
active += 1;
|
||||
started += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await gate;
|
||||
active -= 1;
|
||||
return value;
|
||||
},
|
||||
get maxActive() {
|
||||
return maxActive;
|
||||
},
|
||||
get started() {
|
||||
return started;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@hyperframes/engine", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@hyperframes/engine")>();
|
||||
return {
|
||||
...actual,
|
||||
analyzeKeyframeIntervals: async () =>
|
||||
probeTracker.run({ isProblematic: false, maxIntervalSeconds: 0 }),
|
||||
probeMediaProfile: async () =>
|
||||
probeTracker.run({ hasVideoStream: true, hasAudioStream: true, visualKind: "moving" }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/ffprobe.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/ffprobe.js")>();
|
||||
return {
|
||||
...actual,
|
||||
extractMediaMetadata: async () => probeTracker.run({ durationSeconds: 1, isVFR: false }),
|
||||
};
|
||||
});
|
||||
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
import { preflightCompositionAssetMediaTypes } from "./assetMediaType.js";
|
||||
|
||||
describe("aggregate media-probe concurrency", () => {
|
||||
let projectDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
probeTracker.release();
|
||||
if (projectDir) rmSync(projectDir, { recursive: true, force: true });
|
||||
projectDir = undefined;
|
||||
});
|
||||
|
||||
it("caps overlapping compiler advisories and media-type preflight at four probes", async () => {
|
||||
probeTracker.reset();
|
||||
projectDir = mkdtempSync(join(tmpdir(), "hf-media-probe-concurrency-"));
|
||||
const mediaMarkup: string[] = [];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const src = `video-${index}.asset`;
|
||||
writeFileSync(join(projectDir, src), "fixture");
|
||||
mediaMarkup.push(
|
||||
`<video id="video-${index}" src="${src}" loop data-start="0" data-duration="1"></video>`,
|
||||
);
|
||||
}
|
||||
const htmlPath = join(projectDir, "index.html");
|
||||
writeFileSync(
|
||||
htmlPath,
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180" data-duration="1">
|
||||
${mediaMarkup.join("\n")}
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const compiled = await compileForRender(projectDir, htmlPath, join(projectDir, "downloads"));
|
||||
const preflight = preflightCompositionAssetMediaTypes({
|
||||
projectDir,
|
||||
compiledDir: join(projectDir, "compiled"),
|
||||
composition: compiled,
|
||||
});
|
||||
|
||||
// Let all fire-and-forget advisory calls and the preflight contend for the
|
||||
// shared limiter while their mocked probe bodies remain blocked.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(probeTracker.maxActive).toBe(4);
|
||||
|
||||
probeTracker.release();
|
||||
await preflight;
|
||||
expect(probeTracker.started).toBe(18);
|
||||
expect(probeTracker.maxActive).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export function synthesizeMediaFixture(args: string[]): void {
|
||||
const result = spawnSync("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", ...args]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ffmpeg fixture synthesis failed: ${result.stderr.toString().slice(-400)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { EngineConfig } from "@hyperframes/engine";
|
||||
import { runCompileStage } from "./compileStage.js";
|
||||
|
||||
const noopLog = {
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
function createCfg(): EngineConfig {
|
||||
return {
|
||||
chromeArgs: [],
|
||||
chromePath: undefined,
|
||||
captureCostMultiplier: 1,
|
||||
format: "jpeg",
|
||||
jpegQuality: 80,
|
||||
concurrency: "auto",
|
||||
coresPerWorker: 2.5,
|
||||
minParallelFrames: 120,
|
||||
largeRenderThreshold: 1000,
|
||||
disableGpu: false,
|
||||
browserGpuMode: "software",
|
||||
enableBrowserPool: false,
|
||||
browserTimeout: 120_000,
|
||||
protocolTimeout: 300_000,
|
||||
forceScreenshot: false,
|
||||
enableChunkedEncode: false,
|
||||
chunkSizeFrames: 360,
|
||||
enableStreamingEncode: false,
|
||||
streamingEncodeMaxDurationSeconds: 240,
|
||||
ffmpegEncodeTimeout: 600_000,
|
||||
ffmpegProcessTimeout: 300_000,
|
||||
ffmpegStreamingTimeout: 600_000,
|
||||
hdr: false,
|
||||
hdrAutoDetect: true,
|
||||
audioGain: 1,
|
||||
frameDataUriCacheLimit: 256,
|
||||
frameDataUriCacheBytesLimitMb: 1500,
|
||||
playerReadyTimeout: 45_000,
|
||||
renderReadyTimeout: 15_000,
|
||||
verifyRuntime: true,
|
||||
debug: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCompileStage — asset media-type preflight", () => {
|
||||
let workDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||
workDir = null;
|
||||
});
|
||||
|
||||
it("rejects the zero-video audio-under-image shape before extraction", async () => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "compile-stage-media-type-"));
|
||||
const projectDir = join(workDir, "project");
|
||||
mkdirSync(projectDir);
|
||||
const htmlPath = join(projectDir, "index.html");
|
||||
writeFileSync(
|
||||
htmlPath,
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180" data-duration="1">
|
||||
<img id="hero" src="voice.asset" data-start="0" data-end="1" />
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
const audioPath = join(projectDir, "voice.asset");
|
||||
const synth = spawnSync("ffmpeg", [
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
"-f",
|
||||
"wav",
|
||||
audioPath,
|
||||
]);
|
||||
expect(synth.status).toBe(0);
|
||||
|
||||
await expect(
|
||||
runCompileStage({
|
||||
projectDir,
|
||||
workDir,
|
||||
htmlPath,
|
||||
entryFile: "index.html",
|
||||
job: {
|
||||
id: "media-type-test",
|
||||
config: { fps: { num: 30, den: 1 }, quality: "standard" },
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
currentStage: "Queued",
|
||||
createdAt: new Date(0),
|
||||
},
|
||||
cfg: createCfg(),
|
||||
needsAlpha: false,
|
||||
log: noopLog,
|
||||
assertNotAborted: () => {},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "ASSET_MEDIA_TYPE_MISMATCH",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type CompositionMetadata,
|
||||
} from "../shared.js";
|
||||
import type { RenderJob } from "../../renderOrchestrator.js";
|
||||
import { preflightCompositionAssetMediaTypes } from "../../assetMediaType.js";
|
||||
|
||||
export interface CompileStageInput {
|
||||
projectDir: string;
|
||||
@@ -312,6 +313,13 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
|
||||
width: compiled.width,
|
||||
height: compiled.height,
|
||||
};
|
||||
await preflightCompositionAssetMediaTypes({
|
||||
projectDir,
|
||||
compiledDir: join(workDir, "compiled"),
|
||||
composition,
|
||||
signal: abortSignal,
|
||||
});
|
||||
assertNotAborted();
|
||||
const { width, height } = composition;
|
||||
const effectiveResolution = adaptAspectAgnosticResolution(
|
||||
job.config.outputResolution,
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
// in beginframe mode even when lowMemoryMode demanded screenshot capture).
|
||||
const capturedCfgs: unknown[] = [];
|
||||
const capturedOptions: unknown[] = [];
|
||||
let mediaPreflightCallCount = 0;
|
||||
let mediaPreflightError: Error | null = null;
|
||||
let mediaPreflightSignal: AbortSignal | undefined;
|
||||
let mediaPreflightComposition: unknown;
|
||||
let afterMediaPreflight: (() => void) | null = null;
|
||||
let fileServerCloseCallCount = 0;
|
||||
let browserMediaResults: unknown[] = [];
|
||||
|
||||
type MockSession = {
|
||||
id: number;
|
||||
@@ -55,8 +62,28 @@ function resetRetryMocks() {
|
||||
createdSessions.length = 0;
|
||||
closedSessions.length = 0;
|
||||
durationProbeSessions.length = 0;
|
||||
mediaPreflightCallCount = 0;
|
||||
mediaPreflightError = null;
|
||||
mediaPreflightSignal = undefined;
|
||||
mediaPreflightComposition = undefined;
|
||||
afterMediaPreflight = null;
|
||||
fileServerCloseCallCount = 0;
|
||||
browserMediaResults = [];
|
||||
}
|
||||
|
||||
mock.module("../../assetMediaType.js", () => ({
|
||||
preflightCompositionAssetMediaTypes: async (input: {
|
||||
signal?: AbortSignal;
|
||||
composition?: unknown;
|
||||
}) => {
|
||||
mediaPreflightCallCount += 1;
|
||||
mediaPreflightSignal = input.signal;
|
||||
mediaPreflightComposition = input.composition;
|
||||
if (mediaPreflightError) throw mediaPreflightError;
|
||||
afterMediaPreflight?.();
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@hyperframes/engine", () => ({
|
||||
createCaptureSession: async (
|
||||
_url: string,
|
||||
@@ -126,14 +153,17 @@ mock.module("../../fileServer.js", () => ({
|
||||
createFileServer: async () => ({
|
||||
url: "http://127.0.0.1:0",
|
||||
port: 0,
|
||||
close: () => {},
|
||||
close: () => {
|
||||
fileServerCloseCallCount += 1;
|
||||
},
|
||||
addPreHeadScript: () => {},
|
||||
}),
|
||||
closeFileServerSafely: (fileServer: { close: () => void }) => fileServer.close(),
|
||||
VIRTUAL_TIME_SHIM: "",
|
||||
}));
|
||||
|
||||
mock.module("../../htmlCompiler.js", () => ({
|
||||
discoverMediaFromBrowser: async () => [],
|
||||
discoverMediaFromBrowser: async () => browserMediaResults,
|
||||
discoverAudioVolumeAutomationFromTimeline: async () => [],
|
||||
discoverVideoVisibilityFromTimeline: async () => [],
|
||||
recompileWithResolutions: async (c: unknown) => c,
|
||||
@@ -142,7 +172,11 @@ mock.module("../../htmlCompiler.js", () => ({
|
||||
|
||||
mock.module("../shared.js", () => ({
|
||||
BROWSER_MEDIA_EPSILON: 0.0001,
|
||||
projectBrowserEndToCompositionTimeline: () => 0,
|
||||
projectBrowserEndToCompositionTimeline: (
|
||||
existingStart: number,
|
||||
browserStart: number,
|
||||
browserEnd: number,
|
||||
) => browserEnd + (existingStart - browserStart),
|
||||
resolveBrowserMediaEnd: (_start: number, end: number, duration: number) =>
|
||||
Number.isFinite(duration) && duration > 0 ? _start + duration : end,
|
||||
writeCompiledArtifacts: () => {},
|
||||
@@ -238,6 +272,7 @@ function makeProbeInput(overrides: {
|
||||
debug: () => {},
|
||||
},
|
||||
assertNotAborted: () => {},
|
||||
abortSignal: undefined as AbortSignal | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,16 +334,132 @@ describe("hasVariableBoundMedia", () => {
|
||||
expect(hasVariableBoundMedia(html, { voice_src: "row-02.wav" })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not probe unrelated overrides or image-only bindings", () => {
|
||||
it("ignores unrelated overrides and probes image-bound sources", () => {
|
||||
const audio = `<audio src="fallback.wav" data-var-src="voice_src"></audio>`;
|
||||
const image = `<img src="fallback.png" data-var-src="hero_src" />`;
|
||||
|
||||
expect(hasVariableBoundMedia(audio, { title: "Row 02" })).toBe(false);
|
||||
expect(hasVariableBoundMedia(image, { hero_src: "row-02.png" })).toBe(false);
|
||||
expect(hasVariableBoundMedia(image, { hero_src: "row-02.png" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProbeStage — forceScreenshot threading", () => {
|
||||
it("runs media-type preflight after the browser-reconciliation phase", async () => {
|
||||
mediaPreflightCallCount = 0;
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.composition.duration = 5;
|
||||
input.compiled.html = "<div>static</div>";
|
||||
|
||||
await runProbeStage(input);
|
||||
|
||||
expect(mediaPreflightCallCount).toBe(1);
|
||||
});
|
||||
|
||||
it("reconciles a sub-composition image without losing its parent timeline offset", async () => {
|
||||
resetRetryMocks();
|
||||
browserMediaResults = [
|
||||
{
|
||||
id: "hero",
|
||||
tagName: "image",
|
||||
src: "runtime-video.asset",
|
||||
start: 0,
|
||||
end: 2,
|
||||
duration: 2,
|
||||
mediaStart: 0,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
},
|
||||
];
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.composition.duration = 5;
|
||||
input.composition.images.push({ id: "hero", src: "fallback.png", start: 4, end: 6 });
|
||||
input.compiled.html =
|
||||
'<img id="hero" src="fallback.png" data-var-src="hero_src" data-start="0" data-end="2">';
|
||||
input.job.config.variables = { hero_src: "runtime-video.asset" };
|
||||
|
||||
await runProbeStage(input);
|
||||
|
||||
expect(input.composition.images[0]?.src).toBe("runtime-video.asset");
|
||||
expect(input.composition.images[0]?.start).toBe(4);
|
||||
expect(input.composition.images[0]?.end).toBe(6);
|
||||
expect(mediaPreflightComposition).toBe(input.composition);
|
||||
});
|
||||
|
||||
it("reconciles a nested source's selected runtime URL before media-type preflight", async () => {
|
||||
resetRetryMocks();
|
||||
browserMediaResults = [
|
||||
{
|
||||
id: "clip",
|
||||
tagName: "video",
|
||||
src: "runtime-still.asset",
|
||||
start: 0,
|
||||
end: 5,
|
||||
duration: 5,
|
||||
mediaStart: 0,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
},
|
||||
];
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.composition.duration = 5;
|
||||
input.composition.videos.push({
|
||||
id: "clip",
|
||||
src: "fallback.mp4",
|
||||
start: 0,
|
||||
end: 5,
|
||||
mediaStart: 0,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
});
|
||||
input.compiled.html = `<video id="clip" data-start="0" data-end="5">
|
||||
<source src="fallback.mp4" data-var-src="clip_src">
|
||||
</video>`;
|
||||
input.job.config.variables = { clip_src: "runtime-still.asset" };
|
||||
|
||||
await runProbeStage(input);
|
||||
|
||||
expect(input.composition.videos[0]?.src).toBe("runtime-still.asset");
|
||||
expect(mediaPreflightComposition).toBe(input.composition);
|
||||
});
|
||||
|
||||
it("passes cancellation through and closes probe-owned resources when preflight rejects", async () => {
|
||||
resetRetryMocks();
|
||||
mediaPreflightError = new Error("ASSET_MEDIA_TYPE_MISMATCH");
|
||||
const controller = new AbortController();
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.abortSignal = controller.signal;
|
||||
|
||||
await expect(runProbeStage(input)).rejects.toThrow("ASSET_MEDIA_TYPE_MISMATCH");
|
||||
|
||||
expect(mediaPreflightSignal).toBe(controller.signal);
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
expect(fileServerCloseCallCount).toBe(1);
|
||||
mediaPreflightError = null;
|
||||
});
|
||||
|
||||
it("closes probe-owned resources when cancellation lands after an empty preflight", async () => {
|
||||
resetRetryMocks();
|
||||
const controller = new AbortController();
|
||||
afterMediaPreflight = () => controller.abort(new Error("render cancelled"));
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.abortSignal = controller.signal;
|
||||
input.assertNotAborted = () => controller.signal.throwIfAborted();
|
||||
|
||||
await expect(runProbeStage(input)).rejects.toThrow("render cancelled");
|
||||
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
expect(fileServerCloseCallCount).toBe(1);
|
||||
});
|
||||
|
||||
it("launches a probe when a static-duration composition inserts video at runtime", async () => {
|
||||
capturedCfgs.length = 0;
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
@@ -480,6 +631,40 @@ describe("runProbeStage — decimal duration frame count", () => {
|
||||
});
|
||||
|
||||
describe("runProbeStage — transient browser error retry (#1687)", () => {
|
||||
async function runWithTransientInitializeError(message: string) {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error(message);
|
||||
initializeSessionFailUntilAttempt = 1;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(initializeSessionCallCount).toBe(2);
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
expect(result.duration).toBe(5);
|
||||
expect(result.probeSession).not.toBeNull();
|
||||
}
|
||||
|
||||
async function expectInitializeFailure(input: {
|
||||
message: string;
|
||||
expectedMessage: string;
|
||||
expectedAttempts: number;
|
||||
}) {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error(input.message);
|
||||
initializeSessionFailUntilAttempt = 999;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const probeInput = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
|
||||
await expect(runProbeStage(probeInput)).rejects.toThrow(input.expectedMessage);
|
||||
expect(initializeSessionCallCount).toBe(input.expectedAttempts);
|
||||
expect(closeCaptureSessionCallCount).toBe(input.expectedAttempts);
|
||||
}
|
||||
|
||||
it("uses the replacement session after a BeginFrame liveness fallback", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
@@ -499,124 +684,42 @@ describe("runProbeStage — transient browser error retry (#1687)", () => {
|
||||
});
|
||||
|
||||
it("retries once on a transient 'Navigating frame was detached' error and succeeds", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error("Navigating frame was detached");
|
||||
initializeSessionFailUntilAttempt = 1;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(initializeSessionCallCount).toBe(2);
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
expect(result.duration).toBe(5);
|
||||
expect(result.probeSession).not.toBeNull();
|
||||
await runWithTransientInitializeError("Navigating frame was detached");
|
||||
});
|
||||
|
||||
it("retries once on a browser-probe navigation timeout and succeeds", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
initializeSessionFailUntilAttempt = 1;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(initializeSessionCallCount).toBe(2);
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
expect(result.duration).toBe(5);
|
||||
expect(result.probeSession).not.toBeNull();
|
||||
await runWithTransientInitializeError("Navigation timeout of 60000 ms exceeded");
|
||||
});
|
||||
|
||||
it("throws immediately on a non-transient error without retrying", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error("FONT_FETCH_FAILED: Inter");
|
||||
initializeSessionFailUntilAttempt = 999;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runProbeStage(input);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toContain("FONT_FETCH_FAILED");
|
||||
expect(initializeSessionCallCount).toBe(1);
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
await expectInitializeFailure({
|
||||
message: "FONT_FETCH_FAILED: Inter",
|
||||
expectedMessage: "FONT_FETCH_FAILED",
|
||||
expectedAttempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws after exhausting retry attempts on persistent transient errors", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error("Target closed");
|
||||
initializeSessionFailUntilAttempt = 999;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runProbeStage(input);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toContain("Target closed");
|
||||
expect(initializeSessionCallCount).toBe(2);
|
||||
expect(closeCaptureSessionCallCount).toBe(2);
|
||||
await expectInitializeFailure({
|
||||
message: "Target closed",
|
||||
expectedMessage: "Target closed",
|
||||
expectedAttempts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries once on a pollHfReady zero-duration timeout (renderReady: false) and succeeds", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error(
|
||||
await runWithTransientInitializeError(
|
||||
"[FrameCapture] Composition has zero duration.\n Runtime ready: false, __player: true, __hf.seek: true, GSAP timeline: true, data-duration: 53.3s",
|
||||
);
|
||||
initializeSessionFailUntilAttempt = 1;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(initializeSessionCallCount).toBe(2);
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
expect(result.duration).toBe(5);
|
||||
expect(result.probeSession).not.toBeNull();
|
||||
});
|
||||
|
||||
it("throws immediately on a permanent zero-duration error (renderReady: true — genuine authoring bug)", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
initializeSessionError = new Error(
|
||||
"[FrameCapture] Composition has zero duration.\n Runtime ready: true, __player: true, __hf.seek: true, GSAP timeline: false, data-duration: not set",
|
||||
);
|
||||
initializeSessionFailUntilAttempt = 999;
|
||||
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runProbeStage(input);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toContain("Runtime ready: true");
|
||||
expect(initializeSessionCallCount).toBe(1);
|
||||
expect(closeCaptureSessionCallCount).toBe(1);
|
||||
await expectInitializeFailure({
|
||||
message:
|
||||
"[FrameCapture] Composition has zero duration.\n Runtime ready: true, __player: true, __hf.seek: true, GSAP timeline: false, data-duration: not set",
|
||||
expectedMessage: "Runtime ready: true",
|
||||
expectedAttempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries on a transient browser LAUNCH failure (createCaptureSession throws)", async () => {
|
||||
|
||||
@@ -50,7 +50,12 @@ import {
|
||||
recompileWithResolutions,
|
||||
resolveCompositionDurations,
|
||||
} from "../../htmlCompiler.js";
|
||||
import { createFileServer, type FileServerHandle, VIRTUAL_TIME_SHIM } from "../../fileServer.js";
|
||||
import {
|
||||
closeFileServerSafely,
|
||||
createFileServer,
|
||||
type FileServerHandle,
|
||||
VIRTUAL_TIME_SHIM,
|
||||
} from "../../fileServer.js";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
import {
|
||||
BROWSER_MEDIA_EPSILON,
|
||||
@@ -61,6 +66,7 @@ import {
|
||||
} from "../shared.js";
|
||||
import type { RenderJob } from "../../renderOrchestrator.js";
|
||||
import { isActionableProbeFailure } from "./probeFailures.js";
|
||||
import { preflightCompositionAssetMediaTypes } from "../../assetMediaType.js";
|
||||
|
||||
export interface ProbeStageInput {
|
||||
projectDir: string;
|
||||
@@ -75,6 +81,7 @@ export interface ProbeStageInput {
|
||||
forceScreenshot: boolean;
|
||||
log: ProducerLogger;
|
||||
assertNotAborted: () => void;
|
||||
abortSignal?: AbortSignal;
|
||||
/** From compileStage. May be replaced via `recompileWithResolutions`. */
|
||||
compiled: CompiledComposition;
|
||||
/** From compileStage. Mutated in place (videos/audios pushed, duration set). */
|
||||
@@ -148,7 +155,7 @@ export function hasAutoStartVideos(html: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable-bound audio/video sources are resolved by the browser runtime, not
|
||||
* Variable-bound image/audio/video sources are resolved by the browser runtime, not
|
||||
* the static compiler. Probe them whenever the current render overrides the
|
||||
* referenced variable so media extraction follows the resolved row value.
|
||||
*/
|
||||
@@ -159,7 +166,9 @@ export function hasVariableBoundMedia(
|
||||
if (!variables || Object.keys(variables).length === 0) return false;
|
||||
const { document } = parseHTML(html);
|
||||
return Array.from(
|
||||
document.querySelectorAll("audio[data-var-src], video[data-var-src], source[data-var-src]"),
|
||||
document.querySelectorAll(
|
||||
"img[data-var-src], audio[data-var-src], video[data-var-src], source[data-var-src]",
|
||||
),
|
||||
).some((element) => {
|
||||
const variableId = element.getAttribute("data-var-src")?.trim();
|
||||
return Boolean(variableId && Object.hasOwn(variables, variableId));
|
||||
@@ -224,6 +233,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
forceScreenshot,
|
||||
log,
|
||||
assertNotAborted,
|
||||
abortSignal,
|
||||
composition,
|
||||
width,
|
||||
height,
|
||||
@@ -469,6 +479,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
if (browserMedia.length > 0) {
|
||||
const existingVideoIds = new Set(composition.videos.map((v) => v.id));
|
||||
const existingAudioIds = new Set(composition.audios.map((a) => a.id));
|
||||
const existingImageIds = new Set(composition.images.map((image) => image.id));
|
||||
|
||||
pruneMutedBrowserMedia(composition, browserMedia, existingAudioIds);
|
||||
|
||||
@@ -575,6 +586,33 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
});
|
||||
existingAudioIds.add(el.id);
|
||||
}
|
||||
} else if (el.tagName === "image") {
|
||||
if (existingImageIds.has(el.id)) {
|
||||
const existing = composition.images.find((image) => image.id === el.id);
|
||||
if (existing) {
|
||||
existing.src = src;
|
||||
const runtimeEnd = resolveBrowserMediaEnd(el.start, el.end, el.duration);
|
||||
const projectedEnd = projectBrowserEndToCompositionTimeline(
|
||||
existing.start,
|
||||
el.start,
|
||||
runtimeEnd,
|
||||
);
|
||||
if (
|
||||
projectedEnd > existing.start &&
|
||||
Math.abs(existing.end - projectedEnd) > BROWSER_MEDIA_EPSILON
|
||||
) {
|
||||
existing.end = projectedEnd;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
composition.images.push({
|
||||
id: el.id,
|
||||
src,
|
||||
start: el.start,
|
||||
end: resolveBrowserMediaEnd(el.start, el.end, el.duration),
|
||||
});
|
||||
existingImageIds.add(el.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,6 +666,38 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await preflightCompositionAssetMediaTypes({
|
||||
projectDir,
|
||||
compiledDir: join(workDir, "compiled"),
|
||||
composition,
|
||||
signal: abortSignal,
|
||||
});
|
||||
// Keep the final cancellation check inside the ownership guard: an abort
|
||||
// after the last probe resolves must still release the stage-owned browser
|
||||
// session and file server before propagating.
|
||||
assertNotAborted();
|
||||
} catch (error) {
|
||||
// The orchestrator only takes ownership after this stage returns. Until
|
||||
// then, any post-browser validation failure must release both resources
|
||||
// here or a deterministic user error strands Chrome and its file server.
|
||||
if (probeSession) {
|
||||
try {
|
||||
await closeCaptureSession(probeSession);
|
||||
} catch (closeError) {
|
||||
log.warn("Failed to close probe session after media preflight failure", {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError),
|
||||
});
|
||||
}
|
||||
probeSession = null;
|
||||
}
|
||||
if (fileServer) {
|
||||
closeFileServerSafely(fileServer, "probe media preflight", log);
|
||||
fileServer = null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const browserProbeMs = Date.now() - probeStart;
|
||||
|
||||
const duration = composition.duration;
|
||||
|
||||
@@ -2272,6 +2272,7 @@ async function executeRenderPipeline(input: {
|
||||
forceScreenshot: captureForceScreenshot,
|
||||
log,
|
||||
assertNotAborted,
|
||||
abortSignal: executionSignal,
|
||||
compiled,
|
||||
composition,
|
||||
width,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Semaphore } from "./semaphore.js";
|
||||
|
||||
const MEDIA_PROBE_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Process-wide because compiler advisories can outlive compileForRender and
|
||||
* overlap the later media-type preflight. A per-phase limiter would still let
|
||||
* those independently scheduled probe pools multiply subprocess load.
|
||||
*/
|
||||
export const sharedMediaProbeSemaphore = new Semaphore(MEDIA_PROBE_CONCURRENCY);
|
||||
|
||||
export async function withMediaProbeSlot<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const release = await sharedMediaProbeSemaphore.acquire();
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user