Skip to content
Open
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 @@ -116,6 +116,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 @@ -212,6 +223,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
44 changes: 26 additions & 18 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,8 +13,13 @@
* 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";

// ── Types ────────────────────────────────────────────────────────────────

export interface UnresolvedElement {
Expand Down Expand Up @@ -136,23 +141,25 @@ function compileTag(
result = injectAttr(result, "data-hf-auto-start", "");
startStr = "0";
}
const start = parseFloat(startStr);
const start = parseNumeric(startStr);
const mediaStartStr = getAttr(result, "data-media-start");
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
const mediaStart = parseNumeric(mediaStartStr) ?? 0;

// 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");
if (durationStr !== null) {
const end = start + parseFloat(durationStr);
result = injectAttr(result, "data-end", String(end));
if (start != null) {
const end = start + parseFloat(durationStr);
if (Number.isFinite(end)) result = injectAttr(result, "data-end", String(end));
}
} 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,
};
}
Expand Down Expand Up @@ -213,7 +220,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
unresolved.push({
id,
tagName: "div",
start: startStr ? parseFloat(startStr) : 0,
start: parseNumeric(startStr) ?? 0,
mediaStart: 0,
compositionSrc: compositionSrc ?? undefined,
});
Expand Down Expand Up @@ -245,11 +252,12 @@ export function injectDurations(html: string, resolutions: ResolvedDuration[]):
result = injectAttr(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 @@ -290,9 +298,9 @@ 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: mediaStartStr ? parseFloat(mediaStartStr) : 0,
mediaStart: parseNumeric(mediaStartStr) ?? 0,
loop: hasAttr(tag, "loop"),
});
}
Expand All @@ -313,10 +321,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
2 changes: 2 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ export {
isVideoFrameFormat,
} from "./services/videoFrameExtractor.js";

export { resolveReferencedStart, type RefResolverEl } from "./services/referenceResolver.js";

export { createVideoFrameInjector } from "./services/videoFrameInjector.js";

export {
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 @@ -44,6 +44,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 @@ -704,6 +705,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
17 changes: 17 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,7 @@ describe("template-wrapped sub-composition media offsets", () => {
hostAttrs: string,
mediaAttrs: string = 'data-start="0" data-duration="4"',
extraMediaMarkup: string = "",
extraRootMarkup: string = "",
): {
projectDir: string;
indexPath: string;
Expand All @@ -1050,6 +1051,7 @@ describe("template-wrapped sub-composition media offsets", () => {
data-height="360"
data-duration="4"
>
${extraRootMarkup}
<div
id="scene-host"
data-composition-id="scene"
Expand Down Expand Up @@ -1115,6 +1117,21 @@ describe("template-wrapped sub-composition media offsets", () => {
});
});

it("resolves a host data-start id-ref against a sibling clip", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="intro" data-duration="2" data-width="640" data-height="360"',
'data-start="0" data-duration="4"',
"",
'<video id="intro" src="assets/clip.mp4" data-start="0" data-duration="10" muted></video>',
);

const compiled = await compileForRender(projectDir, indexPath, projectDir);
expect(compiled.videos.find((v) => v.id === "scene-video")).toMatchObject({
start: 10,
end: 14,
});
});

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
6 changes: 5 additions & 1 deletion packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import {
assertMediaPayload,
NotMediaPayloadError,
probeMediaProfile,
resolveReferencedStart,
type RefResolverEl,
} from "@hyperframes/engine";
import {
downloadToTemp,
Expand Down Expand Up @@ -630,6 +632,8 @@ async function parseSubCompositions(

const { document } = parseHTML(html);
const compEls = document.querySelectorAll("[data-composition-src]");
const startCache = new Map<RefResolverEl, number>();
const visiting = new Set<RefResolverEl>();

// Build work items, filtering out invalid/circular entries synchronously
const workItems: Array<{
Expand All @@ -645,7 +649,7 @@ async function parseSubCompositions(
const srcPath = el.getAttribute("data-composition-src");
if (!srcPath) continue;

const elStart = parseFloat(el.getAttribute("data-start") || "0");
const elStart = resolveReferencedStart(document, el, startCache, visiting);
const elEndRaw = el.getAttribute("data-end");
const elEnd = elEndRaw ? parseFloat(elEndRaw) : Infinity;

Expand Down