mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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 0–2s. 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user