diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7c85694fc3..6d60702fd3 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -211,7 +211,12 @@ // Exported for render.test.ts (exported-for-tests pattern). { "file": "packages/cli/src/commands/render.ts", - "exports": ["resolveBrowserGpuForCli", "renderLocal", "checkRenderResolutionPreflight"], + "exports": [ + "resolveBrowserGpuForCli", + "renderLocal", + "checkRenderResolutionPreflight", + "renderLintContinuationHint", + ], }, // initThreeDProjectionInPage: passed to page.evaluate() for browser-side execution — // Puppeteer serializes it at runtime, not an import-graph consumer. diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index c1179c7545..e5617485d3 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -80,7 +80,7 @@ vi.mock("../utils/producer.js", () => ({ }), createRenderJob: vi.fn((config: Record) => { producerState.createdJobs.push(config); - return { config, progress: 100 }; + return { config, progress: 100, outcome: "completed", warnings: [] }; }), executeRenderJob: vi.fn(async (job: Record) => producerState.executeImpl(job)), })), @@ -413,6 +413,35 @@ describe("renderLocal browser GPU config", () => { expect(producerState.createdJobs[0]?.debug).toBe(true); }); + it("defaults to best-effort readiness", async () => { + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: { num: 30, den: 1 }, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + }); + + expect(producerState.createdJobs[0]?.strictness).toBe("best-effort"); + }); + + it("forwards an explicit strict readiness opt-in", async () => { + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: { num: 30, den: 1 }, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + bestEffort: false, + }); + + expect(producerState.createdJobs[0]?.strictness).toBe("strict"); + }); + it("omits variables from createRenderJob when not provided", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index f4e3e5df61..8cfeac8571 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -267,6 +267,12 @@ export default defineCommand({ "Write full render diagnostics and keep intermediate artifacts under the producer .debug directory.", default: false, }, + "best-effort": { + type: "boolean", + description: + "Allow output with structured capture-readiness warnings (default). Use --no-best-effort to fail on missing or unready media.", + default: true, + }, strict: { type: "boolean", description: "Fail render on lint errors", @@ -622,6 +628,7 @@ export default defineCommand({ const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg); const quiet = args.quiet ?? false; const debug = args.debug ?? false; + const bestEffort = args["best-effort"] ?? true; const batchJson = args.json ?? false; const effectiveQuiet = quiet || (batchPath != null && batchJson); const strictAll = args["strict-all"] ?? false; @@ -902,6 +909,7 @@ export default defineCommand({ protocolTimeout, playerReadyTimeout, debug, + bestEffort, exitAfterComplete: false, throwOnError: true, skipFeedback: true, @@ -960,6 +968,7 @@ export default defineCommand({ videoFrameFormat, quiet, debug, + bestEffort, variables, entryFile, outputResolution, @@ -988,6 +997,7 @@ export default defineCommand({ quiet, browserPath, debug, + bestEffort, variables, entryFile, outputResolution, @@ -1006,6 +1016,8 @@ export default defineCommand({ export interface SingleRenderResult { durationMs?: number; renderTimeMs: number; + outcome?: "completed" | "completed_with_warnings"; + warnings?: Array<{ code: string; message: string }>; } export function renderLintContinuationHint(strictErrors: boolean): string { @@ -1036,6 +1048,7 @@ interface RenderOptions { videoFrameFormat?: VideoFrameFormat; quiet: boolean; debug?: boolean; + bestEffort?: boolean; browserPath?: string; variables?: Record; entryFile?: string; @@ -1370,6 +1383,7 @@ async function renderDocker( outputResolution: options.outputResolution, pageSideCompositing: options.pageSideCompositing, debug: options.debug, + bestEffort: options.bestEffort, experimentalFastCapture: options.experimentalFastCapture, pageNavigationTimeoutMs: options.pageNavigationTimeoutMs, }, @@ -1490,6 +1504,7 @@ export async function renderLocal( entryFile: options.entryFile, outputResolution: options.outputResolution, debug: options.debug, + strictness: options.bestEffort === false ? "strict" : "best-effort", }); const onProgress = options.quiet @@ -1515,6 +1530,11 @@ export async function renderLocal( maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet); const elapsed = Date.now() - startTime; + if (job.outcome === "completed_with_warnings") { + for (const warning of job.warnings) { + console.warn(c.warn(` [${warning.code}] ${warning.message}`)); + } + } trackRenderMetrics(job, elapsed, options, false); printRenderComplete( outputPath, @@ -1534,7 +1554,14 @@ export async function renderLocal( const durationMs = job.perfSummary ? Math.round(job.perfSummary.compositionDurationSeconds * 1000) : undefined; - return { renderTimeMs: elapsed, durationMs }; + const outcome = + job.outcome === "completed_with_warnings" ? "completed_with_warnings" : "completed"; + return { + renderTimeMs: elapsed, + durationMs, + outcome, + warnings: job.warnings.map((warning) => ({ code: warning.code, message: warning.message })), + }; } type UnrefableTimer = { diff --git a/packages/cli/src/server/studioRenderTelemetry.test.ts b/packages/cli/src/server/studioRenderTelemetry.test.ts index 17e7114568..07d1262147 100644 --- a/packages/cli/src/server/studioRenderTelemetry.test.ts +++ b/packages/cli/src/server/studioRenderTelemetry.test.ts @@ -280,6 +280,7 @@ describe("studioRenderTelemetry", () => { progress: 25, currentStage: "Starting frame capture", createdAt: new Date(), + warnings: [], errorDetails: { message: "Navigation timeout of 60000 ms exceeded", elapsedMs: 60_001, diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 7efc098856..97b4e4ade8 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -172,6 +172,7 @@ describe("buildDockerRunArgs", () => { videoFrameFormat: "png", quiet: true, debug: true, + bestEffort: false, entryFile: "compositions/intro.html", experimentalFastCapture: true, }, @@ -191,6 +192,7 @@ describe("buildDockerRunArgs", () => { expect(args).toContain("png"); expect(args).toContain("--quiet"); expect(args).toContain("--debug"); + expect(args).toContain("--no-best-effort"); expect(args).toContain("--gpu"); expect(args).toContain("--no-browser-gpu"); expect(args).toContain("--hdr"); @@ -199,6 +201,21 @@ describe("buildDockerRunArgs", () => { expect(args).toContain("--experimental-fast-capture"); }); + it("forwards only an explicit strict-readiness opt-in", () => { + const compatible = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, bestEffort: true }, + }); + expect(compatible).not.toContain("--best-effort"); + expect(compatible).not.toContain("--no-best-effort"); + + const strict = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, bestEffort: false }, + }); + expect(strict).toContain("--no-best-effort"); + }); + it("forwards --experimental-fast-capture only when enabled", () => { const on = buildDockerRunArgs({ ...FIXED_INPUT, diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 9a2d359df5..5f30f3e337 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -51,6 +51,7 @@ export interface DockerRenderOptions { videoFrameFormat?: "auto" | "jpg" | "png"; quiet: boolean; debug?: boolean; + bestEffort?: boolean; variables?: Record; entryFile?: string; /** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */ @@ -136,6 +137,9 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { : []), ...(options.quiet ? ["--quiet"] : []), ...(options.debug ? ["--debug"] : []), + // The in-container CLI is best-effort by default. Only forward the + // explicit strict opt-in so Docker and local renders cannot drift. + ...(options.bestEffort === false ? ["--no-best-effort"] : []), ...(options.gpu ? ["--gpu"] : []), ...(options.browserGpu ? [] : ["--no-browser-gpu"]), ...(options.hdrMode === "force-hdr" ? ["--hdr"] : []), diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 7d572e4b68..55302aeb8e 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -582,7 +582,7 @@ window.__utilsMarker = gsap.utils.marker; expect(errorSpy).not.toHaveBeenCalled(); }); - it("reads remapped timeline registry accessors with the original target receiver", () => { + it("reads and dual-publishes remapped timelines without replacing the registry", () => { let timeline = "initial"; const timelineRegistry = { get host() { @@ -633,6 +633,8 @@ window.__afterTimeline = window.__timelines.scene; expect(fakeWindow.__beforeTimeline).toBe("initial"); expect(fakeWindow.__afterTimeline).toBe("updated"); + expect(Reflect.get(timelineRegistry, "scene")).toBe("updated"); + expect(fakeWindow.__timelines).toBe(timelineRegistry); expect(errorSpy).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index a1ba967551..6224270a5b 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -408,10 +408,25 @@ export function wrapScopedCompositionScript( if (!__hfTimelineRegistryProxy) { __hfTimelineRegistryProxy = new Proxy(window.__timelines, { get: function(target, prop, receiver) { - return Reflect.get(target, prop === __hfCompId ? __hfTimelineCompId : prop, target); + if (prop !== __hfCompId) { + return Reflect.get(target, prop, target); + } + var authoredValue = Reflect.get(target, prop, target); + return authoredValue === undefined + ? Reflect.get(target, __hfTimelineCompId, target) + : authoredValue; }, set: function(target, prop, value, receiver) { - return Reflect.set(target, prop === __hfCompId ? __hfTimelineCompId : prop, value, target); + if (prop !== __hfCompId) { + return Reflect.set(target, prop, value, target); + } + // The authored node remains in the compiled DOM when its local id + // differs from the runtime mount id, so readiness legitimately sees + // both compositions. Publish the same timeline under both identities + // instead of replacing one with the other. + var authoredSet = Reflect.set(target, __hfCompId, value, target); + var runtimeSet = Reflect.set(target, __hfTimelineCompId, value, target); + return authoredSet && runtimeSet; }, }); } @@ -435,6 +450,11 @@ export function wrapScopedCompositionScript( }, set: function(target, prop, value, receiver) { if (prop === "__timelines") { + // Common authoring boilerplate assigns the registry back to + // itself (window.__timelines = window.__timelines || {}). The + // getter above returns our proxy; do not replace the canonical + // registry with that proxy or later wrappers will stack proxies. + if (value === __hfTimelineRegistryProxy) return true; target.__timelines = value || {}; __hfTimelineRegistryProxy = null; return true; diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts index 61a00658f6..214894a673 100644 --- a/packages/core/src/compiler/inlineSubCompositions.test.ts +++ b/packages/core/src/compiler/inlineSubCompositions.test.ts @@ -142,6 +142,32 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => { expect(wrappedScript).toContain('"intro"'); }); + it("maps a template-local timeline id onto a differently named mount", () => { + const document = makeHostDocument("captions-comp"); + const host = document.querySelector('[data-composition-src="intro.html"]')!; + const captionsHtml = ``; + + const result = inlineSubCompositions(document, [host], { + resolveHtml: () => captionsHtml, + parseHtml: (html) => parseHTML(html).document, + }); + + expect(host.getAttribute("data-composition-id")).toBe("captions-comp"); + expect(host.querySelector('[data-composition-id="captions"]')).not.toBeNull(); + expect(result.styles.join("\n")).toContain('[data-composition-id="captions-comp"]'); + const wrappedScript = result.scripts.join("\n"); + expect(wrappedScript).toContain('var __hfCompId = "captions"'); + expect(wrappedScript).toContain('var __hfTimelineCompId = "captions-comp"'); + }); + it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index a245da1ef3..b1d650abb6 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -228,13 +228,21 @@ export function inlineSubCompositions( continue; } - // Find the inner composition root + // Keep structural flattening tied to an exact mount-id match. A template + // may intentionally use a different local id (for example, a + // `captions-comp` host mounting a `captions` template); flattening that + // fallback root changes the compiled DOM and can invalidate selectors and + // regression goldens. Discover it separately so script timeline + // registration can still map the authored id onto the runtime mount id. const innerRoot = compId ? queryByAttr(contentDoc, "data-composition-id", compId) : contentDoc.querySelector("[data-composition-id]"); - const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || ""; + const authoredCompositionRoot = innerRoot ?? contentDoc.querySelector("[data-composition-id]"); + const inferredCompId = + authoredCompositionRoot?.getAttribute("data-composition-id")?.trim() || ""; const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null; const scopeCompId = compId || inferredCompId; + const scriptCompositionId = inferredCompId || scopeCompId; const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : ""; // Variable merging (bundler feature). Read declared defaults from the @@ -312,13 +320,13 @@ export function inlineSubCompositions( } scriptItems.push({ kind: "external", src: externalSrc }); } else { - const wrappedScript = scopeCompId + const wrappedScript = scriptCompositionId ? wrapScopedCompositionScript( s.textContent || "", - scopeCompId, + scriptCompositionId, scriptErrorLabel, runtimeScope || undefined, - runtimeCompId || scopeCompId, + runtimeCompId || scopeCompId || scriptCompositionId, authoredRootId, ) : wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e2b4b49c14..1848dea4cd 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -40,6 +40,8 @@ export type { CaptureResult, CaptureBufferResult, CapturePerfSummary, + CaptureWarning, + CaptureWarningCode, SubTimelineWaitOutcome, } from "./types.js"; diff --git a/packages/engine/src/services/frameCapture-readinessWarnings.test.ts b/packages/engine/src/services/frameCapture-readinessWarnings.test.ts new file mode 100644 index 0000000000..5055144fb2 --- /dev/null +++ b/packages/engine/src/services/frameCapture-readinessWarnings.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { Page } from "puppeteer-core"; +import { collectMediaReadinessWarnings } from "./frameCapture.js"; + +function makePage(input: { + images?: Array<{ src: string; complete: boolean; naturalWidth: number }>; + media?: Array<{ + id?: string; + tagName: "VIDEO" | "AUDIO"; + src: string; + readyState: number; + networkState?: number; + error?: unknown; + }>; +}): Page { + return { + evaluate: async (fn: (skipIds: readonly string[]) => unknown, skipIds: readonly string[]) => { + const previousDocument = Reflect.get(globalThis, "document"); + const previousMedia = Reflect.get(globalThis, "HTMLMediaElement"); + Reflect.set(globalThis, "HTMLMediaElement", { + NETWORK_NO_SOURCE: 3, + HAVE_CURRENT_DATA: 2, + }); + Reflect.set(globalThis, "document", { + querySelectorAll: (selector: string) => { + if (selector === "img") { + return (input.images ?? []).map((image) => ({ + ...image, + getAttribute: (name: string) => (name === "src" ? image.src : null), + })); + } + const media = + selector === "video" + ? (input.media ?? []).filter((element) => element.tagName === "VIDEO") + : (input.media ?? []); + return media.map((media) => ({ + id: media.id ?? "", + tagName: media.tagName, + currentSrc: media.src, + readyState: media.readyState, + networkState: media.networkState ?? 1, + error: media.error ?? null, + getAttribute: (name: string) => (name === "src" ? media.src : null), + })); + }, + }); + try { + return await fn(skipIds); + } finally { + Reflect.set(globalThis, "document", previousDocument); + Reflect.set(globalThis, "HTMLMediaElement", previousMedia); + } + }, + } as unknown as Page; +} + +describe("collectMediaReadinessWarnings", () => { + it("returns stable warnings for visual media and ignores out-of-band audio", async () => { + const page = makePage({ + images: [ + { src: "/pending.png", complete: false, naturalWidth: 0 }, + { src: "/broken.png", complete: true, naturalWidth: 0 }, + ], + media: [ + { tagName: "VIDEO", src: "/broken.mp4", readyState: 0, error: new Error("decode") }, + { tagName: "AUDIO", src: "/pending.mp3", readyState: 1 }, + { id: "injected", tagName: "VIDEO", src: "/injected.mp4", readyState: 0 }, + ], + }); + + const warnings = await collectMediaReadinessWarnings(page, ["injected"], 45000); + + expect(warnings.map((warning) => [warning.code, warning.details?.mediaType])).toEqual([ + ["media_readiness_timeout", "image"], + ["media_load_failed", "image"], + ["media_load_failed", "video"], + ]); + expect(warnings.every((warning) => warning.details?.timeoutMs === 45000)).toBe(true); + }); + + it("returns no warnings when every relevant resource is ready", async () => { + const page = makePage({ + images: [{ src: "/ready.png", complete: true, naturalWidth: 100 }], + media: [ + { tagName: "VIDEO", src: "/ready.mp4", readyState: 2 }, + { tagName: "AUDIO", src: "/ready.mp3", readyState: 2 }, + ], + }); + + await expect(collectMediaReadinessWarnings(page, [], 1000)).resolves.toEqual([]); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 4786668261..0bb503fb40 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -50,6 +50,7 @@ import type { CaptureResult, CaptureBufferResult, CapturePerfSummary, + CaptureWarning, SubTimelineWaitOutcome, } from "../types.js"; @@ -106,6 +107,8 @@ export interface CaptureSession { scriptLoadFailures: string[]; /** Outcome of the sub-composition timeline wait: ready | timeout | script_failure. */ subTimelineWaitOutcome?: SubTimelineWaitOutcome; + /** Structured readiness warnings surfaced to the producer's render policy. */ + warnings: CaptureWarning[]; initTelemetry?: { initDurationMs: number; tweenCount: number; @@ -946,6 +949,7 @@ export async function createCaptureSession( isInitialized: false, browserConsoleBuffer: [], scriptLoadFailures: [], + warnings: [], capturePerf: { frames: 0, seekMs: 0, @@ -1325,6 +1329,105 @@ export async function pollImagesReady( return check(); } +type MediaReadinessSnapshot = { + pendingImages: string[]; + failedImages: string[]; + pendingVideos: string[]; + failedVideos: string[]; +}; + +/** @internal exported for contract testing. */ +export async function collectMediaReadinessWarnings( + page: Page, + skipIds: readonly string[], + timeoutMs: number, +): Promise { + const snapshot = await page.evaluate((skipIdList: readonly string[]): MediaReadinessSnapshot => { + const skipped = new Set(skipIdList); + const result: MediaReadinessSnapshot = { + pendingImages: [], + failedImages: [], + pendingVideos: [], + failedVideos: [], + }; + + for (const img of document.querySelectorAll("img")) { + const src = img.getAttribute("src") || ""; + if (!src || src.startsWith("data:")) continue; + if (!img.complete) result.pendingImages.push(img.src || src); + else if (img.naturalWidth <= 0) result.failedImages.push(img.src || src); + } + + // Browser media readiness is a frame-capture requirement only for video. + // Audio is extracted and mixed out of band by the producer, so an idle + // DOM