mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* 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.
107 lines
4.2 KiB
TypeScript
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;
|
|
}
|