Skip to content

Commit 459de56

Browse files
fix(core,producer): resolve relative data-start id-refs in render compile
1 parent 9da422f commit 459de56

6 files changed

Lines changed: 87 additions & 20 deletions

File tree

packages/core/src/compiler/timingCompiler.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,17 @@ describe("compileTimingAttrs", () => {
133133
expect(compiled).not.toContain("data-hf-auto-start");
134134
});
135135

136+
it("leaves data-end off a relative data-start id-ref", () => {
137+
const html =
138+
'<video id="intro" src="a.mp4" data-start="0" data-duration="10">' +
139+
'<video id="main" src="b.mp4" data-start="intro" data-duration="20">';
140+
const { html: compiled } = compileTimingAttrs(html);
141+
142+
expect(compiled).toContain('data-start="intro"');
143+
expect(compiled).not.toMatch(/id="main"[^>]*data-end=/);
144+
expect(compiled).toMatch(/id="intro"[^>]*data-end="10"/);
145+
});
146+
136147
it("compiles audio tags the same as video (minus data-has-audio)", () => {
137148
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
138149
const { html: compiled } = compileTimingAttrs(html);
@@ -229,6 +240,15 @@ describe("injectDurations", () => {
229240
// data-duration already present, should not be duplicated
230241
expect(result).toContain('data-duration="3"');
231242
});
243+
244+
it("injects data-duration but not data-end when data-start is a relative id-ref", () => {
245+
const html = '<video id="main" src="b.mp4" data-start="intro">';
246+
const result = injectDurations(html, [{ id: "main", duration: 5 }]);
247+
248+
expect(result).toContain('data-duration="5"');
249+
expect(result).toContain('data-start="intro"');
250+
expect(result).not.toMatch(/data-end=/);
251+
});
232252
});
233253

234254
describe("extractResolvedMedia", () => {

packages/core/src/compiler/timingCompiler.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Timing Compiler
33
*
44
* Shared, pure HTML compilation that normalizes timing attributes.
5-
* Works in both Node.js and browser (no dependencies, regex-based).
5+
* Works in both Node.js and browser (regex-based, no DOM).
66
*
77
* Guarantees every timed element gets:
88
* - id on media elements when missing
@@ -13,14 +13,17 @@
1313
* this compiler identifies them as "unresolved" so the caller can provide
1414
* durations via an environment-specific resolver (ffprobe, el.duration, etc.)
1515
* and call injectDurations() to complete the compilation.
16+
*
17+
* Relative `data-start` (`intro`, `intro + 0.5`) is not numeric — leave
18+
* `data-end` off so extract can resolve the id-ref later.
1619
*/
1720

21+
import { parseNumeric } from "@hyperframes/parsers/composition-contract";
1822
import {
1923
parseStrictFiniteTimingNumber,
2024
readElementPlaybackRate,
2125
readMediaStart,
2226
} from "../runtime/playbackRate.js";
23-
2427
// ── Types ────────────────────────────────────────────────────────────────
2528

2629
export interface UnresolvedElement {
@@ -149,25 +152,26 @@ function compileTag(
149152
result = injectAttr(result, "data-hf-auto-start", "");
150153
startStr = "0";
151154
}
152-
const start = parseFloat(startStr);
155+
const start = parseNumeric(startStr);
153156
const attrReader = { getAttribute: (name: string) => getAttr(result, name) };
154157
const mediaStart = readMediaStart(attrReader);
155158
const playbackRate = readElementPlaybackRate(attrReader);
156159

157-
// 1. Compute data-end from data-start + data-duration
160+
// 1. Compute data-end from data-start + data-duration. Skip relative id-refs.
158161
if (!hasAttr(result, "data-end")) {
159162
const durationStr = getAttr(result, "data-duration");
160163
const duration = parseStrictFiniteTimingNumber(durationStr);
161164
if (duration != null) {
162-
const end = start + duration;
163-
result = injectAttr(result, "data-end", String(end));
165+
if (start != null) {
166+
result = injectAttr(result, "data-end", String(start + duration));
167+
}
164168
} else if (id) {
165169
// No data-duration: mark as unresolved so caller can provide it
166170
unresolved = {
167171
id,
168172
tagName: isVideo ? "video" : "audio",
169173
src: getAttr(result, "src") ?? undefined,
170-
start,
174+
start: start ?? 0,
171175
mediaStart,
172176
playbackRate,
173177
};
@@ -229,7 +233,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
229233
unresolved.push({
230234
id,
231235
tagName: "div",
232-
start: startStr ? parseFloat(startStr) : 0,
236+
start: parseNumeric(startStr) ?? 0,
233237
mediaStart: 0,
234238
playbackRate: 1,
235239
compositionSrc: compositionSrc ?? undefined,
@@ -262,11 +266,12 @@ export function injectDurations(html: string, resolutions: ResolvedDuration[]):
262266
result = setAttr(result, "data-duration", String(duration));
263267
}
264268

265-
// Add data-end if missing
269+
// Add data-end if missing. Skip relative id-refs.
266270
if (!hasAttr(result, "data-end")) {
267-
const startStr = getAttr(result, "data-start");
268-
const start = startStr ? parseFloat(startStr) : 0;
269-
result = injectAttr(result, "data-end", String(start + duration));
271+
const start = parseNumeric(getAttr(result, "data-start"));
272+
if (start != null) {
273+
result = injectAttr(result, "data-end", String(start + duration));
274+
}
270275
}
271276

272277
return result;
@@ -307,7 +312,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
307312
id,
308313
tagName: isVideo ? "video" : "audio",
309314
src: getAttr(tag, "src") ?? undefined,
310-
start: startStr !== null ? parseFloat(startStr) : 0,
315+
start: parseNumeric(startStr) ?? 0,
311316
duration,
312317
mediaStart: readMediaStart(attrReader),
313318
playbackRate: readElementPlaybackRate(attrReader),
@@ -331,10 +336,10 @@ export function clampDurations(html: string, clamps: ResolvedDuration[]): string
331336
// Replace data-duration value
332337
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
333338

334-
// Recompute data-end from data-start + clamped duration
335-
const startStr = getAttr(tag, "data-start");
336-
const start = startStr ? parseFloat(startStr) : 0;
337-
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
339+
const start = parseNumeric(getAttr(tag, "data-start"));
340+
if (start != null) {
341+
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
342+
}
338343

339344
return tag;
340345
});

packages/engine/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ export {
211211
isVideoFrameFormat,
212212
} from "./services/videoFrameExtractor.js";
213213

214+
export { resolveReferencedStart, type RefResolverEl } from "./services/referenceResolver.js";
215+
214216
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
215217

216218
export {

packages/engine/src/services/videoFrameExtractor.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
import { runFfmpeg } from "../utils/runFfmpeg.js";
5858
import { COMPLETE_SENTINEL, GC_MARKER, SCHEMA_PREFIX } from "./extractionCache.js";
5959
import { resolveRuntimeMediaClipDuration } from "../../../core/src/runtime/media.js";
60+
import { compileTimingAttrs } from "@hyperframes/core";
6061

6162
// ffmpeg is not preinstalled on GitHub's ubuntu-24.04 runners. The producer
6263
// regression test at packages/producer/tests/vfr-screen-recording/ runs inside
@@ -804,6 +805,16 @@ describe("parseVideoElements", () => {
804805
expect(main?.end).toBe(30);
805806
});
806807

808+
it("still resolves relative data-start after compileTimingAttrs", () => {
809+
const raw =
810+
'<video id="intro" src="a.mp4" data-start="0" data-duration="10"></video>' +
811+
'<video id="main" src="b.mp4" data-start="intro" data-duration="20"></video>';
812+
const { html } = compileTimingAttrs(raw);
813+
const main = parseVideoElements(html).find((v) => v.id === "main");
814+
expect(main?.start).toBe(10);
815+
expect(main?.end).toBe(30);
816+
});
817+
807818
it("applies + and - offsets on a relative reference", () => {
808819
const videos = parseVideoElements(
809820
'<video id="intro" src="a.mp4" data-start="0" data-duration="10"></video>' +

packages/producer/src/services/htmlCompiler.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,7 @@ describe("template-wrapped sub-composition media offsets", () => {
10501050
hostAttrs: string,
10511051
mediaAttrs: string = 'data-start="0" data-duration="4"',
10521052
extraMediaMarkup: string = "",
1053+
extraRootMarkup: string = "",
10531054
): {
10541055
projectDir: string;
10551056
indexPath: string;
@@ -1071,6 +1072,7 @@ describe("template-wrapped sub-composition media offsets", () => {
10711072
data-height="360"
10721073
data-duration="4"
10731074
>
1075+
${extraRootMarkup}
10741076
<div
10751077
id="scene-host"
10761078
data-composition-id="scene"
@@ -1136,6 +1138,21 @@ describe("template-wrapped sub-composition media offsets", () => {
11361138
});
11371139
});
11381140

1141+
it("resolves a host data-start id-ref against a sibling clip", async () => {
1142+
const { projectDir, indexPath } = writeTemplateWrappedProject(
1143+
'data-start="intro" data-duration="2" data-width="640" data-height="360"',
1144+
'data-start="0" data-duration="4"',
1145+
"",
1146+
'<video id="intro" src="assets/clip.mp4" data-start="0" data-duration="10" muted></video>',
1147+
);
1148+
1149+
const compiled = await compileForRender(projectDir, indexPath, projectDir);
1150+
expect(compiled.videos.find((v) => v.id === "scene-video")).toMatchObject({
1151+
start: 10,
1152+
end: 14,
1153+
});
1154+
});
1155+
11391156
it("preserves first-pass media offsets when durations are resolved after inlining", async () => {
11401157
const { projectDir, indexPath } = writeTemplateWrappedProject(
11411158
'data-start="2" data-width="640" data-height="360"',

packages/producer/src/services/renderMediaCollector.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
parseVideoElements,
2121
parseImageElements,
2222
parseAudioElements,
23+
resolveReferencedStart,
24+
type RefResolverEl,
2325
type VideoElement,
2426
type ImageElement,
2527
type AudioElement,
@@ -56,7 +58,12 @@ function parseNumeric(value: string | null): number | null {
5658
* carrying just `data-duration` was unbounded there too, and widening that here
5759
* would silently retime existing compositions rather than fix identity.
5860
*/
59-
function resolveHostWindow(element: Element): HostWindow {
61+
function resolveHostWindow(
62+
element: Element,
63+
doc: { getElementById(id: string): RefResolverEl | null; querySelector(selector: string): RefResolverEl | null },
64+
startCache: Map<RefResolverEl, number>,
65+
visiting: Set<RefResolverEl>,
66+
): HostWindow {
6067
const hosts: Element[] = [];
6168
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
6269
if (ancestor.hasAttribute(COMPOSITION_HOST_ATTR)) hosts.push(ancestor);
@@ -67,7 +74,7 @@ function resolveHostWindow(element: Element): HostWindow {
6774
let limit = Infinity;
6875
// parentElement walks leaf → root; the offsets accumulate root → leaf.
6976
for (const host of hosts.reverse()) {
70-
const hostStart = parseNumeric(host.getAttribute("data-start")) ?? 0;
77+
const hostStart = resolveReferencedStart(doc, host, startCache, visiting);
7178
const hostEnd = parseNumeric(host.getAttribute("data-end"));
7279
if (hostEnd != null) limit = Math.min(limit, offset + hostEnd);
7380
offset += hostStart;
@@ -82,11 +89,16 @@ function resolveHostWindow(element: Element): HostWindow {
8289
*/
8390
function collectHostWindows(html: string): Map<string, HostWindow> {
8491
const { document } = parseHTML(html);
92+
const startCache = new Map<RefResolverEl, number>();
93+
const visiting = new Set<RefResolverEl>();
8594
const windows = new Map<string, HostWindow>();
8695
for (const element of document.querySelectorAll(`[${MEDIA_RENDER_ID_ATTR}]`)) {
8796
const renderId = element.getAttribute(MEDIA_RENDER_ID_ATTR);
8897
if (!renderId) continue;
89-
windows.set(renderId, resolveHostWindow(element as unknown as Element));
98+
windows.set(
99+
renderId,
100+
resolveHostWindow(element as unknown as Element, document, startCache, visiting),
101+
);
90102
}
91103
return windows;
92104
}

0 commit comments

Comments
 (0)