mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)
* 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>
This commit is contained in:
co-authored by
Miguel Angel Simon Sierra
parent
d7204ac47f
commit
89db718899
@@ -3,11 +3,14 @@
|
||||
// edit's undo history, and swapping the rewritten script into the live preview
|
||||
// without a full iframe reload when possible.
|
||||
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||
import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftReload";
|
||||
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
||||
import type { RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { patchDocumentRootDuration } from "./timelineEditingGsap";
|
||||
|
||||
class GsapPreviewConvergenceError extends Error {}
|
||||
class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
|
||||
|
||||
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
||||
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
||||
throw new Error(`Unsafe path: ${targetPath}`);
|
||||
@@ -25,6 +28,49 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
|
||||
return data.content;
|
||||
}
|
||||
|
||||
/** Verify rollback ownership support before any GSAP mutation can land. */
|
||||
async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
|
||||
throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically restore one GSAP mutation only while its exact output still owns
|
||||
* the file. The server performs compare + write synchronously, eliminating the
|
||||
* client GET→PUT window that could overwrite a successor edit. */
|
||||
async function rollbackOwnedMutation(
|
||||
projectId: string,
|
||||
targetPath: string,
|
||||
expected: string,
|
||||
restore: string,
|
||||
): Promise<"restored" | "conflict"> {
|
||||
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
||||
throw new Error(`Unsafe path: ${targetPath}`);
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expected, restore }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to restore ${targetPath}`);
|
||||
}
|
||||
const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
|
||||
if (result.restored === true && result.conflict === false) return "restored";
|
||||
if (result.restored === false && result.conflict === true) return "conflict";
|
||||
throw new Error(`Invalid restore response for ${targetPath}`);
|
||||
}
|
||||
|
||||
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
|
||||
function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
|
||||
try {
|
||||
@@ -34,19 +80,7 @@ function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: n
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically push the composition's content-driven length into the player
|
||||
* store right after the live DOM patch, so the duration readout + seek bar
|
||||
* update immediately. The readout binds to store.duration (PlayerControls);
|
||||
* edits only patched store.elements, so the number stayed frozen (esp. on
|
||||
* shrink) until a manual refresh. Read from the just-patched preview DOM (raw
|
||||
* data-duration) so it's immune to the runtime's truncated live durations.
|
||||
*
|
||||
* Also writes the content end into the live root's `data-duration`. Timing
|
||||
* edits take the soft-reload path (no full iframe reload), which lets the
|
||||
* runtime recompute the length from the root's declared duration and post it
|
||||
* back — reading the STALE root would revert this optimistic set.
|
||||
*/
|
||||
/** Keep the duration readout and live root aligned with optimistically patched clips. */
|
||||
export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
|
||||
const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
|
||||
if (end > 0) {
|
||||
@@ -55,14 +89,7 @@ export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): vo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the store duration BEFORE an optimistic duration update
|
||||
* (extendRootDurationIfNeeded + syncPreviewContentDuration) and return a
|
||||
* rollback closure for the persist-failure path. The rollback restores BOTH
|
||||
* the store duration and the live root's `data-duration` — otherwise a failed
|
||||
* write leaves the readout/seek bar and the live root advertising a duration
|
||||
* the saved source never got. No-op when the duration never changed.
|
||||
*/
|
||||
/** Restore both store and live-root duration when a timing persist fails. */
|
||||
export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
|
||||
const previousDuration = usePlayerStore.getState().duration;
|
||||
return () => {
|
||||
@@ -77,19 +104,38 @@ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () =>
|
||||
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
|
||||
* swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
|
||||
* when the endpoint didn't return one (older server, or a multi-script comp the
|
||||
* soft path can't scope), in which case the caller full-reloads as before.
|
||||
* soft path can't scope) — the caller then full-reloads when `mutated`, or
|
||||
* rebinds the runtime timing in place when nothing was rewritten (see
|
||||
* syncTimingEditPreview).
|
||||
*/
|
||||
export type GsapMutationStatus = { mutated: boolean; scriptText: string | null };
|
||||
export type GsapMutationStatus = {
|
||||
mutated: boolean;
|
||||
scriptText: string | null;
|
||||
/** Atomic whole-file ownership pair returned by the mutation endpoint. */
|
||||
before?: string;
|
||||
after?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readMutationStatus(value: unknown): GsapMutationStatus {
|
||||
if (!isRecord(value)) return { mutated: false, scriptText: null };
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.mutated !== "boolean" ||
|
||||
typeof value.before !== "string" ||
|
||||
typeof value.after !== "string" ||
|
||||
value.mutated !== (value.before !== value.after) ||
|
||||
("changed" in value && value.changed !== value.mutated)
|
||||
) {
|
||||
throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
|
||||
}
|
||||
return {
|
||||
mutated: value.mutated === true || value.changed === true,
|
||||
mutated: value.mutated,
|
||||
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
|
||||
before: value.before,
|
||||
after: value.after,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +144,40 @@ function readMutationError(value: unknown, fallback: string): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function postGsapMutation(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
mutation: Record<string, unknown>,
|
||||
fallback: string,
|
||||
): Promise<GsapMutationStatus> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(mutation),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
|
||||
}
|
||||
return readMutationStatus(body);
|
||||
}
|
||||
|
||||
/** Re-derive live timing windows without re-executing composition scripts.
|
||||
* Works for zero-GSAP compositions; false asks the caller to full-reload. */
|
||||
function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean {
|
||||
return applySoftReloadFinalization(iframe, currentTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
|
||||
* soft reload over the full iframe reload that flashes every clip.
|
||||
@@ -115,15 +195,34 @@ function readMutationError(value: unknown, fallback: string): string {
|
||||
* Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
|
||||
* result (no gsap runtime / rebind hook / scopable key / script element, or the
|
||||
* re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
|
||||
* already applied the shift; a remount would re-flash for nothing. When the server
|
||||
* returned no `scriptText` (older server, multi-script comp), we also full-reload.
|
||||
* already applied the shift; a remount would re-flash for nothing.
|
||||
*
|
||||
* `mutated` is the canonical decision, regardless of whether the server echoes
|
||||
* the unchanged script text:
|
||||
* - `mutated: false` — nothing was rewritten because there was NOTHING TO
|
||||
* REWRITE (the clip has no domId so no id-addressed tweens, the delta was
|
||||
* zero, or the server confirmed a no-op). Every script is unchanged and the
|
||||
* timing attributes are already live-patched, so (when `rebindWhenUnmutated`
|
||||
* allows it) `rebindPreviewTiming` re-seeks + rebinds and the runtime
|
||||
* re-derives the clip windows from the live DOM — no script re-execution,
|
||||
* no full-reload blink. This covers comps with zero GSAP scripts too; only
|
||||
* a missing iframe/runtime hook falls back to the full reload.
|
||||
* - `mutated: true` with no script — the file on disk WAS rewritten but the
|
||||
* server returned no script (older server, multi-script comp): the live
|
||||
* script is now stale, so a rebind against it would show wrong positions →
|
||||
* full-reload.
|
||||
*/
|
||||
function syncTimingEditPreview(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
outcome: Pick<GsapMutationStatus, "scriptText">,
|
||||
outcome: GsapMutationStatus,
|
||||
currentTime: number,
|
||||
reloadPreview: () => void,
|
||||
rebindWhenUnmutated: boolean,
|
||||
): void {
|
||||
if (!outcome.mutated && rebindWhenUnmutated) {
|
||||
if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
|
||||
return;
|
||||
}
|
||||
if (!iframe || !outcome.scriptText) {
|
||||
reloadPreview();
|
||||
return;
|
||||
@@ -140,6 +239,14 @@ async function finishTimelineTimingFallback(input: {
|
||||
reloadPreview: () => void;
|
||||
gsapMutation?: () => Promise<GsapMutationStatus>;
|
||||
onGsapError: (error: unknown) => void;
|
||||
/**
|
||||
* When the mutation produced no rewrite (mutated:false, no scriptText),
|
||||
* rebind the runtime timing in place (no script re-execution) instead of
|
||||
* full-reloading (see syncTimingEditPreview). Callers pass false when a full
|
||||
* reload is the only sync that reflects everything (e.g. a multi-file group
|
||||
* edit).
|
||||
*/
|
||||
rebindWhenUnmutated: boolean;
|
||||
}): Promise<void> {
|
||||
let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
|
||||
if (input.gsapMutation) {
|
||||
@@ -147,6 +254,9 @@ async function finishTimelineTimingFallback(input: {
|
||||
outcome = await input.gsapMutation();
|
||||
} catch (error) {
|
||||
input.onGsapError(error);
|
||||
// Protocol and ownership conflicts mean the live iframe may no longer
|
||||
// represent the bytes that won on disk. Converge explicitly by reloading.
|
||||
if (error instanceof GsapPreviewConvergenceError) input.reloadPreview();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -155,6 +265,7 @@ async function finishTimelineTimingFallback(input: {
|
||||
outcome,
|
||||
usePlayerStore.getState().currentTime,
|
||||
input.reloadPreview,
|
||||
input.rebindWhenUnmutated,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,44 +273,124 @@ async function finishTimelineTimingFallback(input: {
|
||||
// outlast one GSAP server round-trip, never a real second edit.
|
||||
const GSAP_HISTORY_COALESCE_MS = 10_000;
|
||||
|
||||
type OwnedMutationStep = {
|
||||
path: string;
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
|
||||
type OwnedMutationRunner = (
|
||||
path: string,
|
||||
mutation: () => Promise<GsapMutationStatus> | null,
|
||||
) => Promise<GsapMutationStatus>;
|
||||
|
||||
/**
|
||||
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
|
||||
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
|
||||
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
|
||||
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
|
||||
* folding both writes into one undo step. Returns the mutation status for caller reloads.
|
||||
*
|
||||
* Failure domains are separate: a MUTATION failure propagates (nothing was applied, so
|
||||
* the caller must skip the preview sync), but a failure in the history-FOLD step
|
||||
* (re-read / recordEdit) after a successful mutation is surfaced via `onFoldError` and
|
||||
* the mutation status is still returned — the server rewrite already landed on disk, so
|
||||
* the caller must still sync the preview or it shows stale GSAP positions.
|
||||
* Restore successful mutation steps in reverse order through the server's
|
||||
* atomic compare-and-restore endpoint. A conflict means a successor owns the
|
||||
* file and is deliberately preserved. Conflicts and restore errors require a
|
||||
* preview reload; errors are also reported through onError.
|
||||
*/
|
||||
async function rollbackMutatedFiles(
|
||||
projectId: string,
|
||||
ownedSteps: readonly OwnedMutationStep[],
|
||||
onError: (error: unknown) => void,
|
||||
): Promise<"restored" | "convergence-required"> {
|
||||
let convergenceRequired = false;
|
||||
for (let index = ownedSteps.length - 1; index >= 0; index -= 1) {
|
||||
const step = ownedSteps[index];
|
||||
if (!step || step.before === step.after) continue;
|
||||
try {
|
||||
if (
|
||||
(await rollbackOwnedMutation(projectId, step.path, step.after, step.before)) === "conflict"
|
||||
) {
|
||||
convergenceRequired = true;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
convergenceRequired = true;
|
||||
onError(rollbackError);
|
||||
}
|
||||
}
|
||||
return convergenceRequired ? "convergence-required" : "restored";
|
||||
}
|
||||
|
||||
async function rollbackAfterFailure(
|
||||
projectId: string,
|
||||
ownedSteps: readonly OwnedMutationStep[],
|
||||
onError: (error: unknown) => void,
|
||||
originalError: unknown,
|
||||
): Promise<never> {
|
||||
const outcome = await rollbackMutatedFiles(projectId, ownedSteps, onError);
|
||||
if (outcome === "convergence-required") {
|
||||
throw new GsapPreviewConvergenceError(
|
||||
"GSAP rollback could not safely restore every owned write; preview reload required",
|
||||
{ cause: originalError },
|
||||
);
|
||||
}
|
||||
throw originalError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold server-owned GSAP rewrites into the preceding timing history entry.
|
||||
* Every mutation contributes the atomic before/after pair returned by its
|
||||
* endpoint; the first before and last after for each contiguous file chain are
|
||||
* the only bytes this transaction may record or roll back.
|
||||
*/
|
||||
// The ledger, reverse rollback, final ownership check, and history fold are one
|
||||
// transaction; extracting phases would obscure which function owns convergence.
|
||||
// fallow-ignore-next-line complexity
|
||||
async function foldGsapMutationIntoHistory(input: {
|
||||
projectId: string;
|
||||
paths: string[];
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
gsapMutation: () => Promise<GsapMutationStatus>;
|
||||
onFoldError: (error: unknown) => void;
|
||||
gsapMutation: (runOwnedMutation: OwnedMutationRunner) => Promise<GsapMutationStatus>;
|
||||
onRollbackError: (error: unknown) => void;
|
||||
}): Promise<GsapMutationStatus> {
|
||||
const uniquePaths = [...new Set(input.paths)];
|
||||
const before = new Map<string, string>();
|
||||
// A `before`-snapshot failure propagates like a mutation failure: the mutation
|
||||
// has not run yet, so nothing landed on disk and skipping the sync is correct.
|
||||
for (const path of uniquePaths) {
|
||||
before.set(path, await readFileContent(input.projectId, path));
|
||||
const ownedSteps: OwnedMutationStep[] = [];
|
||||
const runOwnedMutation: OwnedMutationRunner = async (path, mutation) => {
|
||||
const pending = mutation();
|
||||
if (!pending) return { mutated: false, scriptText: null };
|
||||
const status = await pending;
|
||||
if (!status.mutated) return status;
|
||||
if (status.before === undefined || status.after === undefined) {
|
||||
throw new GsapOwnershipProtocolError(
|
||||
`GSAP mutation returned no owned before/after pair for ${path}`,
|
||||
);
|
||||
}
|
||||
const previous = [...ownedSteps].reverse().find((step) => step.path === path);
|
||||
const step = { path, before: status.before, after: status.after };
|
||||
ownedSteps.push(step);
|
||||
// A foreign writer landed between two same-file mutation steps. The second
|
||||
// step already wrote, so keep it in ownedSteps for reverse rollback, then
|
||||
// fail rather than folding foreign bytes into this gesture's history.
|
||||
if (previous && previous.after !== step.before) {
|
||||
throw new Error(`GSAP mutation ownership chain broke for ${path}`);
|
||||
}
|
||||
return status;
|
||||
};
|
||||
let status: GsapMutationStatus;
|
||||
try {
|
||||
await requireGsapOwnershipProtocol(input.projectId);
|
||||
status = await input.gsapMutation(runOwnedMutation);
|
||||
} catch (error) {
|
||||
return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
|
||||
}
|
||||
const status = await input.gsapMutation();
|
||||
if (status.mutated) {
|
||||
try {
|
||||
const ownershipByPath = new Map<string, { before: string; after: string }>();
|
||||
for (const step of ownedSteps) {
|
||||
const owned = ownershipByPath.get(step.path);
|
||||
if (owned) owned.after = step.after;
|
||||
else ownershipByPath.set(step.path, { before: step.before, after: step.after });
|
||||
}
|
||||
const files: Record<string, { before: string; after: string }> = {};
|
||||
for (const path of uniquePaths) {
|
||||
const priorContent = before.get(path);
|
||||
for (const [path, owned] of ownershipByPath) {
|
||||
const finalContent = await readFileContent(input.projectId, path);
|
||||
if (priorContent !== undefined && finalContent !== priorContent) {
|
||||
files[path] = { before: priorContent, after: finalContent };
|
||||
if (finalContent !== owned.after) {
|
||||
throw new Error(`GSAP mutation ownership lost for ${path}`);
|
||||
}
|
||||
if (owned.before !== owned.after) {
|
||||
files[path] = owned;
|
||||
}
|
||||
}
|
||||
if (Object.keys(files).length > 0) {
|
||||
@@ -212,7 +403,7 @@ async function foldGsapMutationIntoHistory(input: {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
input.onFoldError(error);
|
||||
return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
|
||||
}
|
||||
}
|
||||
return status;
|
||||
@@ -230,23 +421,16 @@ export async function shiftGsapPositions(
|
||||
delta: number,
|
||||
): Promise<GsapMutationStatus> {
|
||||
if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
return postGsapMutation(
|
||||
projectId,
|
||||
filePath,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "shift-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
delta,
|
||||
}),
|
||||
type: "shift-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
delta,
|
||||
},
|
||||
"shift-positions failed",
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(readMutationError(err, "shift-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
export async function scaleGsapPositions(
|
||||
@@ -262,26 +446,19 @@ export async function scaleGsapPositions(
|
||||
return { mutated: false, scriptText: null };
|
||||
if (oldStart === newStart && oldDuration === newDuration)
|
||||
return { mutated: false, scriptText: null };
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
return postGsapMutation(
|
||||
projectId,
|
||||
filePath,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "scale-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
oldStart,
|
||||
oldDuration,
|
||||
newStart,
|
||||
newDuration,
|
||||
}),
|
||||
type: "scale-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
oldStart,
|
||||
oldDuration,
|
||||
newStart,
|
||||
newDuration,
|
||||
},
|
||||
"scale-positions failed",
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(readMutationError(err, "scale-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
/** Timing delta a single-clip edit applies to its GSAP tweens. */
|
||||
@@ -297,8 +474,11 @@ export type SingleClipGsapEdit =
|
||||
* Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
|
||||
* server shift/scale mutation, folds the rewrite into the timing edit's history
|
||||
* entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
|
||||
* the rewritten script — full reload when the mutation is skipped, failed, or
|
||||
* returned no script.
|
||||
* the rewritten script. When there was nothing to rewrite (no domId — e.g. a
|
||||
* selector-addressed caption clip — zero delta, or a server no-op) it rebinds
|
||||
* the runtime timing in place instead of full-reloading; full reload remains
|
||||
* for genuine rewrites without a returned script and for comps the soft path
|
||||
* can't handle (see syncTimingEditPreview).
|
||||
*/
|
||||
export function finishClipTimingFallback(input: {
|
||||
iframe: HTMLIFrameElement | null;
|
||||
@@ -338,15 +518,16 @@ export function finishClipTimingFallback(input: {
|
||||
? () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId,
|
||||
paths: [targetPath],
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: () => runMutation(projectId, domId),
|
||||
onFoldError: onGsapError,
|
||||
gsapMutation: (runOwnedMutation) =>
|
||||
runOwnedMutation(targetPath, () => runMutation(projectId, domId)),
|
||||
onRollbackError: onGsapError,
|
||||
})
|
||||
: undefined,
|
||||
onGsapError,
|
||||
rebindWhenUnmutated: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -386,18 +567,17 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
||||
gsapMutation: () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId: input.projectId,
|
||||
paths: input.changes.map((change) => input.resolveChangePath(change.element)),
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: async () => {
|
||||
gsapMutation: async (runOwnedMutation) => {
|
||||
let mutated = false;
|
||||
let scriptText: GsapMutationStatus["scriptText"] = null;
|
||||
for (const change of input.changes) {
|
||||
const changePath = input.resolveChangePath(change.element);
|
||||
const pending = input.mutateChange(change, changePath);
|
||||
if (!pending) continue;
|
||||
const status = await pending;
|
||||
const status = await runOwnedMutation(changePath, () =>
|
||||
input.mutateChange(change, changePath),
|
||||
);
|
||||
mutated = mutated || status.mutated;
|
||||
// The LAST mutation against the active comp carries the cumulative
|
||||
// rewritten script for that file.
|
||||
@@ -405,8 +585,13 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
||||
}
|
||||
return { mutated, scriptText: otherFileChanged ? null : scriptText };
|
||||
},
|
||||
onFoldError: onGsapError,
|
||||
onRollbackError: onGsapError,
|
||||
}),
|
||||
onGsapError,
|
||||
// A batch where nothing needed rewriting (every change was zero-delta or
|
||||
// no-domId, e.g. closing a gap over caption clips) still needs the runtime
|
||||
// to re-derive clip windows — the in-place timing rebind covers that. But
|
||||
// when another file changed, only a full reload reflects every file.
|
||||
rebindWhenUnmutated: !otherFileChanged,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user