fix(producer,core): honor relative data-start id-refs in render media scheduling (#3252)

compileTimingAttrs/injectDurations used parseFloat, so data-start="intro"
wrote a NaN data-end and extract preferred that over duration; parseNumeric
now skips the id-ref (parseVideoElements already resolves it).

collectRenderMedia's resolveHostWindow likewise read host data-start with
parseFloat, so chained sub-composition slots (data-start="hook") stacked at
0-2s and every scene after the first rendered black. It now resolves host
starts through the shared resolveReferencedStart, matching the media parsers.

Fixes #3361.
This commit is contained in:
Val
2026-08-26 05:36:24 +00:00
committed by GitHub
parent 0c9d234bd8
commit f52ec1c25f
8 changed files with 129 additions and 26 deletions
@@ -1138,6 +1138,35 @@ describe("template-wrapped sub-composition media offsets", () => {
});
});
it("offsets nested media by a host data-start id-ref to a sibling slot", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-chained-slots-"));
const compositionsDir = join(projectDir, "compositions");
mkdirSync(compositionsDir, { recursive: true });
const scene = (id: string) => `<template>
<div data-composition-id="${id}" data-start="0" data-duration="2" data-width="640" data-height="360">
<video id="${id}-video" src="../assets/clip.mp4" data-start="0" data-duration="2" data-track-index="0"></video>
</div>
</template>`;
writeFileSync(join(compositionsDir, "hook.html"), scene("hook"));
writeFileSync(join(compositionsDir, "body.html"), scene("body"));
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-start="0" data-duration="4" data-width="640" data-height="360">
<div data-composition-id="hook" data-composition-src="compositions/hook.html" data-start="0" data-duration="2"></div>
<div data-composition-id="body" data-composition-src="compositions/body.html" data-start="hook" data-duration="2"></div>
</div>
<script>window.__timelines = { root: { duration: () => 4 } };</script>
</body></html>`,
);
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
const byId = Object.fromEntries(compiled.videos.map((v) => [v.id, v]));
expect(byId["hook-video"]).toMatchObject({ start: 0, end: 2 });
expect(byId["body-video"]).toMatchObject({ start: 2, end: 4 });
});
it("preserves first-pass media offsets when durations are resolved after inlining", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="2" data-width="640" data-height="360"',
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test";
import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core";
import { collectRenderMedia } from "./renderMediaCollector.js";
describe("collectRenderMedia host windows", () => {
it("schedules nested videos at resolved host id-ref windows", () => {
const html =
`<div data-composition-file="hook.html" data-composition-id="hook" data-start="0" data-duration="2">` +
`<video ${MEDIA_RENDER_ID_ATTR}="red" id="red" src="red.mp4" data-start="0" data-duration="2"></video>` +
`</div>` +
`<div data-composition-file="body.html" data-composition-id="body" data-start="hook" data-duration="2">` +
`<video ${MEDIA_RENDER_ID_ATTR}="blue" id="blue" src="blue.mp4" data-start="0" data-duration="2"></video>` +
`</div>`;
const { videos } = collectRenderMedia(html);
expect(videos.find((v) => v.id === "red")).toMatchObject({ start: 0, end: 2 });
expect(videos.find((v) => v.id === "blue")).toMatchObject({ start: 2, end: 4 });
});
});
@@ -20,6 +20,9 @@ import {
parseVideoElements,
parseImageElements,
parseAudioElements,
resolveReferencedStart,
type RefResolverEl,
type RefResolverDoc,
type VideoElement,
type ImageElement,
type AudioElement,
@@ -50,13 +53,18 @@ function parseNumeric(value: string | null): number | null {
/**
* Fold a media element's chain of composition hosts into one window.
*
* Mirrors the offset arithmetic `parseSubCompositions` applied while walking
* the composition file tree, so a document that has no id collisions produces
* exactly the timings it did before. Only `data-end` bounds a host: a host
* carrying just `data-duration` was unbounded there too, and widening that here
* would silently retime existing compositions rather than fix identity.
* Host `data-start` is resolved the same way media is (`resolveReferencedStart`):
* numeric literals, or an id / `data-composition-id` ref to a sibling slot's
* end (`data-start="hook"`). `parseFloat("hook")` is 0, which stacked every
* chained scene at 02s. Only `data-end` bounds a host: a host carrying just
* `data-duration` was unbounded in the file-tree walk too.
*/
function resolveHostWindow(element: Element): HostWindow {
function resolveHostWindow(
element: Element,
document: RefResolverDoc,
startCache: Map<RefResolverEl, number>,
visiting: Set<RefResolverEl>,
): HostWindow {
const hosts: Element[] = [];
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
if (ancestor.hasAttribute(COMPOSITION_HOST_ATTR)) hosts.push(ancestor);
@@ -67,7 +75,7 @@ function resolveHostWindow(element: Element): HostWindow {
let limit = Infinity;
// parentElement walks leaf → root; the offsets accumulate root → leaf.
for (const host of hosts.reverse()) {
const hostStart = parseNumeric(host.getAttribute("data-start")) ?? 0;
const hostStart = resolveReferencedStart(document, host, startCache, visiting);
const hostEnd = parseNumeric(host.getAttribute("data-end"));
if (hostEnd != null) limit = Math.min(limit, offset + hostEnd);
offset += hostStart;
@@ -83,10 +91,15 @@ function resolveHostWindow(element: Element): HostWindow {
function collectHostWindows(html: string): Map<string, HostWindow> {
const { document } = parseHTML(html);
const windows = new Map<string, HostWindow>();
const startCache = new Map<RefResolverEl, number>();
const visiting = new Set<RefResolverEl>();
for (const element of document.querySelectorAll(`[${MEDIA_RENDER_ID_ATTR}]`)) {
const renderId = element.getAttribute(MEDIA_RENDER_ID_ATTR);
if (!renderId) continue;
windows.set(renderId, resolveHostWindow(element as unknown as Element));
windows.set(
renderId,
resolveHostWindow(element as unknown as Element, document, startCache, visiting),
);
}
return windows;
}