Files
hyperframes/packages/core/src/runtime/clipTree.ts
T
Miguel Ángel 09aadd86a5 feat(core): video, audio and image length come from one shared resolver (#4166)
A new resolver in @hyperframes/parsers decides how long a video, audio or image
occupies the timeline. The core runtime start resolver, clip manifest and clip
tree now read length through it. An image with data-start and no data-duration
gets the dropped-image default of 3 seconds; a bare image stays a static layer.
Video and audio take their length from the file until an authored duration trims it.

The playback rate bound (0.1 to 10) now lives in parsers, and core re-exports it.
A rate lane keeps its own arithmetic in core.

The lint rule that asked images for data-duration is removed, and the docs no
longer say an image requires it.
2026-09-19 08:01:28 -04:00

143 lines
4.5 KiB
TypeScript

/**
* window.__clipTree — hierarchical clip tree for Studio.
*
* Maps every timed element to a node so Studio can derive parent/child
* relationships for inline timeline expansion. Read-only: timing edits go
* through the host's normal save → reloadPreview path, not a DOM patch here.
*
* ponytail: intentionally minimal. Node carries only what consumers read
* (id/parentId/children) plus the backing element. Add fields (label, kind,
* absolute start) when a caller needs them.
*/
import type { RuntimeTimelineLike } from "./types";
import {
parseStrictFiniteTimingNumber,
resolveNaturalMediaTimelineDuration,
resolveTimedImageDurationSeconds,
} from "./playbackRate";
import { isMediaElement } from "./domRealm";
export interface ClipNode {
readonly id: string;
readonly element: Element;
readonly parentId: string | null;
readonly children: readonly ClipNode[];
}
export interface ClipTree {
readonly roots: readonly ClipNode[];
}
// Mutable shape used only while building; the public ClipTree exposes it as
// readonly so Studio consumers can't accidentally mutate the live tree.
type MutableClipNode = {
id: string;
element: Element;
parentId: string | null;
children: MutableClipNode[];
};
const DECORATIVE_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);
/**
* Stable identity for a timed element, shared by __clipTree and __clipManifest
* so the two id spaces align. Prefers the author `id`, then the generator's
* `data-hf-id` (present on every generated element). Without the data-hf-id
* fallback an id-less child (root index.html children use data-hf-id, not id)
* gets a synthetic `__clip-N` in the tree but `null` in the manifest, so inline
* timeline expansion can't join them and never expands.
*/
export function stableClipId(el: Element): string | null {
return (el as HTMLElement).id || el.getAttribute("data-hf-id") || null;
}
interface StartResolverLike {
resolveStartForElement: (element: Element, fallback?: number) => number;
}
function parseNum(value: string | null): number | null {
return parseStrictFiniteTimingNumber(value);
}
function durationFromTimeline(
el: Element,
registry: Record<string, RuntimeTimelineLike | undefined>,
): number | null {
const compId = el.getAttribute("data-composition-id");
if (!compId) return null;
const d = Number(registry[compId]?.duration?.());
return Number.isFinite(d) && d > 0 ? d : null;
}
function durationFromMedia(el: Element): number | null {
if (isMediaElement(el)) {
return Number.isFinite(el.duration)
? resolveNaturalMediaTimelineDuration(el, el.duration)
: null;
}
return resolveTimedImageDurationSeconds(el);
}
// Used only to filter out zero-duration (decorative) elements at build time.
function resolveDuration(
el: Element,
timelineRegistry: Record<string, RuntimeTimelineLike | undefined>,
rootDuration: number,
absoluteStart: number,
): number {
const attr = parseNum(el.getAttribute("data-duration"));
if (attr != null && attr > 0) return attr;
return (
durationFromTimeline(el, timelineRegistry) ??
durationFromMedia(el) ??
Math.max(0, rootDuration - absoluteStart)
);
}
function linkParentChild(elementToNode: Map<Element, MutableClipNode>): void {
for (const [el, node] of elementToNode) {
let cursor = el.parentElement;
while (cursor) {
const parentNode = elementToNode.get(cursor);
if (parentNode) {
node.parentId = parentNode.id;
parentNode.children.push(node);
break;
}
cursor = cursor.parentElement;
}
}
}
export function createClipTree(params: {
startResolver: StartResolverLike;
timelineRegistry: Record<string, RuntimeTimelineLike | undefined>;
rootDuration: number;
}): ClipTree {
const { startResolver, timelineRegistry, rootDuration } = params;
const elementToNode = new Map<Element, MutableClipNode>();
const root = document.querySelector("[data-composition-id]");
let ordinal = 0;
for (const el of document.querySelectorAll("[data-start]")) {
if (el === root || DECORATIVE_TAGS.has(el.tagName)) continue;
const absoluteStart = startResolver.resolveStartForElement(el, 0);
if (resolveDuration(el, timelineRegistry, rootDuration, absoluteStart) <= 0) continue;
const node: MutableClipNode = {
id: stableClipId(el) ?? `__clip-${ordinal++}`,
element: el,
parentId: null,
children: [],
};
elementToNode.set(el, node);
}
linkParentChild(elementToNode);
return {
roots: Array.from(elementToNode.values()).filter((n) => n.parentId === null),
};
}