Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down
39 changes: 22 additions & 17 deletions packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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;
});
Expand Down
6 changes: 6 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/services/referenceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
11 changes: 11 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>' +
Expand Down
29 changes: 29 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand Down
19 changes: 19 additions & 0 deletions packages/producer/src/services/renderMediaCollector.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
29 changes: 21 additions & 8 deletions packages/producer/src/services/renderMediaCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
parseVideoElements,
parseImageElements,
parseAudioElements,
resolveReferencedStart,
type RefResolverEl,
type RefResolverDoc,
type VideoElement,
type ImageElement,
type AudioElement,
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
Loading