mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-07 15:27:14 +00:00
* feat(studio): order the clip menu into time, sound, picture and delete groups Split and Freeze frame; Normalize, Voice, link items and Duck; Look and Crop; then Delete beside Delete this clip only. Each item file renders its pieces separately, empty groups collapse, and the viewport clamp uses the measured menu height instead of a row-count estimate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio-server): mint freeze-frame right-half links with the shared lk-N helper Freeze frame now calls core relinkSplitHalves, so split halves and freeze halves share one link-id convention instead of L -> L-2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): apply speed edits to every data-link member in one undo step The inspector speed slider, rate lane edits and ramp presets on a linked clip now commit once, writing the selection and, via expandToLinkedMembers, each partner from its own current value. Unlinked clips keep their usual path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(studio): drop the audio parent tick in favour of the data-link chain badge A linked clip now shows one link indicator: the chain badge driven by data-link. The waveform no longer draws its 1px parent tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): follow late clip-menu rows and target link partners like S2 The menu height follows a ResizeObserver so rows that read the live preview after mount still clamp inside the viewport. Speed fan-out builds partner targets with buildPatchTarget, matching the link edit plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): show the sound strip and audio menu items without studio's shell The audible-video waveform strip drops AudioWaveform's 16px label band, which left a 0px canvas on short lanes. Normalize loudness and Duck under voice fall back to the timeline session's project, the live preview iframe store and a new onNotice edit callback when no StudioShellProvider is mounted. usePreviewIframeStore is exported so a host can publish its preview. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): show duck under voice only when a voice overlaps The item now appears only when an overlapping voice exists or the bed is already ducked. Its check mark moves to the right edge so unchecked rows align with siblings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): name the partner in the clip menu link and unlink items Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): clip menu stops offering unlink once the clips are unlinked The timeline skipped store updates when only data-link changed, so the menu kept reading the stale link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(core): sync origin, offset math and move/slip into sync for linked pairs data-sync-origin survives Unlink; split gives right halves a fresh origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio-server): split re-mints the sync origin of unlinked pairs too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): detach, link and keep-sound cutout write a sync origin that unlink keeps TimelineElement.syncOrigin is read on both parse paths; merge back drops it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(studio): expect sync-origin on keep-sound cutout write Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): linked selection toggle in the timeline toolbar, on by default Off: a click selects one clip; drag, trim, razor, speed and delete stay on it (delete unlinks the survivor). Alt-click still selects one half when on. Right-click holds the out-of-sync indicator preference. Both persist in UI prefs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): link groups share a label colour; out-of-sync badges with move and slip Red badge on both halves of a sync-origin pair that drifted (frames, s:ff past a second); hidden when rates differ or the preference is off. The whole badge opens Move / Slip into Sync; each edits that clip alone in one undo step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): take link label colours from theme tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(sdk): syncOffset, moveIntoSync and slipIntoSync for sync-origin pairs Core gains findSyncPartner, the DOM pairing shared by the SDK and the CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cli): timeline rows report syncOffsetFrames and print out-of-sync Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cli): the timeline row fixture carries syncOffsetFrames Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): data-sync-origin in hyperframes-core Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(core,sdk): keep a one-sided cut's sync origin and slip the in-point playback reads relinkSplitHalves reminted the right half's sync origin even when only one member of an unlinked source pair was cut, so the uncut partner kept the old origin and the new half lost its badge. The origin is now reminted only when both the video and the audio were cut. Slip into Sync wrote data-media-start whenever it existed, but playback reads data-playback-start first, so a clip carrying both never moved. The link timing reader now uses playback's precedence and the slip writes the attribute that wins; lint compares the same field. A sync partner is also looked up only in the clip's own composition (core) or source file (Studio). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): flip the out-of-sync menu above the pointer near the bottom edge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: trim comments the sync-origin layer added Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(core): split the scoped candidate lookup out of findSyncPartner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(core,studio): find sync partners inside the link scope findSyncPartner and Studio's syncPartnerOf now scope by the same rule as link groups: the nearest data-composition-id or data-composition-file ancestor, plus the row's compositionScope in Studio. An origin reused in a file host or an inline composition no longer pairs across it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
936 lines
36 KiB
TypeScript
936 lines
36 KiB
TypeScript
/**
|
|
* Phase 3a — real editing session.
|
|
*
|
|
* CompositionImpl: live linkedom document, real dispatch, RFC 6902 patch emission,
|
|
* override-set accumulation, batch, can(), serialize(), applyPatches().
|
|
*
|
|
* openComposition() wires history + persist queue for standalone (T1/T2) mode.
|
|
* T3 (embedded) callers supply overrides; SDK emits patches only — host owns state.
|
|
*/
|
|
|
|
import type {
|
|
CanResult,
|
|
Composition,
|
|
EditOp,
|
|
ElementSnapshot,
|
|
ElementTimingSnapshot,
|
|
FindQuery,
|
|
FontValue,
|
|
GsapTweenSpec,
|
|
ElasticHold,
|
|
KeyframeSpec,
|
|
HfId,
|
|
ImageValue,
|
|
JsonPatchOp,
|
|
OverrideSet,
|
|
PatchEvent,
|
|
PersistErrorEvent,
|
|
SelectionProxy,
|
|
ElementHandle,
|
|
VariableUsageReport,
|
|
} from "./types.js";
|
|
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
|
import { buildRoots, flatElements, parsedAnimationIds } from "./document.js";
|
|
import { syncFixFor, syncOffsetOf } from "./engine/syncTiming.js";
|
|
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
|
|
import { parseMutable } from "./engine/model.js";
|
|
import type { ParsedDocument } from "./engine/model.js";
|
|
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
|
|
import { getGsapScripts, resolveScoped, declarationCarriers } from "./engine/model.js";
|
|
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
|
|
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
|
|
import { readClipTiming, type ClipTiming } from "@hyperframes/core/composition-contract";
|
|
import {
|
|
readDeclaredDefaults,
|
|
validateVariables,
|
|
scanVariableUsage,
|
|
} from "@hyperframes/core/variables";
|
|
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
|
|
import { readVariableDeclarations } from "./engine/variableModel.js";
|
|
import { serializeDocument } from "./engine/serialize.js";
|
|
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
|
|
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
|
|
import { createHistory } from "./history.js";
|
|
import type { HistoryModule } from "./history.js";
|
|
import { createPersistQueue } from "./persist-queue.js";
|
|
import type { PersistQueueModule } from "./persist-queue.js";
|
|
|
|
export interface OpenCompositionOptions {
|
|
persist?: PersistAdapter;
|
|
/** Adapter path the persist queue writes to. Default: "composition.html". Immutable for the session lifetime. */
|
|
persistPath?: string;
|
|
preview?: PreviewAdapter;
|
|
/** T3 embedded mode: override-set applied on top of the base template. */
|
|
overrides?: OverrideSet;
|
|
/** Origins whose mutations enter the undo stack. Default: all non-applyPatches. */
|
|
trackedOrigins?: unknown[];
|
|
/** Auto-coalesce window for history entries (ms). Default: 300. */
|
|
coalesceMs?: number;
|
|
/**
|
|
* Pass `false` to skip attaching the history module (undo/redo).
|
|
* Default: history is attached in standalone (non-embedded) mode.
|
|
* Use when the host owns the undo stack and SDK undo is dead weight.
|
|
*/
|
|
history?: false;
|
|
}
|
|
|
|
// ─── Implementation ───────────────────────────────────────────────────────────
|
|
|
|
/** Escape a string for literal use inside a RegExp. */
|
|
function escapeRegExp(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
class CompositionImpl implements Composition {
|
|
private readonly parsed: ParsedDocument;
|
|
private readonly persist: PersistAdapter | undefined;
|
|
readonly preview: PreviewAdapter | undefined;
|
|
|
|
/** Accumulated override-set — T3 embedded mode fold contract. */
|
|
private overrides: OverrideSet;
|
|
|
|
/**
|
|
* Declared variable defaults as authored at open — captured BEFORE the
|
|
* T3 override-set (and any later setVariableValue) folded into the
|
|
* declarations. Serves getVariableValue({ base: true }).
|
|
*/
|
|
private readonly baseVariableDefaults: Record<string, unknown>;
|
|
|
|
/** Lazily-built element snapshot, invalidated on every mutation. */
|
|
private elementsCache: ElementSnapshot[] | null = null;
|
|
/** Lazily-built root snapshot (getRootElements), invalidated alongside elementsCache. */
|
|
private rootsCache: ElementSnapshot[] | null = null;
|
|
|
|
private currentSelection: string[] = [];
|
|
|
|
private changeHandlers: Array<() => void> = [];
|
|
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
|
private patchHandlers: Array<(e: PatchEvent) => void> = [];
|
|
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
|
|
private previewSelectionUnsubscribe: (() => void) | null = null;
|
|
|
|
/** Attached by openComposition() for standalone mode. */
|
|
private historyModule: HistoryModule | null = null;
|
|
private persistQueueModule: PersistQueueModule | null = null;
|
|
|
|
/** Batching state: accumulates patches from multiple dispatches. */
|
|
private batchDepth = 0;
|
|
private batchForward: JsonPatchOp[] = [];
|
|
private batchInverse: JsonPatchOp[] = [];
|
|
private batchOpTypes: string[] = [];
|
|
private batchOrigin: unknown = ORIGIN_LOCAL;
|
|
/** Override-set state at outermost batch entry — restored if the batch throws. */
|
|
private batchOverridesSnapshot: OverrideSet = {};
|
|
|
|
constructor(
|
|
parsed: ParsedDocument,
|
|
opts: OpenCompositionOptions,
|
|
baseVariableDefaults: Record<string, unknown> = {},
|
|
) {
|
|
this.parsed = parsed;
|
|
this.persist = opts.persist;
|
|
this.preview = opts.preview;
|
|
this.overrides = { ...(opts.overrides ?? {}) };
|
|
this.baseVariableDefaults = baseVariableDefaults;
|
|
this.previewSelectionUnsubscribe =
|
|
this.preview?.on("selection", (ids) => this.updateSelection(ids)) ?? null;
|
|
}
|
|
|
|
attachHistory(module: HistoryModule): void {
|
|
this.historyModule = module;
|
|
}
|
|
|
|
attachPersistQueue(module: PersistQueueModule): void {
|
|
this.persistQueueModule = module;
|
|
}
|
|
|
|
_fireError(e: PersistErrorEvent): void {
|
|
this.errorHandlers.forEach((h) => h(e));
|
|
}
|
|
|
|
// ── Typed methods (F10 layer 1) ─────────────────────────────────────────────
|
|
|
|
setStyle(id: HfId, styles: Record<string, string | null>): void {
|
|
this.dispatch({ type: "setStyle", target: id, styles });
|
|
}
|
|
|
|
setText(id: HfId, value: string): void {
|
|
this.dispatch({ type: "setText", target: id, value });
|
|
}
|
|
|
|
setAttribute(id: HfId, name: string, value: string | null): void {
|
|
this.dispatch({ type: "setAttribute", target: id, name, value });
|
|
}
|
|
|
|
setTiming(
|
|
id: HfId,
|
|
timing: { start?: number; duration?: number; trackIndex?: number },
|
|
opts?: { linked?: boolean },
|
|
): void {
|
|
this.dispatch({ type: "setTiming", target: id, ...timing, ...opts });
|
|
}
|
|
|
|
syncOffset(id: HfId, fps = 30): number | null {
|
|
return syncOffsetOf(this.parsed.document, id, fps);
|
|
}
|
|
|
|
/** Moves `id` alone; a link group it belongs to is left (as `setTiming` `linked: false`). */
|
|
moveIntoSync(id: HfId): void {
|
|
const fix = syncFixFor(this.parsed.document, id, "move");
|
|
if (fix.kind === "move")
|
|
this.dispatch({ type: "setTiming", target: id, start: fix.start, linked: false });
|
|
}
|
|
|
|
slipIntoSync(id: HfId): void {
|
|
const fix = syncFixFor(this.parsed.document, id, "slip");
|
|
if (fix.kind === "slip")
|
|
this.dispatch({ type: "setAttribute", target: id, name: fix.name, value: fix.value });
|
|
}
|
|
|
|
removeElement(id: HfId): void {
|
|
this.dispatch({ type: "removeElement", target: id });
|
|
}
|
|
|
|
addElement(parent: HfId | null, index: number, html: string): HfId {
|
|
const result = this._dispatch({ type: "addElement", parent, index, html }, ORIGIN_LOCAL);
|
|
return result.meta?.newId ?? "";
|
|
}
|
|
|
|
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void {
|
|
this.dispatch({ type: "setVariableValue", id, value });
|
|
}
|
|
|
|
// ── #2098 CRUD conveniences — thin aliases over the canonical surface below.
|
|
// They delegate so the per-file declaration-element scope (template/fragment
|
|
// sub-comps included) is resolved in exactly one place.
|
|
getVariableValue(
|
|
id: string,
|
|
opts?: { base?: boolean },
|
|
): string | number | boolean | FontValue | ImageValue | undefined {
|
|
// { base: true } reads the declaration default as authored at open —
|
|
// BEFORE the override-set / any setVariableValue folded into it. This
|
|
// is the value an undo-to-base must restore; the live default IS the
|
|
// override after a fold. Ids unseen at open (declared mid-session) fall
|
|
// through to the live default — their declaration is their base.
|
|
const source =
|
|
opts?.base && id in this.baseVariableDefaults
|
|
? this.baseVariableDefaults
|
|
: this.getVariableValues();
|
|
return source[id] as string | number | boolean | FontValue | ImageValue | undefined;
|
|
}
|
|
|
|
listVariables(): CompositionVariable[] {
|
|
return this.getVariableDeclarations();
|
|
}
|
|
|
|
removeVariable(id: string): void {
|
|
this.dispatch({ type: "removeVariable", id });
|
|
}
|
|
|
|
// ── Canonical declaration edit ops (this stack) ──
|
|
// declareVariable unified onto the {declaration} op payload (#2098 used
|
|
// {decl}); the method signature is identical, so #2098 callers are unaffected.
|
|
declareVariable(declaration: CompositionVariable): void {
|
|
this.dispatch({ type: "declareVariable", declaration });
|
|
}
|
|
|
|
updateVariableDeclaration(id: string, declaration: CompositionVariable): void {
|
|
this.dispatch({ type: "updateVariableDeclaration", id, declaration });
|
|
}
|
|
|
|
removeVariableDeclaration(id: string): void {
|
|
this.dispatch({ type: "removeVariableDeclaration", id });
|
|
}
|
|
|
|
getVariableDeclarations(): CompositionVariable[] {
|
|
const byId = new Map<string, CompositionVariable>();
|
|
for (const el of declarationCarriers(this.parsed.document, this.parsed.wrapped))
|
|
for (const decl of readVariableDeclarations(el)) byId.set(decl.id, decl);
|
|
return [...byId.values()];
|
|
}
|
|
|
|
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown> {
|
|
// THIS composition's own declared defaults (loose extraction: any entry with
|
|
// a string id + a `default` key, even ones the strict declaration parser
|
|
// drops) spread under the overrides. Scope note: this reads the composition's
|
|
// own carriers (<html> and its root) only — NOT every `[data-composition-
|
|
// variables]` in the document. The runtime's getVariables()
|
|
// (core/runtime/getVariables.ts) additionally walks inlined sub-composition
|
|
// declarers because it operates on the bundled multi-composition document;
|
|
// the SDK models one composition file, so per-file scope is intended.
|
|
const defaults = declaredDefaults(this.parsed);
|
|
return { ...defaults, ...(overrides ?? {}) };
|
|
}
|
|
|
|
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[] {
|
|
return validateVariables(values, this.getVariableDeclarations());
|
|
}
|
|
|
|
/**
|
|
* Script scans are content-keyed (same rationale as _gsapLabelCache): the
|
|
* panel recomputes usage on every preview reload, and unchanged script text
|
|
* is the common case — never pay a second acorn parse for identical input.
|
|
*/
|
|
private _variableUsageScanCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
|
|
|
|
// Scan/merge dispatcher — same complexity class as the suppressed
|
|
// variableUsage.ts classifiers it drives.
|
|
// fallow-ignore-next-line complexity
|
|
getVariableUsage(): VariableUsageReport {
|
|
const usedIds: string[] = [];
|
|
const seen = new Set<string>();
|
|
let scanIncomplete = false;
|
|
const freshCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
|
|
// Inline scripts only — external src scripts aren't part of the document model.
|
|
for (const script of Array.from(this.parsed.document.querySelectorAll("script"))) {
|
|
if (script.getAttribute("src")) continue;
|
|
const text = script.textContent ?? "";
|
|
// Direct global reads (window.__hfVariables / __hfVariablesByComp) are
|
|
// invisible to the getVariables() scanner — the report must degrade to
|
|
// a lower bound instead of confidently claiming declarations unused.
|
|
if (text.includes("__hfVariables")) scanIncomplete = true;
|
|
if (!text.includes("getVariables")) continue; // cheap pre-filter before an acorn parse
|
|
const scan = this._variableUsageScanCache.get(text) ?? scanVariableUsage(text);
|
|
freshCache.set(text, scan);
|
|
scanIncomplete = scanIncomplete || scan.scanIncomplete;
|
|
for (const id of scan.usedIds) {
|
|
if (!seen.has(id)) {
|
|
seen.add(id);
|
|
usedIds.push(id);
|
|
}
|
|
}
|
|
}
|
|
// Declarative bindings (data-var-src / data-var-text) are direct reads.
|
|
for (const el of Array.from(
|
|
this.parsed.document.querySelectorAll("[data-var-src], [data-var-text]"),
|
|
)) {
|
|
for (const attr of ["data-var-src", "data-var-text"]) {
|
|
const id = el.getAttribute(attr)?.trim();
|
|
if (id && !seen.has(id)) {
|
|
seen.add(id);
|
|
usedIds.push(id);
|
|
}
|
|
}
|
|
}
|
|
this._variableUsageScanCache = freshCache;
|
|
const declaredIds = this.getVariableDeclarations().map((d) => d.id);
|
|
// The CSS compat channel counts as usage: a variable consumed only via
|
|
// var(--id) in stylesheets or inline styles must not be badged unused
|
|
// (removing it also removes the --{id} root prop and breaks the binding).
|
|
const cssText = this._collectCssText();
|
|
// Match var(--id) only at a custom-property-name boundary: the id must be
|
|
// followed by whitespace, a comma (fallback), or the closing paren — so id
|
|
// "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are
|
|
// regex-escaped because a value read from disk may predate can()'s
|
|
// /^[A-Za-z_][A-Za-z0-9_-]*$/ enforcement and carry metacharacters.
|
|
const cssUsed = (id: string) =>
|
|
new RegExp(`var\\(\\s*--${escapeRegExp(id)}[\\s,)]`).test(cssText);
|
|
const declaredSet = new Set(declaredIds);
|
|
return {
|
|
usedIds,
|
|
unusedDeclarations: declaredIds.filter((id) => !seen.has(id) && !cssUsed(id)),
|
|
undeclaredReads: usedIds.filter((id) => !declaredSet.has(id)),
|
|
scanIncomplete,
|
|
};
|
|
}
|
|
|
|
/** All stylesheet + inline-style text, for var(--id) consumption checks. */
|
|
private _collectCssText(): string {
|
|
const cssParts: string[] = [];
|
|
for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) {
|
|
cssParts.push(styleEl.textContent ?? "");
|
|
}
|
|
for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) {
|
|
cssParts.push(el.getAttribute("style") ?? "");
|
|
}
|
|
return cssParts.join("\n");
|
|
}
|
|
|
|
setPreviewVariables(values: Record<string, unknown> | null): boolean {
|
|
if (!this.preview?.setPreviewVariables) return false;
|
|
this.preview.setPreviewVariables(values);
|
|
return true;
|
|
}
|
|
|
|
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
|
|
|
|
/**
|
|
* Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts.
|
|
* extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated
|
|
* getElementTimings reads when the scripts are unchanged.
|
|
*/
|
|
private _gsapLabelCache: {
|
|
scripts: string[];
|
|
labels: ReturnType<typeof extractGsapLabels>;
|
|
} | null = null;
|
|
|
|
// fallow-ignore-next-line complexity
|
|
getElementTimings(): Record<HfId, ElementTimingSnapshot> {
|
|
const scripts = getGsapScripts(this.parsed.document);
|
|
|
|
// Extract all addLabel("name", position) calls from every GSAP script.
|
|
let allLabels: ReturnType<typeof extractGsapLabels>;
|
|
const cachedScripts = this._gsapLabelCache?.scripts;
|
|
const cacheMatches =
|
|
cachedScripts?.length === scripts.length &&
|
|
cachedScripts.every((script, index) => script === scripts[index]);
|
|
if (cacheMatches) {
|
|
allLabels = this._gsapLabelCache?.labels ?? [];
|
|
} else {
|
|
allLabels = scripts.flatMap((script) => extractGsapLabels(script));
|
|
this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null;
|
|
}
|
|
|
|
// refId remains a bare global id, matching runtime resolution. The shared
|
|
// contract owns parsing, canonical duration preference, clamping, and
|
|
// legacy data-end compatibility; this adapter only supplies document lookup.
|
|
const timingCache = new Map<Element, ClipTiming>();
|
|
const visiting = new Set<Element>();
|
|
const resolveTiming = (el: Element): ClipTiming => {
|
|
const cached = timingCache.get(el);
|
|
if (cached) return cached;
|
|
if (visiting.has(el)) return readClipTiming(el);
|
|
visiting.add(el);
|
|
try {
|
|
const timing = readClipTiming(el, {
|
|
resolveReferenceEnd: (refId) => {
|
|
const target = resolveScoped(this.parsed.document, refId);
|
|
if (!target) return null;
|
|
const targetTiming = resolveTiming(target);
|
|
return targetTiming.end ?? targetTiming.start;
|
|
},
|
|
});
|
|
timingCache.set(el, timing);
|
|
return timing;
|
|
} finally {
|
|
visiting.delete(el);
|
|
}
|
|
};
|
|
|
|
const result: Record<HfId, ElementTimingSnapshot> = {};
|
|
const elements = this.getElements();
|
|
for (const el of elements) {
|
|
const domEl = resolveScoped(this.parsed.document, el.scopedId);
|
|
if (!domEl) continue;
|
|
|
|
const timing = resolveTiming(domEl);
|
|
const enterAt = timing.start ?? 0;
|
|
const duration = timing.duration;
|
|
if (duration === null) continue; // no timing info — skip non-timed elements
|
|
|
|
const exitAt = enterAt + duration;
|
|
|
|
// Labels whose position falls within [enterAt, exitAt] (end-inclusive: a
|
|
// label exactly at exitAt is treated as within the element's window).
|
|
const labels = allLabels
|
|
.filter(({ position }) => position >= enterAt && position <= exitAt)
|
|
.map(({ name }) => name);
|
|
|
|
result[el.scopedId] = { enterAt, exitAt, labels };
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
setElementTiming(
|
|
map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>,
|
|
): void {
|
|
const entries = Object.entries(map);
|
|
if (entries.length === 0) return;
|
|
|
|
this.batch(() => {
|
|
for (const [id, timing] of entries) {
|
|
this.dispatch({ type: "setTiming", target: id, ...timing });
|
|
}
|
|
});
|
|
}
|
|
|
|
setHold(id: HfId, hold: ElasticHold): void {
|
|
this.dispatch({ type: "setHold", target: id, hold });
|
|
}
|
|
|
|
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
|
|
const result = this._dispatch({ type: "addGsapTween", target, tween }, ORIGIN_LOCAL);
|
|
return result.meta?.animationId ?? "";
|
|
}
|
|
|
|
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void {
|
|
this.dispatch({ type: "setGsapTween", animationId, properties });
|
|
}
|
|
|
|
removeGsapTween(animationId: string): void {
|
|
this.dispatch({ type: "removeGsapTween", animationId });
|
|
}
|
|
|
|
addWithKeyframes(
|
|
targetSelector: string,
|
|
position: number,
|
|
duration: number,
|
|
keyframes: KeyframeSpec[],
|
|
ease?: string,
|
|
): string {
|
|
const result = this._dispatch(
|
|
{ type: "addWithKeyframes", targetSelector, position, duration, keyframes, ease },
|
|
ORIGIN_LOCAL,
|
|
);
|
|
return result.meta?.animationId ?? "";
|
|
}
|
|
|
|
replaceWithKeyframes(
|
|
animationId: string,
|
|
targetSelector: string,
|
|
position: number,
|
|
duration: number,
|
|
keyframes: KeyframeSpec[],
|
|
ease?: string,
|
|
): string {
|
|
const result = this._dispatch(
|
|
{
|
|
type: "replaceWithKeyframes",
|
|
animationId,
|
|
targetSelector,
|
|
position,
|
|
duration,
|
|
keyframes,
|
|
ease,
|
|
},
|
|
ORIGIN_LOCAL,
|
|
);
|
|
// Position-derived IDs renumber after the remove — this is the NEW id, which
|
|
// may differ from the input animationId.
|
|
return result.meta?.animationId ?? "";
|
|
}
|
|
|
|
undo(): void {
|
|
this.historyModule?.undo();
|
|
}
|
|
|
|
redo(): void {
|
|
this.historyModule?.redo();
|
|
}
|
|
|
|
canUndo(): boolean {
|
|
return this.historyModule?.canUndo() ?? false;
|
|
}
|
|
|
|
canRedo(): boolean {
|
|
return this.historyModule?.canRedo() ?? false;
|
|
}
|
|
|
|
// ── Query API (F1) ───────────────────────────────────────────────────────────
|
|
|
|
getElements(): ElementSnapshot[] {
|
|
// Walk the live linkedom DOM directly — no serialize/re-parse round trip.
|
|
this.elementsCache ??= flatElements(buildRoots(this.parsed.document));
|
|
return [...this.elementsCache];
|
|
}
|
|
|
|
/**
|
|
* Top-level elements only (each still carrying its full descendant subtree via
|
|
* `.children`) — unlike `getElements()`, no element appears twice. Consumers building a
|
|
* tree view (a layer panel) want this, not `getElements()`: that method's flat list
|
|
* includes every descendant a second time as its own top-level entry, since each
|
|
* snapshot in it still carries its children. `buildRoots` already computes true roots
|
|
* internally for `getElements()` to flatten — this just returns them unflattened.
|
|
* Cached like elementsCache — a layer panel calling this every render tick shouldn't
|
|
* repay the DOM walk each time.
|
|
*/
|
|
getRootElements(): ElementSnapshot[] {
|
|
this.rootsCache ??= buildRoots(this.parsed.document);
|
|
return [...this.rootsCache];
|
|
}
|
|
|
|
getElement(id: HfId): ElementSnapshot | null {
|
|
// Accept both bare ids (top-level) and scoped ids (sub-composition elements).
|
|
// Match by scopedId first (canonical); bare-id fallback keeps top-level compat
|
|
// for callers that don't yet use scoped ids.
|
|
return (
|
|
this.getElements().find((el) => el.scopedId === id) ??
|
|
this.getElements().find((el) => el.id === id && el.scopedId === el.id) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
find(query: FindQuery): string[] {
|
|
return (
|
|
this.getElements()
|
|
// fallow-ignore-next-line complexity
|
|
.filter((el) => {
|
|
if (query.tag && el.tag !== query.tag) return false;
|
|
if (query.text && !el.text?.includes(query.text)) return false;
|
|
if (query.name && el.attributes["data-name"] !== query.name) return false;
|
|
if (query.track !== undefined && el.trackIndex !== query.track) return false;
|
|
if (query.composition && !el.scopedId.startsWith(`${query.composition}/`)) return false;
|
|
return true;
|
|
})
|
|
.map((el) => el.scopedId)
|
|
);
|
|
}
|
|
|
|
getAllAnimationIds(): Set<string> {
|
|
const ids = new Set<string>();
|
|
for (const script of getGsapScripts(this.parsed.document)) {
|
|
for (const id of parsedAnimationIds(script)) ids.add(id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// ── Selection API ────────────────────────────────────────────────────────────
|
|
|
|
selection(): SelectionProxy {
|
|
const ids = [...this.currentSelection];
|
|
return {
|
|
ids,
|
|
setStyle: (styles) => this.dispatch({ type: "setStyle", target: ids, styles }),
|
|
setText: (value) => this.dispatch({ type: "setText", target: ids, value }),
|
|
setAttribute: (name, value) =>
|
|
this.dispatch({ type: "setAttribute", target: ids, name, value }),
|
|
setTiming: (timing, opts) =>
|
|
this.dispatch({ type: "setTiming", target: ids, ...timing, ...opts }),
|
|
removeElement: () => this.dispatch({ type: "removeElement", target: ids }),
|
|
};
|
|
}
|
|
|
|
element(id: HfId): ElementHandle {
|
|
return {
|
|
id,
|
|
setStyle: (styles) => this.dispatch({ type: "setStyle", target: id, styles }),
|
|
setText: (value) => this.dispatch({ type: "setText", target: id, value }),
|
|
setAttribute: (name, value) =>
|
|
this.dispatch({ type: "setAttribute", target: id, name, value }),
|
|
setTiming: (timing, opts) =>
|
|
this.dispatch({ type: "setTiming", target: id, ...timing, ...opts }),
|
|
removeElement: () => this.dispatch({ type: "removeElement", target: id }),
|
|
};
|
|
}
|
|
|
|
getSelection(): string[] {
|
|
return [...this.currentSelection];
|
|
}
|
|
|
|
setSelection(ids: string[]): void {
|
|
const deduped = Array.from(new Set(ids));
|
|
if (
|
|
deduped.length === this.currentSelection.length &&
|
|
deduped.every((id, i) => id === this.currentSelection[i])
|
|
) {
|
|
return;
|
|
}
|
|
this.updateSelection(deduped);
|
|
}
|
|
|
|
private updateSelection(ids: readonly string[]): void {
|
|
this.currentSelection = [...ids];
|
|
for (const handler of this.selectionHandlers) {
|
|
handler([...this.currentSelection]);
|
|
}
|
|
}
|
|
|
|
// ── Dispatch / batch ─────────────────────────────────────────────────────────
|
|
|
|
// fallow-ignore-next-line complexity
|
|
private _dispatch(op: EditOp, origin: unknown): MutationResult {
|
|
const result = applyOp(this.parsed, op);
|
|
const { forward, inverse } = result;
|
|
|
|
if (forward.length === 0 && inverse.length === 0) {
|
|
if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h());
|
|
return result;
|
|
}
|
|
|
|
this.elementsCache = null;
|
|
this.rootsCache = null;
|
|
|
|
// Update override-set from forward patches
|
|
for (const p of forward) {
|
|
const key = pathToKey(p.path);
|
|
if (key !== null) {
|
|
this.overrides[key] =
|
|
p.op === "remove"
|
|
? null
|
|
: (p.value as string | number | boolean | Record<string, unknown> | null);
|
|
}
|
|
}
|
|
|
|
// Purge orphan property keys for removed elements so the override-set stays
|
|
// compact and a future T3 session doesn't replay stale properties onto a
|
|
// non-existent element. Override-set keys use decoded scoped ids ("hf-host/hf-leaf")
|
|
// while path segments use RFC 6902 encoding ("hf-host~1hf-leaf") — decode before compare.
|
|
for (const p of forward) {
|
|
const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path);
|
|
if (p.op === "remove" && elemMatch) {
|
|
// Decode RFC 6902 escaping: ~1 → /, ~0 → ~
|
|
const id = elemMatch[1]!.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
for (const key of Object.keys(this.overrides)) {
|
|
// Purge property sub-keys (e.g. "hf-x.style.color") but preserve
|
|
// the removal marker itself (key === id, set to null in the loop above).
|
|
if (key.startsWith(`${id}.`) || key.startsWith(`${id}/`)) {
|
|
delete this.overrides[key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.batchDepth > 0) {
|
|
this.batchForward.push(...forward);
|
|
this.batchInverse.push(...inverse);
|
|
if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type);
|
|
} else {
|
|
// Reverse the inverse list (parity with batch() below): an op that emits
|
|
// multiple patches whose undo order matters — same path (reorderElements
|
|
// with a duplicate target), an aliased multi-target, or a nested
|
|
// parent+child removeElement — must undo in reverse application order, or
|
|
// undo lands on an intermediate value / drops a subtree. Harmless for the
|
|
// common single-patch / independent-path case.
|
|
const event = buildPatchEvent(forward, [...inverse].reverse(), origin, [op.type]);
|
|
this.patchHandlers.forEach((h) => h(event));
|
|
this.changeHandlers.forEach((h) => h());
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
|
|
this._dispatch(op, opts?.origin ?? ORIGIN_LOCAL);
|
|
}
|
|
|
|
/**
|
|
* Coalesce multiple dispatches into one undo entry / one patch event.
|
|
*
|
|
* Transactional: if the callback throws, all DOM mutations applied so far
|
|
* are reverted (accumulated inverse patches replayed in reverse) and the
|
|
* override-set is restored — the model is exactly as it was at batch entry.
|
|
*
|
|
* Note: a batch that produces no effective mutations still fires 'change'
|
|
* handlers (parity with no-op dispatch) — subscribers must not assume
|
|
* silence when wrapping speculative operations.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
batch(fn: () => void, opts?: { origin?: unknown }): void {
|
|
const origin = opts?.origin ?? ORIGIN_LOCAL;
|
|
this.batchDepth++;
|
|
if (this.batchDepth === 1) {
|
|
this.batchOrigin = origin; // only set on outermost entry
|
|
this.batchOverridesSnapshot = { ...this.overrides };
|
|
}
|
|
let threw = false;
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
threw = true;
|
|
throw err;
|
|
} finally {
|
|
this.batchDepth--;
|
|
if (this.batchDepth === 0) {
|
|
if (!threw && this.batchForward.length > 0) {
|
|
const event = buildPatchEvent(
|
|
this.batchForward,
|
|
[...this.batchInverse].reverse(),
|
|
this.batchOrigin,
|
|
this.batchOpTypes,
|
|
);
|
|
// Fire handlers before resetting batch state so that if a handler
|
|
// throws the patch data (batchForward/batchInverse) is still intact
|
|
// for callers that inspect it on error. The event was already built
|
|
// from a snapshot so handler re-entrancy does not corrupt the event.
|
|
this.patchHandlers.forEach((h) => h(event));
|
|
this.changeHandlers.forEach((h) => h());
|
|
this.resetBatchState();
|
|
} else {
|
|
if (threw && this.batchInverse.length > 0) {
|
|
// Roll back: the dispatches inside the batch already mutated the
|
|
// DOM. Without this, a throwing batch would leave the model in a
|
|
// partial state with no patch trail to undo it.
|
|
applyPatchesToDocument(this.parsed, [...this.batchInverse].reverse());
|
|
this.overrides = { ...this.batchOverridesSnapshot };
|
|
this.elementsCache = null;
|
|
this.rootsCache = null;
|
|
}
|
|
this.resetBatchState();
|
|
// Empty no-op batch: fire changeHandlers (parity with dispatch)
|
|
if (!threw) this.changeHandlers.forEach((h) => h());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private resetBatchState(): void {
|
|
this.batchForward = [];
|
|
this.batchInverse = [];
|
|
this.batchOpTypes = [];
|
|
this.batchOrigin = ORIGIN_LOCAL;
|
|
this.batchOverridesSnapshot = {};
|
|
}
|
|
|
|
can(op: EditOp): CanResult {
|
|
return validateOp(this.parsed, op);
|
|
}
|
|
|
|
// ── Events ───────────────────────────────────────────────────────────────────
|
|
|
|
on(event: "change", handler: () => void): () => void;
|
|
on(event: "selectionchange", handler: (ids: string[]) => void): () => void;
|
|
on(event: "patch", handler: (event: PatchEvent) => void): () => void;
|
|
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
|
// fallow-ignore-next-line complexity
|
|
on(event: string, handler: unknown): () => void {
|
|
const h = handler as (...args: unknown[]) => void;
|
|
if (event === "change") {
|
|
this.changeHandlers.push(h as () => void);
|
|
return () => {
|
|
this.changeHandlers = this.changeHandlers.filter((x) => x !== h);
|
|
};
|
|
}
|
|
if (event === "selectionchange") {
|
|
this.selectionHandlers.push(h as (ids: string[]) => void);
|
|
return () => {
|
|
this.selectionHandlers = this.selectionHandlers.filter((x) => x !== h);
|
|
};
|
|
}
|
|
if (event === "patch") {
|
|
this.patchHandlers.push(h as (e: PatchEvent) => void);
|
|
return () => {
|
|
this.patchHandlers = this.patchHandlers.filter((x) => x !== h);
|
|
};
|
|
}
|
|
if (event === "persist:error") {
|
|
const typedH = h as (e: PersistErrorEvent) => void;
|
|
this.errorHandlers.push(typedH);
|
|
const offPersist = this.persist?.on("persist:error", typedH);
|
|
return () => {
|
|
this.errorHandlers = this.errorHandlers.filter((x) => x !== typedH);
|
|
offPersist?.();
|
|
};
|
|
}
|
|
return () => {};
|
|
}
|
|
|
|
// ── Serialization ────────────────────────────────────────────────────────────
|
|
|
|
serialize(opts?: { stripRuntime?: boolean }): string {
|
|
const html = serializeDocument(this.parsed);
|
|
// Newer agent-generated compositions embed hyperframe.runtime.iife.js in their own
|
|
// HTML. Any host driving its own clock (not just an editing iframe — anything that
|
|
// owns seeking/playback itself) must not let that runtime self-init: it races the
|
|
// host's first seek and resets the timeline to t=0. Opt-in (default false) since a
|
|
// host playing the composition normally wants the runtime.
|
|
return opts?.stripRuntime ? stripEmbeddedRuntimeScripts(html) : html;
|
|
}
|
|
|
|
// ── T3 embedded-mode extras ──────────────────────────────────────────────────
|
|
|
|
getOverrides(): OverrideSet {
|
|
return { ...this.overrides };
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void {
|
|
const origin = opts?.origin ?? ORIGIN_APPLY_PATCHES;
|
|
|
|
// The emitted PatchEvent carries an EMPTY inversePatches array — hosts
|
|
// maintaining an external inverse log must compute inverses from their own
|
|
// state; applyPatches events never enter history (origin-guarded).
|
|
// Emit a patch event so subscribers stay in sync.
|
|
applyPatchesToDocument(this.parsed, patches);
|
|
this.elementsCache = null;
|
|
this.rootsCache = null;
|
|
|
|
// Update override-set
|
|
for (const p of patches) {
|
|
const key = pathToKey(p.path);
|
|
if (key !== null) {
|
|
this.overrides[key] =
|
|
p.op === "remove"
|
|
? null
|
|
: (p.value as string | number | boolean | Record<string, unknown> | null);
|
|
}
|
|
}
|
|
|
|
const opTypes = ["applyPatches"];
|
|
const event = buildPatchEvent(patches, [], origin, opTypes);
|
|
this.patchHandlers.forEach((h) => h(event));
|
|
this.changeHandlers.forEach((h) => h());
|
|
}
|
|
|
|
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
|
|
async flush(): Promise<void> {
|
|
await this.persistQueueModule?.flush();
|
|
}
|
|
|
|
dispose(): void {
|
|
this.previewSelectionUnsubscribe?.();
|
|
this.previewSelectionUnsubscribe = null;
|
|
this.persistQueueModule?.dispose();
|
|
this.historyModule?.dispose();
|
|
this.changeHandlers = [];
|
|
this.selectionHandlers = [];
|
|
this.patchHandlers = [];
|
|
this.errorHandlers = [];
|
|
}
|
|
}
|
|
|
|
/** Declared defaults across the composition's carriers, the root winning a shared id as in the runtime. */
|
|
function declaredDefaults(parsed: ParsedDocument): Record<string, unknown> {
|
|
return Object.assign(
|
|
{},
|
|
...declarationCarriers(parsed.document, parsed.wrapped).map((el) => readDeclaredDefaults(el)),
|
|
);
|
|
}
|
|
|
|
// ─── Public factory ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Open a composition for editing.
|
|
*
|
|
* Standalone (T1/T2): supply persist adapter — SDK owns history + auto-save.
|
|
* Embedded (T3): supply overrides — SDK emits patches; host owns history + persistence.
|
|
* Headless (agents): omit both — SDK is a stateless transform + serializer.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
export async function openComposition(
|
|
html: string,
|
|
opts?: OpenCompositionOptions,
|
|
): Promise<Composition> {
|
|
// Single parse: parseMutable stamps hf-ids + builds the live linkedom DOM;
|
|
// the query API derives element snapshots from it lazily.
|
|
const parsed = parseMutable(html);
|
|
|
|
// Pre-override declared defaults — applyOverrideSet below folds `var.<id>`
|
|
// overrides destructively into the declarations, so this is the last moment
|
|
// the authored base values are readable. getVariableValue({ base: true })
|
|
// serves them for the rest of the session (undo-to-base restores).
|
|
const baseVariableDefaults = declaredDefaults(parsed);
|
|
|
|
// T3 embedded: replay the stored override-set onto the base in one pass,
|
|
// so the session exposes the user's exact edited state — not the template.
|
|
if (opts?.overrides) applyOverrideSet(parsed, opts.overrides);
|
|
|
|
const session = new CompositionImpl(parsed, opts ?? {}, baseVariableDefaults);
|
|
|
|
const isEmbedded = opts?.overrides !== undefined;
|
|
|
|
if (!isEmbedded) {
|
|
// history:false opts out of the SDK undo stack ONLY. Persist (auto-save) is
|
|
// independent — gating it on the history flag too would silently drop every
|
|
// disk write for a caller that just wanted to disable undo (data loss).
|
|
if (opts?.history !== false) {
|
|
const history = createHistory(session, {
|
|
coalesceMs: opts?.coalesceMs ?? 300,
|
|
trackedOrigins: opts?.trackedOrigins,
|
|
});
|
|
session.attachHistory(history);
|
|
}
|
|
|
|
if (opts?.persist) {
|
|
const pq = createPersistQueue(session, opts.persist, {
|
|
path: opts.persistPath,
|
|
onError: (e) => session._fireError(e),
|
|
});
|
|
session.attachPersistQueue(pq);
|
|
}
|
|
}
|
|
|
|
return session;
|
|
}
|