Files
hyperframes/packages/core/src/runtime/clipTree.ts
T
Miguel Ángel afafca4b96 feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe

* fix: align media playback timing

* docs: add creator editing recipes

* docs: expand creator editing guidance

* fix: unify media source offsets

* fix: scale natural media duration

* fix: preserve natural media zero spans

* fix: align compiled natural media timing

* test: classify compiler media test as integration

* fix: drop inactive media windows

* fix: unify literal timing parsing

* fix: keep browser media parsing serializable

* fix: keep page timing readers strict

* fix: close remaining preview timing gaps

* fix(core): preserve Studio voice pitch at playback speed

* chore: keep creator contract source-neutral
2026-08-18 10:17:02 -04:00

134 lines
4.4 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 } from "./playbackRate";
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 (!(el instanceof HTMLMediaElement) || !Number.isFinite(el.duration)) return null;
return resolveNaturalMediaTimelineDuration(el, el.duration);
}
// 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),
};
}