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
@@ -133,6 +133,17 @@ describe("compileTimingAttrs", () => {
expect(compiled).not.toContain("data-hf-auto-start");
});
it("leaves data-end off a relative data-start id-ref", () => {
const html =
'<video id="intro" src="a.mp4" data-start="0" data-duration="10">' +
'<video id="main" src="b.mp4" data-start="intro" data-duration="20">';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('data-start="intro"');
expect(compiled).not.toMatch(/id="main"[^>]*data-end=/);
expect(compiled).toMatch(/id="intro"[^>]*data-end="10"/);
});
it("compiles audio tags the same as video (minus data-has-audio)", () => {
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
const { html: compiled } = compileTimingAttrs(html);
@@ -229,6 +240,15 @@ describe("injectDurations", () => {
// data-duration already present, should not be duplicated
expect(result).toContain('data-duration="3"');
});
it("injects data-duration but not data-end when data-start is a relative id-ref", () => {
const html = '<video id="main" src="b.mp4" data-start="intro">';
const result = injectDurations(html, [{ id: "main", duration: 5 }]);
expect(result).toContain('data-duration="5"');
expect(result).toContain('data-start="intro"');
expect(result).not.toMatch(/data-end=/);
});
});
describe("extractResolvedMedia", () => {
+22 -17
View File
@@ -2,7 +2,7 @@
* Timing Compiler
*
* Shared, pure HTML compilation that normalizes timing attributes.
* Works in both Node.js and browser (no dependencies, regex-based).
* Works in both Node.js and browser (regex-based, no DOM).
*
* Guarantees every timed element gets:
* - id on media elements when missing
@@ -13,14 +13,17 @@
* this compiler identifies them as "unresolved" so the caller can provide
* durations via an environment-specific resolver (ffprobe, el.duration, etc.)
* and call injectDurations() to complete the compilation.
*
* Relative `data-start` (`intro`, `intro + 0.5`) is not numeric — leave
* `data-end` off so extract can resolve the id-ref later.
*/
import { parseNumeric } from "@hyperframes/parsers/composition-contract";
import {
parseStrictFiniteTimingNumber,
readElementPlaybackRate,
readMediaStart,
} from "../runtime/playbackRate.js";
// ── Types ────────────────────────────────────────────────────────────────
export interface UnresolvedElement {
@@ -149,25 +152,26 @@ function compileTag(
result = injectAttr(result, "data-hf-auto-start", "");
startStr = "0";
}
const start = parseFloat(startStr);
const start = parseNumeric(startStr);
const attrReader = { getAttribute: (name: string) => getAttr(result, name) };
const mediaStart = readMediaStart(attrReader);
const playbackRate = readElementPlaybackRate(attrReader);
// 1. Compute data-end from data-start + data-duration
// 1. Compute data-end from data-start + data-duration. Skip relative id-refs.
if (!hasAttr(result, "data-end")) {
const durationStr = getAttr(result, "data-duration");
const duration = parseStrictFiniteTimingNumber(durationStr);
if (duration != null) {
const end = start + duration;
result = injectAttr(result, "data-end", String(end));
if (start != null) {
result = injectAttr(result, "data-end", String(start + duration));
}
} else if (id) {
// No data-duration: mark as unresolved so caller can provide it
unresolved = {
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(result, "src") ?? undefined,
start,
start: start ?? 0,
mediaStart,
playbackRate,
};
@@ -229,7 +233,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
unresolved.push({
id,
tagName: "div",
start: startStr ? parseFloat(startStr) : 0,
start: parseNumeric(startStr) ?? 0,
mediaStart: 0,
playbackRate: 1,
compositionSrc: compositionSrc ?? undefined,
@@ -262,11 +266,12 @@ export function injectDurations(html: string, resolutions: ResolvedDuration[]):
result = setAttr(result, "data-duration", String(duration));
}
// Add data-end if missing
// Add data-end if missing. Skip relative id-refs.
if (!hasAttr(result, "data-end")) {
const startStr = getAttr(result, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
result = injectAttr(result, "data-end", String(start + duration));
const start = parseNumeric(getAttr(result, "data-start"));
if (start != null) {
result = injectAttr(result, "data-end", String(start + duration));
}
}
return result;
@@ -307,7 +312,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(tag, "src") ?? undefined,
start: startStr !== null ? parseFloat(startStr) : 0,
start: parseNumeric(startStr) ?? 0,
duration,
mediaStart: readMediaStart(attrReader),
playbackRate: readElementPlaybackRate(attrReader),
@@ -331,10 +336,10 @@ export function clampDurations(html: string, clamps: ResolvedDuration[]): string
// Replace data-duration value
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
// Recompute data-end from data-start + clamped duration
const startStr = getAttr(tag, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
const start = parseNumeric(getAttr(tag, "data-start"));
if (start != null) {
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
}
return tag;
});
+6
View File
@@ -212,6 +212,12 @@ export {
isVideoFrameFormat,
} from "./services/videoFrameExtractor.js";
export {
resolveReferencedStart,
type RefResolverEl,
type RefResolverDoc,
} from "./services/referenceResolver.js";
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
export {
@@ -18,7 +18,7 @@ import { parseNumeric, parseStartExpression } from "@hyperframes/core";
export interface RefResolverEl {
getAttribute(name: string): string | null;
}
interface RefResolverDoc {
export interface RefResolverDoc {
getElementById(id: string): RefResolverEl | null;
querySelector(selector: string): RefResolverEl | null;
}
@@ -57,6 +57,7 @@ import {
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { COMPLETE_SENTINEL, GC_MARKER, SCHEMA_PREFIX } from "./extractionCache.js";
import { resolveRuntimeMediaClipDuration } from "../../../core/src/runtime/media.js";
import { compileTimingAttrs } from "@hyperframes/core";
// ffmpeg is not preinstalled on GitHub's ubuntu-24.04 runners. The producer
// regression test at packages/producer/tests/vfr-screen-recording/ runs inside
@@ -804,6 +805,16 @@ describe("parseVideoElements", () => {
expect(main?.end).toBe(30);
});
it("still resolves relative data-start after compileTimingAttrs", () => {
const raw =
'<video id="intro" src="a.mp4" data-start="0" data-duration="10"></video>' +
'<video id="main" src="b.mp4" data-start="intro" data-duration="20"></video>';
const { html } = compileTimingAttrs(raw);
const main = parseVideoElements(html).find((v) => v.id === "main");
expect(main?.start).toBe(10);
expect(main?.end).toBe(30);
});
it("applies + and - offsets on a relative reference", () => {
const videos = parseVideoElements(
'<video id="intro" src="a.mp4" data-start="0" data-duration="10"></video>' +
@@ -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;
}