Files
hyperframes/packages/engine/src/services/referenceResolver.ts
T
Miguel Ángel 0a5c235173 feat(cli): hyperframes timeline prints a project's tracks and clips (#4138)
* feat(cli): hyperframes timeline prints a project's tracks and clips

* docs(cli): list the timeline command

* feat(cli): timeline command reports unreadable lanes; add skill docs

* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes

* fix(cli): timeline resolves symlinks before the project containment check

* fix(cli): timeline accepts project folders whose name starts with two dots

* refactor(cli): timeline and compositions share the engine's start resolver

* docs(skills): point agents at the timeline command where they decide how to read a project

* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips

Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.

* fix(cli): timeline places nested media at the runtime start and tests its query one-liners

A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.

* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0

The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.

* refactor(core): one media-start rule shared by the runtime and the timeline command

The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.

* test(cli): timeline fixture for a media start written as an expression

parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
2026-09-19 04:27:45 -04:00

107 lines
4.2 KiB
TypeScript

/**
* Node-side resolver for relative `data-start` timing references, shared by
* every parser that reads media timing out of compiled HTML — video frames
* (`parseVideoElements`), images (`parseImageElements`), and audio
* (`parseAudioElements`). Keeping the resolution in ONE place is load-bearing:
* if audio and video disagree on what `data-start="intro"` means, a relative
* reference that renders a video at the right time silently drops the audio
* track (they used to — audio parsed `parseFloat("intro") = NaN`).
*
* Mirrors the browser runtime's startResolver so `snapshot`/`render` agree.
* DOM access is via a minimal structural shape so it works against linkedom
* (Node) without pulling in lib.dom types.
*/
import { parseNumeric, parseStartExpression } from "@hyperframes/core";
/** Minimal structural DOM shape the reference resolver needs. */
export interface RefResolverEl {
getAttribute(name: string): string | null;
}
export interface RefResolverDoc {
getElementById(id: string): RefResolverEl | null;
querySelector(selector: string): RefResolverEl | null;
}
/**
* Find the element a relative `data-start` reference points at — by `id`
* first, then by `data-composition-id` (a sub-composition can be referenced).
* The reference-id grammar (see parseStartExpression) is restricted to
* `[A-Za-z0-9_.:-]`, none of which need escaping inside a quoted attribute
* selector, so no CSS.escape (absent in linkedom) is required.
*/
function findReferenceTargetEl(doc: RefResolverDoc, refId: string): RefResolverEl | null {
return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`);
}
/**
* Resolve an element's absolute start time (seconds) the same way the browser
* runtime's startResolver does, so `<video data-start="intro">` (a relative
* reference to another clip's end) renders at the right time instead of
* producing NaN and compositing blank / dropping audio. Durations come from
* `data-duration` or `data-end` here; the natural-media-duration fallback isn't
* known at parse time, so — exactly like the runtime — an unknown-duration
* reference falls back to the target's start and an unknown target falls back
* to 0 (never NaN).
*/
export function resolveReferencedStart(
doc: RefResolverDoc,
el: RefResolverEl,
startCache: Map<RefResolverEl, number>,
visiting: Set<RefResolverEl>,
): number {
const cached = startCache.get(el);
if (cached !== undefined) return cached;
if (visiting.has(el)) return 0; // cycle guard (A -> B -> A)
visiting.add(el);
try {
const expression = parseStartExpression(el.getAttribute("data-start"));
if (!expression) {
startCache.set(el, 0);
return 0;
}
if (expression.kind === "absolute") {
const value = Math.max(0, expression.value);
startCache.set(el, value);
return value;
}
const target = findReferenceTargetEl(doc, expression.refId);
if (!target) {
startCache.set(el, 0);
return 0;
}
const targetStart = resolveReferencedStart(doc, target, startCache, visiting);
const targetDuration = resolveReferencedDuration(doc, target, startCache, visiting);
const resolved =
targetDuration != null && targetDuration > 0
? Math.max(0, targetStart + targetDuration + expression.offset)
: Math.max(0, targetStart + expression.offset);
startCache.set(el, resolved);
return resolved;
} finally {
visiting.delete(el);
}
}
/**
* Duration of a referenced clip, from `data-duration` or `data-end - start`.
* Returns null when only the natural media duration would settle it (unknown
* at parse time) — the caller then treats the reference as duration-0.
*/
export function resolveReferencedDuration(
doc: RefResolverDoc,
el: RefResolverEl,
startCache: Map<RefResolverEl, number>,
visiting: Set<RefResolverEl>,
): number | null {
const durationAttr = parseNumeric(el.getAttribute("data-duration"));
if (durationAttr != null && durationAttr > 0) return durationAttr;
const endAttr = parseNumeric(el.getAttribute("data-end"));
if (endAttr != null) {
const start = resolveReferencedStart(doc, el, startCache, visiting);
const delta = endAttr - start;
if (Number.isFinite(delta) && delta > 0) return delta;
}
return null;
}