mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +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:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 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