mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides
Track order = default paint order; authored z = advanced override.
- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
timeline lane move — closest track in the action's direction that is free
over the clip's whole span, else a new lane adjacent to the crossed
neighbor; temporal-overlap scope (default pending product sign-off, see
module doc); visual zone only; same-file reference scoping; persistTrack
via the shared authored-space rules. null for non-clips (menu stays
z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
persists the lane move through the same machinery as a timeline lane drag
(optimistic store update, authoredTrack refresh, rollback); inserts reuse
commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
entry (test proves it over the real history reducer). The mirror never
triggers the lane->z stacking sync, so it cannot fight the z values the
action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
contradicts lane order among temporally-overlapping same-context visual
neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
a 'z' badge — authored z overrides are surfaced instead of silently
disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
(600-line cap).
* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists
Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).
zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.
* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu
- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
skip the GSAP fallback round-trip and the preview reload entirely — the
renderer never reads data-track-index, and the live DOM patch + optimistic
store update cover the UI. Mixed batches keep current behavior. Kills the
canvas blink on mirrored Bring/Send actions (live-verified: an
iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
directional arrow for Forward/Backward; pierced two-layer stack for
Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
clip and every clip after it on that lane left by the clicked gap's width;
leading gaps count, so a single clip with empty space before it compacts
to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
in timelineGaps.ts; persists through the drag path's atomic batch move
(one undo per action); refuses when a clip that must shift is locked;
items disable when there is nothing to close.
* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order
Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.
The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.
Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).
Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.
* fix(studio): bound forward/backward mirror to a one-element step
User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.
* feat(studio): permanent gap-menu rows with hover and click-select gap highlights
- TrackGapContextMenu always renders both rows; an inapplicable action dims
with a tooltip ("No gap here" / lock reason / "No gaps on this track")
instead of vanishing into a one-item menu. Width badge only when a gap
exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
timeline: the single gap for Close gap, every current gap (leading included)
for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
present-state intervals (epsilon-tolerant, overlap-safe), distinct from
resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
(suppressed for marquee multi-selection and during drags; the gap-menu hover
wins on its own lane). Derivation lives in useTimelineGapHighlights with the
pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
+ clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
plumbing (ResizeObserver width + shortcut-hint sync) into
useTimelineScrollViewport, behavior unchanged.
* feat(studio): stronger capcut-style timeline zoom steps
One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.
* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits
Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.
- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
the canvas menu's realization core via resolveZOrderReposition (one
between-z write when a strict gap exists, band-safe scoped renumber
otherwise) instead of computeReorderZValues' all-sibling stamp — that
helper is deleted, completing the #2347 unification follow-up. The drop
then mirrors into a timeline lane move through the same machinery as the
canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
strictly between its NEW paint neighbors' lanes — nearest clip siblings in
the desired render order, decorations skipped — else a track insert at
that boundary; audio zone never crossed). Both writes share one
per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
factored into useMirrorLaneMoveCommit and reused by the new
useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
bump no refreshKey, so the panel's z-sorted order went stale while paused.
handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
rollback; the panel re-collects on it. Verified live: the panel re-sorts
the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
hidden at the current frame (animation-parked opacity, non-clip
display/visibility hides, hidden ancestors) temporarily forces the chain
visible with live inline styles — exact priors restored on deselect, on
another reveal, on play, and on unmount; never persisted (file diff == 0
verified live). Clips keep the existing seek-into-window behavior; the
override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.
Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.
* feat(studio): full-track selection highlight, borderless gap hover strips
- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
gap, inter-clip gaps, and the open space after the last clip to the rendered
end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
derivation). Still click-only: any drag/resize suppresses the strips, and a
marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
nudged to 0.18 alpha to keep the same visual weight.
* feat(studio): selected layer paints on top via a reader-transparent z lift
Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:
- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
writes a far-top inline z; a static element gets a layout-preserving
position:relative with its prior parked in data-hf-reveal-prior-pos. Only
the RENDERER sees the lift: all three studio z readers
(readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
the parked prior while the attribute is present, so the canvas z-menu, the
zOverride badge, the lane mirror, the stacking sync, and the panel sort
keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
play / unmount, each property only while it still holds the value the
override wrote (a later real edit is never clobbered). File diff == 0
verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
reads the parked position for its persist-position:relative static check,
then drops the attributes) — the committed z becomes the truth and the
later restore is a guarded no-op.
* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path
Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:
1. Master-view path gate: activeCompPath is NULL at the master view, so the
'paths[0] === activeCompPath' eligibility check could never match the
index.html restore and every default-view undo full-reloaded at the first
gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
element's innerHTML, but the composition root wraps every clip — any child
change re-detected at the root rejected the restore. Change detection now
compares only each element's OWN attribute surface; structure/text
integrity is still guaranteed by the normalize-residual whole-doc pass
(text nodes, added/removed elements, and un-identified attrs all remain
after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
fell outside the diff entirely. Identity is now id OR data-hf-id, with the
live sync resolving either.
Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.
Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.
* feat(studio): left breathing pad before t=0, double zoom sensitivity again
TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.
- The lanes and the ruler realize it as a plain flow spacer between the
sticky gutter cell and the time-mapped content div, so every
content-relative computation (clip left = t*pps, beat lines, lane-menu
time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
gap strips, drop placeholder, snap guide, range highlight, marquee clip
rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
center exactly under the pointer.
Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.
* fix(studio): left pad renders as true empty space, not lane surface
The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.
* fix(studio): no vertical line in the ruler band before 00:00
The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.
* refactor(studio): remove the timeline z-override badge
User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.
* fix(studio): collision-free expanded child lanes and host-window gap floors
Review findings D1 (blocker) and 4.
- D1: buildChildElements assigned expanded children synthetic display rows as
`host.track + index` — integers that can EQUAL a real clip's lane in another
file (host on 0 with two children puts child #2 on 1). Lane grouping merges
purely by track number, so the collision fused clips from different source
files into one display lane, and lane-scoped actions (the gap menu) then
batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
between the host's lane and the next integer — structurally unable to
collide with any normalized lane, while still rendering as ordered rows
under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
display time is host-anchored — close/compact could drag it before its host
window and persist a wrong (even negative) local time. All gap functions
now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
expandedParentStart for child lanes — single-origin per lane post-D1),
threaded through the menu model, hover highlights, selected-lane strips,
and both commits. Close-gap shifts clamp at the gap's own left edge.
* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity
Review findings 1, 2, and 3.
- Finding 1: buildTrackInsertEdits normalized the FULL display set and
persisted every shifted clip — writing host-lane numbers into OTHER
composition files when expanded children were showing. The renumber write
set is now the edited element's own source file (the sanctioned multi-write
converges one FILE to lane space, never neighbors' files); foreign clips
keep their authored tracks and re-derive display lanes. The locked-clip
refusal scopes the same way. Expanded-origin elements refuse the insert
outright (a new lane is a host-space renumber, meaningless in the child's
file), and the mirrors restrict an expanded child's lane candidates to its
own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
(persisting the sibling's authored track) but can never land on a host lane
with no same-file occupant. authoredTrackForLane's offset fallback rounds:
fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
file can contain several CSS stacking contexts and leaf z is only
comparable within one. Both resolvers now scope by samePaintScope — same
source file AND same stackingContextId (the file check also stops null root
contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
duplicate class selectors (.sub) resolved to occurrence 0 — a different
clip. The key now carries getSelectorIndex, matching how z-reorder entries
derive theirs.
* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists
Review findings 5 and 7.
- Finding 5: commitDomEditPatchBatches resolved successfully even when the
server matched NO patch target — the z write never reached disk (the
preview reloads to reconverge) yet the lane mirror still ran, desyncing
track order from what actually paints. The commit now resolves a durability
report ({allMatched, changed}; the save queue and commit types are generic
over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
rides the timeline/SDK path — two queues, so a second rapid gesture's z
write could land BETWEEN the first gesture's z and lane phases. Every
z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
through runZLaneGesture: a single module-level tail that serializes the
COMPLETE two-phase transaction, with unit tests for ordering, the
durability gate, and queue resilience to failed gestures. The timeline
lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).
* fix(studio): multi-clip GSAP batch mutations roll back on late failure
Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.
* fix(studio): scope mirror inserts to their lane zone
* fix(studio): unify source-scoped clip identity
* fix(studio): isolate track insert topology
* fix(studio): harden timeline paint synchronization
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
491 lines
22 KiB
TypeScript
491 lines
22 KiB
TypeScript
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
|
|
type IframeWindow = Window & {
|
|
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
|
__player?: { getTime?: () => number; seek?: (t: number) => void };
|
|
__hfForceTimelineRebind?: () => void;
|
|
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
|
|
__hfStudioManualEditsApply?: () => void;
|
|
// Set while a MotionPathPlugin <script> is being fetched, so overlapping soft
|
|
// reloads (each needing the plugin) don't queue duplicate plugin scripts that
|
|
// re-flash the iframe. Cleared once the plugin loads or errors.
|
|
__hfMotionPathPluginLoading?: boolean;
|
|
gsap?: {
|
|
timeline?: (...args: unknown[]) => unknown;
|
|
registerPlugin?: (...plugins: unknown[]) => unknown;
|
|
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
|
|
globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
|
|
};
|
|
MotionPathPlugin?: unknown;
|
|
};
|
|
|
|
/**
|
|
* CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
|
|
* bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
|
|
* version is pinned in a single place.
|
|
*/
|
|
const MOTION_PATH_PLUGIN_CDN =
|
|
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
|
|
|
/**
|
|
* Pre-load + register MotionPathPlugin ONCE in the preview iframe so
|
|
* `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
|
|
* preview bootstrap (NLELayout's onIframeLoad) on every iframe load.
|
|
*
|
|
* Why: when a user ADDS a motion path to a composition that never used one, the
|
|
* plugin isn't loaded, so the first soft reload takes the async `<script src>`
|
|
* load path — the timeline is killed/cleared while the CDN load is pending,
|
|
* producing a visible flash. Loading it eagerly here means the soft reload runs
|
|
* synchronously and `needsMotionPath && !win.MotionPathPlugin` never fires for
|
|
* studio edits.
|
|
*
|
|
* Idempotent (no-ops once the plugin is present or already loading) and
|
|
* defensive: no-ops without gsap/registerPlugin and tolerates a CDN failure
|
|
* (the soft-reload async fallback in applySoftReload still covers that case).
|
|
*/
|
|
export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
|
|
if (!iframe?.contentWindow || !iframe.contentDocument) return;
|
|
const win = iframe.contentWindow as IframeWindow;
|
|
const doc = iframe.contentDocument;
|
|
|
|
// Already registered (composition shipped its own plugin, or a prior bootstrap
|
|
// ran) — register it on gsap to be safe, then bail.
|
|
if (win.MotionPathPlugin) {
|
|
try {
|
|
if (win.gsap?.registerPlugin) win.gsap.registerPlugin(win.MotionPathPlugin);
|
|
} catch {}
|
|
return;
|
|
}
|
|
if (!win.gsap?.registerPlugin) return;
|
|
// A load is already in flight for this iframe — don't queue a second script.
|
|
if (win.__hfMotionPathPluginLoading) return;
|
|
|
|
try {
|
|
win.__hfMotionPathPluginLoading = true;
|
|
const pluginScript = doc.createElement("script");
|
|
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
|
|
const finalize = () => {
|
|
win.__hfMotionPathPluginLoading = false;
|
|
try {
|
|
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
|
|
win.gsap.registerPlugin(win.MotionPathPlugin);
|
|
}
|
|
} catch {}
|
|
};
|
|
pluginScript.onload = finalize;
|
|
pluginScript.onerror = finalize;
|
|
doc.head.appendChild(pluginScript);
|
|
} catch {
|
|
win.__hfMotionPathPluginLoading = false;
|
|
}
|
|
}
|
|
|
|
function isGsapScript(text: string): boolean {
|
|
return (
|
|
text.includes("gsap.timeline") ||
|
|
text.includes("__timelines") ||
|
|
text.includes(".to(") ||
|
|
text.includes(".set(")
|
|
);
|
|
}
|
|
|
|
export function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
|
const results: HTMLScriptElement[] = [];
|
|
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
|
|
for (const script of scripts) {
|
|
if (isGsapScript(script.textContent || "")) results.push(script);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Extract the GSAP timeline script text from a serialized HTML document, for
|
|
* feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
|
|
* are present (ambiguous — a serialized snapshot can't say WHICH script a
|
|
* single rewritten text corresponds to; caller should fall back to a full
|
|
* reload), matching applySoftReload's own single-script requirement.
|
|
*/
|
|
export function extractGsapScriptText(html: string): string | null {
|
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
const scripts = findGsapScriptElements(doc);
|
|
if (scripts.length !== 1) return null;
|
|
return scripts[0].textContent || null;
|
|
}
|
|
|
|
/**
|
|
* Confirm the re-run repopulated the timeline(s) this script owns. We check the
|
|
* EXPECTED keys (the ones the script re-registers), not merely "any key": a
|
|
* scoped soft reload only re-runs ONE composition, so the right success signal is
|
|
* "my target keys are back", not "the global map is non-empty". Checking the
|
|
* exact keys avoids the transient false where the global map momentarily looks
|
|
* empty right after the re-run — the spurious trigger of the full-remount fallback.
|
|
*/
|
|
function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): boolean {
|
|
const timelines = win.__timelines;
|
|
if (!timelines) return false;
|
|
if (targetKeys.length > 0) {
|
|
return targetKeys.every((key) => timelines[key] != null);
|
|
}
|
|
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
|
|
}
|
|
|
|
/**
|
|
* Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
|
|
* (the preview genuinely can't be soft-updated — escalate to a full reload) from
|
|
* the TRANSIENT post-run empty-timeline window (the live `gsap.set` already shows
|
|
* the correct value — do NOT escalate; a remount would re-flash the WebGL context
|
|
* and revert subcomposition keyframes):
|
|
*
|
|
* - `"applied"` — the script ran (or is deferred to the async plugin
|
|
* load and WILL run). The preview is/will be correct.
|
|
* - `"verify-failed"` — TRANSIENT: the re-run happened but `__timelines`
|
|
* momentarily read empty. Live state is correct → do
|
|
* NOT escalate. (Was a bare `false` before.)
|
|
* - `"cannot-soft-reload"` — PERMANENT/STRUCTURAL: no gsap runtime, no rebind
|
|
* hook, no scopable target key, or no script element
|
|
* to replace. The preview is stale/broken → escalate.
|
|
*
|
|
* The async MotionPath-plugin load failure is still surfaced via
|
|
* `onAsyncFailure` (it fires after this returned `"applied"` optimistically).
|
|
*/
|
|
export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload";
|
|
|
|
/**
|
|
* Replace the GSAP script in the live iframe without reloading. This preserves
|
|
* the WebGL context and shader transition cache.
|
|
*
|
|
* Scoped to root-document GSAP scripts only — scripts inside `<template>`
|
|
* elements (sub-compositions) are not visible to `querySelectorAll` and will
|
|
* fall back to a full iframe reload.
|
|
*
|
|
* Returns `"cannot-soft-reload"` (caller should full-reload) when:
|
|
* - The iframe or GSAP runtime isn't available
|
|
* - The rebind hook isn't installed
|
|
* - The script registers no scopable `__timelines` key
|
|
* - No GSAP script element exists in the live DOM
|
|
* - The synchronous re-run threw
|
|
*
|
|
* Returns `"verify-failed"` when the re-run executed but the target timeline
|
|
* keys read empty in the transient post-run window (live state is still correct).
|
|
*
|
|
* `onAsyncFailure` is invoked when the soft reload was deferred to load the
|
|
* MotionPath plugin (so this returned `"applied"` optimistically) but the plugin
|
|
* `<script>` then failed to load — the iframe is left without the plugin and the
|
|
* caller should perform a full reload to recover. It never fires on the
|
|
* synchronous paths.
|
|
*/
|
|
export interface SoftReloadOptions {
|
|
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
|
|
onAsyncFailure?: () => void;
|
|
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
|
|
currentTimeOverride?: number;
|
|
/** After-write file HTML — the primary source for authored-opacity restore. */
|
|
authoredHtml?: string;
|
|
}
|
|
|
|
/**
|
|
* The soft reload's finalization step, shared with the rebind-only preview sync
|
|
* below: seek → force timeline rebind → reapply studio manual edits.
|
|
*
|
|
* Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
|
|
* (see init.ts) renders the freshly-created timeline at whatever the
|
|
* runtime's internal scrub position already is, not at whatever we pass
|
|
* here afterward — a redundant seek() call after rebind can be a GSAP
|
|
* no-op if the timeline already reports being at that time internally.
|
|
*/
|
|
function finalizeSoftReload(win: IframeWindow, currentTime: number): void {
|
|
win.__player?.seek?.(currentTime);
|
|
win.__hfForceTimelineRebind?.();
|
|
win.__hfStudioManualEditsApply?.();
|
|
}
|
|
|
|
/**
|
|
* Run ONLY applySoftReload's finalization (seek → __hfForceTimelineRebind →
|
|
* manual-edits reapply) against the live iframe — executing NO scripts and
|
|
* touching NO script elements. `__hfForceTimelineRebind` makes the runtime
|
|
* re-derive every clip's visibility window from the live DOM's `data-start` /
|
|
* `data-duration` attributes (init.ts: bindRootTimelineIfAvailable +
|
|
* syncTimedElementVisibility), so this is the flashless sync for a timing edit
|
|
* whose attributes were already live-patched and whose GSAP scripts are
|
|
* unchanged (`window.__timelines` still valid). Works for compositions with
|
|
* zero GSAP scripts too — the rebind hook is installed unconditionally by the
|
|
* runtime, independent of any animation library.
|
|
*
|
|
* Returns false when the iframe/runtime hook is unavailable or the run threw —
|
|
* the caller should escalate to a full reload.
|
|
*/
|
|
export function applySoftReloadFinalization(
|
|
iframe: HTMLIFrameElement | null,
|
|
currentTime: number,
|
|
): boolean {
|
|
const win = iframe?.contentWindow as IframeWindow | null;
|
|
if (!win?.__hfForceTimelineRebind) return false;
|
|
try {
|
|
if (win.__hfSuppressSceneMutations) {
|
|
win.__hfSuppressSceneMutations(() => finalizeSoftReload(win, currentTime));
|
|
} else {
|
|
finalizeSoftReload(win, currentTime);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function applySoftReload(
|
|
iframe: HTMLIFrameElement | null,
|
|
scriptText: string,
|
|
options: SoftReloadOptions = {},
|
|
): SoftReloadResult {
|
|
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
|
|
if (!iframe || !scriptText) return "cannot-soft-reload";
|
|
|
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
const doc = iframe.contentDocument;
|
|
if (!win || !doc) return "cannot-soft-reload";
|
|
if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
|
|
|
|
// Which composition(s) does this script rebuild? A soft reload re-runs ONE
|
|
// composition's GSAP script, which re-registers its own window.__timelines[key].
|
|
// In a multi-composition preview (top-level + inlined subcompositions) each
|
|
// composition owns a separate timeline keyed by its id, and they're all children
|
|
// of the global timeline — so tearing down ALL of them (or the global timeline's
|
|
// children) and re-running a single script wipes every OTHER composition,
|
|
// reverting its edits. Scope the teardown to the keys THIS script re-registers.
|
|
const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
|
|
.map((m) => m[1]!)
|
|
.filter((key) => key !== "__proxied");
|
|
if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
|
|
const gsapScripts = findGsapScriptElements(doc);
|
|
if (gsapScripts.length === 0) return "cannot-soft-reload";
|
|
// Remove only the stale script element(s) that registered a target key; one we
|
|
// can't match in the doc is left alone (re-running appends a fresh element).
|
|
const staleScripts = gsapScripts.filter((script) =>
|
|
targetKeys.some((key) => {
|
|
const text = script.textContent || "";
|
|
return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`);
|
|
}),
|
|
);
|
|
// Multiple GSAP scripts exist but none registers a key this script owns — we
|
|
// can't identify which element to replace (ambiguous, matching
|
|
// extractGsapScriptText's single-script requirement). Escalate to a full reload
|
|
// rather than killing the target timeline and appending an orphan script.
|
|
if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
|
|
|
|
// Prefer the caller-supplied scrub position (the studio's own authoritative
|
|
// currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`:
|
|
// the two can desync (a keyframe-node drag parks the playhead via the store
|
|
// BEFORE this reload's async commit resolves, and the iframe's own GSAP clock
|
|
// doesn't reliably reflect that yet), which re-seeks the freshly rebuilt
|
|
// timeline to the wrong frame and leaves the element (and its overlay)
|
|
// rendered at a stale/unrelated position.
|
|
const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0;
|
|
|
|
// Track whether the MotionPath async path was taken. When it is, the script
|
|
// executes inside pluginScript.onload — after applySoftReload has already
|
|
// returned. We optimistically return true because the script WILL execute
|
|
// once the plugin loads; the alternative (returning false) would trigger a
|
|
// full iframe reload that destroys the very WebGL context we're preserving.
|
|
let deferredToAsync = false;
|
|
|
|
// Authored-opacity resolution for the restore loop below. Three-state:
|
|
// "0.98" — the element's authored inline opacity
|
|
// "" — resolved, and the element has NO authored inline opacity
|
|
// null — unknown (no authored HTML supplied, element not found in it,
|
|
// and no runtime parse-time stamp)
|
|
// The just-written file (`authoredHtml`) is the current truth; the runtime's
|
|
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
|
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
|
let authoredDoc: Document | null | undefined;
|
|
const findAuthoredSource = (el: HTMLElement): Element | null => {
|
|
if (authoredDoc === undefined) {
|
|
try {
|
|
authoredDoc = authoredHtml
|
|
? new DOMParser().parseFromString(authoredHtml, "text/html")
|
|
: null;
|
|
} catch {
|
|
authoredDoc = null;
|
|
}
|
|
}
|
|
if (!authoredDoc) return null;
|
|
const hfId = el.getAttribute("data-hf-id");
|
|
if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
|
|
return el.id ? authoredDoc.getElementById(el.id) : null;
|
|
};
|
|
const readAuthoredOpacity = (el: HTMLElement): string | null => {
|
|
const source = findAuthoredSource(el);
|
|
if (source instanceof HTMLElement) return source.style.opacity;
|
|
return readStampedAuthoredOpacity(el);
|
|
};
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const doReload = () => {
|
|
const timelines = win.__timelines;
|
|
const allTargets: Element[] = [];
|
|
|
|
// Kill ONLY the target composition's timeline(s) — leaving every other
|
|
// composition's timeline (and its children on the global timeline) intact.
|
|
if (timelines) {
|
|
for (const key of targetKeys) {
|
|
const tl = timelines[key] as
|
|
| {
|
|
kill?: () => void;
|
|
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
|
}
|
|
| undefined;
|
|
if (!tl) continue;
|
|
if (tl.getChildren) {
|
|
try {
|
|
for (const child of tl.getChildren(true)) {
|
|
if (typeof child.targets === "function") {
|
|
for (const t of child.targets()) allTargets.push(t);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
try {
|
|
tl.kill?.();
|
|
} catch {}
|
|
delete timelines[key];
|
|
}
|
|
}
|
|
|
|
// Also reset elements carrying a GSAP-applied inline `transform` that the
|
|
// timeline-children sweep above missed — a dragged element whose position
|
|
// was a standalone `gsap.set` (never a timeline child), or one whose
|
|
// keyframes were just removed (no longer in any timeline). Their last
|
|
// `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
|
|
// and the sweep above can't see it, so the element renders offset from its
|
|
// source position (matching the overlay) until a full reload. The clear
|
|
// below runs BEFORE the re-run, which re-applies the transform for any
|
|
// element the new script still animates.
|
|
const seenTargets = new Set<Element>(allTargets);
|
|
for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
|
|
// Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
|
|
// never strip an authored, non-GSAP inline transform.
|
|
if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
|
|
seenTargets.add(el);
|
|
allTargets.push(el);
|
|
}
|
|
}
|
|
|
|
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
|
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
|
// nukes the element's CSS base (position, width, height, etc.) from the
|
|
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
|
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
const saved: Array<[HTMLElement, string]> = [];
|
|
for (const el of allTargets) {
|
|
// Iframe-realm node: instanceof HTMLElement fails across realms, and
|
|
// gsap targets() only yields elements here — style access is duck-typed.
|
|
const styled = el as HTMLElement;
|
|
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
|
|
}
|
|
try {
|
|
win.gsap.set(allTargets, { clearProps: "all" });
|
|
} catch {}
|
|
for (const [el, css] of saved) {
|
|
const s = el.style;
|
|
s.cssText = css;
|
|
s.removeProperty("transform");
|
|
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
|
// a mid-flight tween's interpolated value, or the color-grading hide
|
|
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|
|
// against it — a from() captures it as its END, a to() as its START —
|
|
// turning the transient into the tween's permanent bound (dimmed or
|
|
// invisible elements). Put the AUTHORED inline opacity back; the seek
|
|
// below re-renders the correct animated value either way.
|
|
const authored = readAuthoredOpacity(el);
|
|
if (authored !== null) {
|
|
applyAuthoredInlineOpacity(s, authored);
|
|
} else if (
|
|
el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) &&
|
|
s.getPropertyValue("opacity") === "0" &&
|
|
s.getPropertyPriority("opacity") === "important"
|
|
) {
|
|
// Authored value unknown, but this is definitely the grading hide —
|
|
// never let a from() capture 0; fall back to the CSS cascade.
|
|
s.removeProperty("opacity");
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const script of staleScripts) script.remove();
|
|
|
|
const executeScript = () => {
|
|
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
|
|
win.gsap.registerPlugin(win.MotionPathPlugin);
|
|
}
|
|
const s = doc.createElement("script");
|
|
s.textContent = `(function(){${scriptText}\n})();`;
|
|
doc.body.appendChild(s);
|
|
finalizeSoftReload(win, currentTime);
|
|
};
|
|
|
|
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
|
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
|
deferredToAsync = true;
|
|
// A prior soft reload is already fetching the plugin — don't queue a second
|
|
// <script> (it re-flashes the iframe). Defer THIS script's execution until
|
|
// the in-flight load settles via a one-shot poll. The bootstrap guard is
|
|
// the single source of truth for "plugin fetch in progress".
|
|
if (win.__hfMotionPathPluginLoading) {
|
|
const started = Date.now();
|
|
const poll = win.setInterval(() => {
|
|
if (win.MotionPathPlugin) {
|
|
win.clearInterval(poll);
|
|
executeScript();
|
|
} else if (!win.__hfMotionPathPluginLoading || Date.now() - started > 10000) {
|
|
// The in-flight load finished without registering the plugin (errored)
|
|
// or we timed out — recover with a full reload instead of running a
|
|
// script that references a missing plugin.
|
|
win.clearInterval(poll);
|
|
onAsyncFailure?.();
|
|
}
|
|
}, 50);
|
|
return;
|
|
}
|
|
win.__hfMotionPathPluginLoading = true;
|
|
const pluginScript = doc.createElement("script");
|
|
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
|
|
pluginScript.onload = () => {
|
|
win.__hfMotionPathPluginLoading = false;
|
|
executeScript();
|
|
};
|
|
pluginScript.onerror = () => {
|
|
// The plugin failed to load. Running executeScript() now would leave the
|
|
// iframe with a motionPath tween referencing a missing plugin while the
|
|
// caller already thinks the soft reload succeeded. Signal failure so the
|
|
// caller can full-reload (which fetches the plugin fresh) instead.
|
|
win.__hfMotionPathPluginLoading = false;
|
|
onAsyncFailure?.();
|
|
};
|
|
doc.head.appendChild(pluginScript);
|
|
return;
|
|
}
|
|
|
|
executeScript();
|
|
};
|
|
|
|
try {
|
|
if (win.__hfSuppressSceneMutations) {
|
|
win.__hfSuppressSceneMutations(doReload);
|
|
} else {
|
|
doReload();
|
|
}
|
|
// When MotionPath needs async loading, the script hasn't executed yet —
|
|
// skip the __timelines check and report success optimistically (the script
|
|
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
|
|
if (deferredToAsync) return "applied";
|
|
// The re-run executed. If the target keys read back, we're done; otherwise
|
|
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
|
|
// as "verify-failed" so callers know NOT to escalate.
|
|
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
|
|
} catch {
|
|
// The synchronous re-run threw — the preview is now genuinely broken (target
|
|
// timeline killed, script not re-registered). Escalate to a full reload.
|
|
return "cannot-soft-reload";
|
|
}
|
|
}
|