diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts
index cbac1f08d8..65e738898c 100644
--- a/packages/core/src/lint/rules/composition.ts
+++ b/packages/core/src/lint/rules/composition.ts
@@ -1,7 +1,76 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet } from "../utils";
+// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
+// to inspect and revise reliably in a single composition.
+const MAX_COMPOSITION_LINES = 300;
+const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
+const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);
+
+function countPhysicalLines(source: string): number {
+ if (source.length === 0) return 0;
+
+ const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
+ return withoutFinalNewline.split("\n").length;
+}
+
+function isCompositionRootOrMount(rawTag: string): boolean {
+ return Boolean(
+ readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
+ );
+}
+
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
+ // composition_file_too_large
+ ({ rawSource, options }) => {
+ const lineCount = countPhysicalLines(rawSource);
+ if (lineCount <= MAX_COMPOSITION_LINES) return [];
+
+ const splitTarget = options.isSubComposition
+ ? "Split this sub-composition further into smaller .html files"
+ : "Split coherent scenes or layers into separate .html files under compositions/";
+
+ return [
+ {
+ code: "composition_file_too_large",
+ severity: "warning",
+ message: `This HTML composition file has ${lineCount} lines. Agents produce better results when large scenes are split into smaller sub-compositions.`,
+ fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`,
+ },
+ ];
+ },
+
+ // timeline_track_too_dense
+ ({ tags, options }) => {
+ const trackCounts = new Map();
+ for (const tag of tags) {
+ if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
+ if (isCompositionRootOrMount(tag.raw)) continue;
+ if (!readAttr(tag.raw, "data-start")) continue;
+
+ const track = readAttr(tag.raw, "data-track-index");
+ if (!track) continue;
+ trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
+ }
+
+ const findings: HyperframeLintFinding[] = [];
+ for (const [track, count] of trackCounts) {
+ if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
+ const splitTarget = options.isSubComposition
+ ? "Move coherent scene groups into smaller .html files"
+ : "Move coherent scene groups into separate .html files under compositions/";
+ findings.push({
+ code: "timeline_track_too_dense",
+ severity: "warning",
+ message: `Track ${track} has ${count} timed elements in this HTML file. Agents produce better timelines when dense tracks are split into smaller sub-compositions.`,
+ fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`,
+ });
+ }
+
+ return findings;
+ },
+
// timed_element_missing_visibility_hidden
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts
index 803a684d9f..1996907c01 100644
--- a/packages/core/src/runtime/timeline.test.ts
+++ b/packages/core/src/runtime/timeline.test.ts
@@ -402,6 +402,92 @@ describe("collectRuntimeTimelinePayload", () => {
expect(s2?.duration).toBe(3);
});
+ it("does not offset GSAP scene clips by the master timeline start time", () => {
+ const root = document.createElement("div");
+ root.setAttribute("data-composition-id", "main");
+ root.setAttribute("data-duration", "20");
+ document.body.appendChild(root);
+
+ const scene = document.createElement("div");
+ scene.id = "scene-1";
+ root.appendChild(scene);
+
+ const masterTimeline = {
+ duration: () => 20,
+ time: () => 1.25,
+ play: () => {},
+ pause: () => {},
+ seek: () => {},
+ add: () => {},
+ paused: () => {},
+ set: () => {},
+ startTime: () => 1.25,
+ getChildren: () => [
+ {
+ targets: () => [scene],
+ startTime: () => 4,
+ duration: () => 3,
+ parent: masterTimeline,
+ },
+ ],
+ };
+
+ (window as any).__timelines = {
+ main: masterTimeline,
+ };
+
+ const result = collectRuntimeTimelinePayload(defaultParams);
+ const clip = result.clips.find((c) => c.id === "scene-1");
+ expect(clip).toBeDefined();
+ expect(clip?.start).toBe(4);
+ expect(clip?.duration).toBe(3);
+ });
+
+ it("keeps nested GSAP timeline offsets below the master timeline", () => {
+ const root = document.createElement("div");
+ root.setAttribute("data-composition-id", "main");
+ root.setAttribute("data-duration", "20");
+ document.body.appendChild(root);
+
+ const scene = document.createElement("div");
+ scene.id = "scene-1";
+ root.appendChild(scene);
+
+ const masterTimeline = {
+ duration: () => 20,
+ time: () => 1.25,
+ play: () => {},
+ pause: () => {},
+ seek: () => {},
+ add: () => {},
+ paused: () => {},
+ set: () => {},
+ startTime: () => 1.25,
+ getChildren: () => [] as unknown[],
+ };
+ const nestedTimeline = {
+ startTime: () => 6,
+ parent: masterTimeline,
+ };
+ const nestedTween = {
+ targets: () => [scene],
+ startTime: () => 2,
+ duration: () => 3,
+ parent: nestedTimeline,
+ };
+ masterTimeline.getChildren = () => [nestedTween];
+
+ (window as any).__timelines = {
+ main: masterTimeline,
+ };
+
+ const result = collectRuntimeTimelinePayload(defaultParams);
+ const clip = result.clips.find((c) => c.id === "scene-1");
+ expect(clip).toBeDefined();
+ expect(clip?.start).toBe(8);
+ expect(clip?.duration).toBe(3);
+ });
+
it("bubbles child tween ranges up to scene-level ancestors", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts
index d56d55ab90..91856ca51d 100644
--- a/packages/core/src/runtime/timeline.ts
+++ b/packages/core/src/runtime/timeline.ts
@@ -416,7 +416,7 @@ export function collectRuntimeTimelinePayload(params: {
targets?: () => Element[];
startTime?: () => number;
duration?: () => number;
- parent?: { startTime?: () => number };
+ parent?: GsapTween;
};
const tlWithChildren = masterTimeline as typeof masterTimeline & {
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => GsapTween[];
@@ -452,9 +452,9 @@ export function collectRuntimeTimelinePayload(params: {
continue;
let tweenStart = tween.startTime();
let parent = tween.parent;
- while (parent && typeof parent.startTime === "function") {
+ while (parent && parent !== masterTimeline && typeof parent.startTime === "function") {
tweenStart += parent.startTime();
- parent = (parent as GsapTween).parent;
+ parent = parent.parent;
}
const tweenEnd = tweenStart + tween.duration();
if (!Number.isFinite(tweenStart) || !Number.isFinite(tweenEnd)) continue;
diff --git a/packages/engine/src/services/videoFrameInjector.ts b/packages/engine/src/services/videoFrameInjector.ts
index 10525a3f9d..9223a87f1c 100644
--- a/packages/engine/src/services/videoFrameInjector.ts
+++ b/packages/engine/src/services/videoFrameInjector.ts
@@ -255,6 +255,13 @@ export interface ElementStackingInfo {
* Falls back to the CSS default `"50% 50%"` (center) when unset.
*/
objectPosition: string;
+ /**
+ * Clip rect from the nearest ancestor with `overflow: hidden` (or
+ * `clip`/`clip-path`). When set, the HDR compositor must scissor the
+ * element's blit to this viewport-relative rectangle. `null` means no
+ * clipping ancestor was found — render at full element bounds.
+ */
+ clipRect: { x: number; y: number; width: number; height: number } | null;
}
/**
@@ -357,6 +364,39 @@ export async function queryElementStacking(
return [0, 0, 0, 0];
}
+ // Walk ancestors to find the tightest overflow:hidden clip rect.
+ // Returns null if no clipping ancestor exists.
+ function getClipRect(
+ node: Element,
+ ): { x: number; y: number; width: number; height: number } | null {
+ let current: Element | null = node.parentElement;
+ let clip: { x: number; y: number; width: number; height: number } | null = null;
+ while (current) {
+ const cs = window.getComputedStyle(current);
+ if (cs.overflow === "hidden" || cs.overflow === "clip") {
+ const r = current.getBoundingClientRect();
+ const ancestor = {
+ x: Math.round(r.x),
+ y: Math.round(r.y),
+ width: Math.round(r.width),
+ height: Math.round(r.height),
+ };
+ if (!clip) {
+ clip = ancestor;
+ } else {
+ // Intersect with existing clip
+ const x1 = Math.max(clip.x, ancestor.x);
+ const y1 = Math.max(clip.y, ancestor.y);
+ const x2 = Math.min(clip.x + clip.width, ancestor.x + ancestor.width);
+ const y2 = Math.min(clip.y + clip.height, ancestor.y + ancestor.height);
+ clip = { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
+ }
+ }
+ current = current.parentElement;
+ }
+ return clip;
+ }
+
// Walk up the DOM multiplying each ancestor's opacity. GSAP animates
// opacity on wrapper divs, not directly on the video element, so the
// element's own opacity is often 1.0. Multiplying ancestors gives the
@@ -472,6 +512,7 @@ export async function queryElementStacking(
// can rely on a populated value.
objectFit: style.objectFit || "fill",
objectPosition: style.objectPosition || "50% 50%",
+ clipRect: isHdrEl ? getClipRect(el) : null,
});
}
return results;
diff --git a/packages/engine/src/utils/alphaBlit.test.ts b/packages/engine/src/utils/alphaBlit.test.ts
index 4edc6a527a..4cc27e2530 100644
--- a/packages/engine/src/utils/alphaBlit.test.ts
+++ b/packages/engine/src/utils/alphaBlit.test.ts
@@ -775,6 +775,25 @@ describe("blitRgb48leRegion", () => {
expect(canvas.readUInt16LE(0)).toBe(20000);
});
+ it("blends opacity over existing destination pixels", () => {
+ const canvas = makeHdrFrame(2, 1, 10000, 20000, 30000);
+ const source = makeHdrFrame(2, 1, 50000, 10000, 60000);
+ blitRgb48leRegion(canvas, source, 0, 0, 2, 1, 2, 1, 0.25);
+ expect(canvas.readUInt16LE(0)).toBe(20000);
+ expect(canvas.readUInt16LE(2)).toBe(17500);
+ expect(canvas.readUInt16LE(4)).toBe(37500);
+ expect(canvas.readUInt16LE(6)).toBe(20000);
+ });
+
+ it("skips exact-zero opacity without mutating the destination", () => {
+ const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
+ const source = makeHdrFrame(1, 1, 50000, 50000, 50000);
+ blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 1, 0);
+ expect(canvas.readUInt16LE(0)).toBe(10000);
+ expect(canvas.readUInt16LE(2)).toBe(20000);
+ expect(canvas.readUInt16LE(4)).toBe(30000);
+ });
+
it("no-op for zero-size region", () => {
const canvas = Buffer.alloc(4 * 4 * 6);
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
diff --git a/packages/engine/src/utils/alphaBlit.ts b/packages/engine/src/utils/alphaBlit.ts
index 9b22ced91d..8f766e3d12 100644
--- a/packages/engine/src/utils/alphaBlit.ts
+++ b/packages/engine/src/utils/alphaBlit.ts
@@ -423,6 +423,7 @@ export function blitRgb48leRegion(
if (sw <= 0 || sh <= 0) return;
const op = opacity ?? 1.0;
+ if (op <= 0) return;
const x0 = Math.max(0, dx);
const y0 = Math.max(0, dy);
@@ -442,6 +443,33 @@ export function blitRgb48leRegion(
const dstRowOff = ((y0 + y) * canvasWidth + x0) * 6;
source.copy(canvas, dstRowOff, srcRowOff, srcRowOff + clippedW * 6);
}
+ } else if (!hasMask) {
+ const invOp = 1 - op;
+ for (let y = 0; y < y1 - y0; y++) {
+ let srcOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6;
+ let dstOff = ((y0 + y) * canvasWidth + x0) * 6;
+ for (let x = 0; x < clippedW; x++) {
+ const sr = source[srcOff]! | (source[srcOff + 1]! << 8);
+ const sg = source[srcOff + 2]! | (source[srcOff + 3]! << 8);
+ const sb = source[srcOff + 4]! | (source[srcOff + 5]! << 8);
+ const dr = canvas[dstOff]! | (canvas[dstOff + 1]! << 8);
+ const dg = canvas[dstOff + 2]! | (canvas[dstOff + 3]! << 8);
+ const db = canvas[dstOff + 4]! | (canvas[dstOff + 5]! << 8);
+
+ const r = (sr * op + dr * invOp + 0.5) | 0;
+ const g = (sg * op + dg * invOp + 0.5) | 0;
+ const b = (sb * op + db * invOp + 0.5) | 0;
+ canvas[dstOff] = r & 0xff;
+ canvas[dstOff + 1] = r >>> 8;
+ canvas[dstOff + 2] = g & 0xff;
+ canvas[dstOff + 3] = g >>> 8;
+ canvas[dstOff + 4] = b & 0xff;
+ canvas[dstOff + 5] = b >>> 8;
+
+ srcOff += 6;
+ dstOff += 6;
+ }
+ }
} else {
for (let y = 0; y < y1 - y0; y++) {
for (let x = 0; x < clippedW; x++) {
@@ -528,6 +556,7 @@ export function blitRgb48leAffine(
const invTy = -(invB * tx + invD * ty);
const op = opacity ?? 1.0;
+ if (op <= 0) return;
const hasMask = borderRadius !== undefined;
diff --git a/packages/engine/src/utils/hdrCompositing.test.ts b/packages/engine/src/utils/hdrCompositing.test.ts
new file mode 100644
index 0000000000..2962dec76b
--- /dev/null
+++ b/packages/engine/src/utils/hdrCompositing.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import { groupIntoLayers } from "./layerCompositor.js";
+import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
+
+function makeEl(
+ id: string,
+ zIndex: number,
+ isHdr: boolean,
+ overrides?: Partial,
+): ElementStackingInfo {
+ return {
+ id,
+ zIndex,
+ x: 0,
+ y: 0,
+ width: 1920,
+ height: 1080,
+ layoutWidth: 1920,
+ layoutHeight: 1080,
+ opacity: 1,
+ visible: true,
+ isHdr,
+ transform: "none",
+ borderRadius: [0, 0, 0, 0],
+ objectFit: "cover",
+ objectPosition: "50% 50%",
+ clipRect: null,
+ ...overrides,
+ };
+}
+
+describe("HDR compositing — opacity filtering", () => {
+ it("zero-opacity elements remain in groupIntoLayers for hide-list correctness", () => {
+ const elements = [
+ makeEl("bg", 0, false),
+ makeEl("v-hdr", 1, true),
+ makeEl("overlay", 2, false, { opacity: 0 }),
+ ];
+ // Elements stay in layers for correct DOM screenshot hide-lists.
+ // The compositor skips zero-opacity HDR layers during blit.
+ const layers = groupIntoLayers(elements);
+ expect(layers).toHaveLength(3);
+ expect(layers[0]!.type).toBe("dom");
+ expect(layers[1]!.type).toBe("hdr");
+ expect(layers[2]!.type).toBe("dom");
+ });
+
+ it("zero-opacity HDR element should be skipped during blit", () => {
+ const el = makeEl("v-hdr", 1, true, { opacity: 0 });
+ // The compositor checks: if (layer.element.opacity <= 0) continue;
+ expect(el.opacity).toBe(0);
+ expect(el.opacity <= 0).toBe(true);
+ });
+
+ it("low but non-zero opacity HDR elements are NOT skipped", () => {
+ const el = makeEl("v-hdr", 1, true, { opacity: 0.1 });
+ expect(el.opacity > 0).toBe(true);
+ });
+
+ it("child data-start element with parent opacity 0 has effective opacity 0", () => {
+ const childOverlay = makeEl("s6-text-wrap", 10, false, { opacity: 0 });
+ expect(childOverlay.opacity).toBe(0);
+ });
+
+ it("DOM overlay above HDR video is in a separate layer when both visible", () => {
+ const elements = [makeEl("bg", 0, false), makeEl("v-hdr", 1, true), makeEl("badge", 10, false)];
+ const layers = groupIntoLayers(elements);
+ expect(layers).toHaveLength(3);
+ expect(layers[0]!.type).toBe("dom");
+ expect(layers[1]!.type).toBe("hdr");
+ expect(layers[2]!.type).toBe("dom");
+ if (layers[2]!.type === "dom") {
+ expect(layers[2]!.elementIds).toEqual(["badge"]);
+ }
+ });
+});
+
+describe("HDR compositing — clip rect", () => {
+ it("clipRect is null when no overflow:hidden ancestor", () => {
+ const el = makeEl("video", 0, true);
+ expect(el.clipRect).toBeNull();
+ });
+
+ it("clipRect constrains element bounds for split-screen", () => {
+ const el = makeEl("video-left", 0, true, {
+ x: 0,
+ y: 0,
+ width: 1920,
+ height: 1080,
+ clipRect: { x: 0, y: 0, width: 960, height: 1080 },
+ });
+ const cr = el.clipRect!;
+ const cx1 = Math.max(el.x, cr.x);
+ const cy1 = Math.max(el.y, cr.y);
+ const cx2 = Math.min(el.x + el.width, cr.x + cr.width);
+ const cy2 = Math.min(el.y + el.height, cr.y + cr.height);
+ expect(cx2 - cx1).toBe(960);
+ expect(cy2 - cy1).toBe(1080);
+ });
+
+ it("fully clipped element produces zero-size intersection", () => {
+ const el = makeEl("offscreen", 0, true, {
+ x: 1000,
+ y: 0,
+ width: 920,
+ height: 1080,
+ clipRect: { x: 0, y: 0, width: 960, height: 1080 },
+ });
+ const cr = el.clipRect!;
+ const cx2 = Math.min(el.x + el.width, cr.x + cr.width);
+ const cx1 = Math.max(el.x, cr.x);
+ expect(Math.max(0, cx2 - cx1)).toBe(0);
+ });
+
+ it("right-half clip produces correct source crop offset", () => {
+ const el = makeEl("video-right", 0, true, {
+ x: 960,
+ y: 0,
+ width: 1920,
+ height: 1080,
+ clipRect: { x: 960, y: 0, width: 960, height: 1080 },
+ });
+ const cr = el.clipRect!;
+ const cx1 = Math.max(el.x, cr.x);
+ const blitSrcX = cx1 - el.x;
+ expect(blitSrcX).toBe(0);
+ const blitW = Math.min(el.x + el.width, cr.x + cr.width) - cx1;
+ expect(blitW).toBe(960);
+ });
+});
diff --git a/packages/engine/src/utils/layerCompositor.test.ts b/packages/engine/src/utils/layerCompositor.test.ts
index 013eb38a43..33e631d485 100644
--- a/packages/engine/src/utils/layerCompositor.test.ts
+++ b/packages/engine/src/utils/layerCompositor.test.ts
@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
import { groupIntoLayers } from "./layerCompositor.js";
import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
-function makeEl(id: string, zIndex: number, isHdr: boolean): ElementStackingInfo {
+function makeEl(
+ id: string,
+ zIndex: number,
+ isHdr: boolean,
+ overrides?: Partial,
+): ElementStackingInfo {
return {
id,
zIndex,
@@ -17,6 +22,10 @@ function makeEl(id: string, zIndex: number, isHdr: boolean): ElementStackingInfo
isHdr,
transform: "none",
borderRadius: [0, 0, 0, 0],
+ objectFit: "cover",
+ objectPosition: "50% 50%",
+ clipRect: null,
+ ...overrides,
};
}
diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts
index b566f69426..d7daa58c1f 100644
--- a/packages/producer/src/services/htmlCompiler.test.ts
+++ b/packages/producer/src/services/htmlCompiler.test.ts
@@ -494,6 +494,7 @@ describe("template-wrapped sub-composition media offsets", () => {
function writeTemplateWrappedProject(
hostAttrs: string,
mediaAttrs: string = 'data-start="0" data-duration="4"',
+ extraMediaMarkup: string = "",
): {
projectDir: string;
indexPath: string;
@@ -547,6 +548,7 @@ describe("template-wrapped sub-composition media offsets", () => {
${mediaAttrs}
data-track-index="0"
>
+ ${extraMediaMarkup}