Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ vi.mock("../utils/producer.js", () => ({
}),
createRenderJob: vi.fn((config: Record<string, unknown>) => {
producerState.createdJobs.push(config);
return { config, progress: 100 };
return { config, progress: 100, outcome: "completed", warnings: [] };
}),
executeRenderJob: vi.fn(async (job: Record<string, unknown>) => producerState.executeImpl(job)),
})),
Expand Down Expand Up @@ -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 },
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -902,6 +909,7 @@ export default defineCommand({
protocolTimeout,
playerReadyTimeout,
debug,
bestEffort,
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
Expand Down Expand Up @@ -960,6 +968,7 @@ export default defineCommand({
videoFrameFormat,
quiet,
debug,
bestEffort,
variables,
entryFile,
outputResolution,
Expand Down Expand Up @@ -988,6 +997,7 @@ export default defineCommand({
quiet,
browserPath,
debug,
bestEffort,
variables,
entryFile,
outputResolution,
Expand All @@ -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 {
Expand Down Expand Up @@ -1036,6 +1048,7 @@ interface RenderOptions {
videoFrameFormat?: VideoFrameFormat;
quiet: boolean;
debug?: boolean;
bestEffort?: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
entryFile?: string;
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/server/studioRenderTelemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ describe("buildDockerRunArgs", () => {
videoFrameFormat: "png",
quiet: true,
debug: true,
bestEffort: false,
entryFile: "compositions/intro.html",
experimentalFastCapture: true,
},
Expand All @@ -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");
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface DockerRenderOptions {
videoFrameFormat?: "auto" | "jpg" | "png";
quiet: boolean;
debug?: boolean;
bestEffort?: boolean;
variables?: Record<string, unknown>;
entryFile?: string;
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
Expand Down Expand Up @@ -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"] : []),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
});

Expand Down
24 changes: 22 additions & 2 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
});
}
Expand All @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<template id="captions-template">
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>[data-composition-id="captions"] { opacity: 1; }</style>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["captions"] = { duration: 4 };
</script>
</div>
</template>`;

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"]')!;
Expand Down
18 changes: 13 additions & 5 deletions packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export type {
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
CaptureWarning,
CaptureWarningCode,
SubTimelineWaitOutcome,
} from "./types.js";

Expand Down
Loading
Loading