Skip to content

perf(studio): lightweight preview reload, skip asset overlay#895

Merged
miguel-heygen merged 9 commits into
mainfrom
perf/studio-preview-reload
May 16, 2026
Merged

perf(studio): lightweight preview reload, skip asset overlay#895
miguel-heygen merged 9 commits into
mainfrom
perf/studio-preview-reload

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 16, 2026

Summary

  • Lightweight iframe reload: Content refreshes now use refreshPlayer() (iframe.src cache-bust) instead of full Player web-component teardown + crossfade
  • Skip asset overlay on reloads: "Preparing preview assets" only shows on initial cold load, not on cached reloads

Test plan

  • Build passes, lint clean
  • Manual: paste/delete/move — no "waiting for media" flash, playhead preserved

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.
reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.
DOM element paste was inserting at the composition root, losing the
parent context that provides CSS styles and positioning. Now stores
the origin selector on copy and inserts the paste as a sibling
immediately after the original element, preserving style inheritance.
Falls back to root insertion if the selector can't be matched.
Copy link
Copy Markdown
Collaborator Author

miguel-heygen commented May 16, 2026

@miguel-heygen miguel-heygen changed the title perf(studio): use lightweight iframe.src reload instead of Player teardown perf(studio): lightweight preview reload, skip asset overlay May 16, 2026
@miguel-heygen miguel-heygen marked this pull request as ready for review May 16, 2026 07:15
jrusso1020
jrusso1020 previously approved these changes May 16, 2026
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — net code reduction (-26 LoC) that removes a tricky crossfade in favor of a simpler iframe-src reload. Right architectural direction.

Audited

  • reloadPreview simplified from try/catch iframe.contentWindow?.location.reload() to just setRefreshKey((k) => k + 1). ✓

  • NLELayout.tsx translates the bumped refreshKey into a call to refreshPlayer() from useTimelinePlayer. The new comment is precise: "refreshPlayer() saves the seek position and appends a cache-busting _t param, avoiding the full web-component teardown + crossfade that the key-based path uses."

  • NLEPreview.tsx removes the entire retiring-Player crossfade infrastructure:

    • retiringKey state + retiringTimerRef deleted
    • Two-Player render (one retiring + one new) collapsed to one Player
    • handleNewPlayerLoad with 160ms timer deleted
    • refreshKey removed from getPreviewPlayerKey — Player identity now depends only on projectId + directUrl

    This is structurally simpler AND avoids the ref-clobber race that motivated hf#881's identity-guard fix (the new path doesn't swap iframes, so the ref stays stable for the full session). The hf#881 guard remains a defensive belt for any future code that does swap iframes, but the common-case crossfade lifecycle that triggered the bug is gone. ✓

  • Asset-loading overlay skip on subsequent loads (Player.tsx):

    const isContentRefresh = loadCountRef.current > 1;
    let lastUnloaded = isContentRefresh ? false : hasUnloadedAssets(iframe, false);

    First load: probes for unloaded assets and shows overlay (cold cache). Subsequent loads: skips the probe entirely, assumes warm cache. Inline comment captures the intent: "The browser has already cached the assets from the first load, so they resolve near-instantly and the overlay just creates a disruptive flash."

Non-blocking note

Cache-eviction edge case on isContentRefresh: the skip-overlay path assumes the browser cache is warm on subsequent loads. If the cache gets evicted between loads (cache pressure, user clears cache, devtools "disable cache" mode) the user gets a silent slow re-fetch with no feedback. Probably negligible in practice — studio sessions are short and cache pressure is rare during active editing — but worth a one-line note in the comment that the skip is an optimization assuming cache, not a strict invariant. Could fall back to the probe-with-shorter-timeout if you wanted a belt-and-suspenders cache-miss path, but YAGNI seems right here.

CI

mergeable_state: unstable — likely required checks pending on this base-branch (hf#894). Standard stack-of-PRs CI propagation; trust the bottom PR's check matrix.

Review by Rames Jusso (pr-review)

vanceingalls
vanceingalls previously approved these changes May 16, 2026
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lightweight refresh path is the right call — iframe.src cache-bust + pendingSeekRef preservation beats the dual-Player crossfade for content-only reloads.

Calibrated strengths

  • useTimelinePlayer.ts:435-445refreshPlayer is small and correct: saveSeekPosition() first (locks in the time before the load handler resets currentTime), then mutates iframe.src so loadCountRef.current++ in Player.tsx:213 fires naturally on the next load. No new state machine.
  • Player.tsx:243 — gating the overlay skip on loadCountRef.current > 1 rather than a new prop is the right seam; the load counter is already the source of truth for "this is a refresh, not a cold mount."
  • Diff is small, deletes load-bearing complexity (the retiring-key crossfade), and the strengths-cited paths line up with the PR description's claims.

Findings

importantpackages/studio/src/player/components/Player.tsx:243-244: skipping hasUnloadedAssets entirely on isContentRefresh is too aggressive. The premise ("browser has already cached the assets from the first load") doesn't hold when the edit introduces a new asset — e.g. a paste/drop that adds a <video src=\"...\"> not present in the prior load, or a Lottie added via __hfLottie. Those go through the refresh path with the asset overlay suppressed; the runtime's queued play() is resilient, but the user gets no "waiting for media" feedback while the new asset buffers — and on a slow network that can be multiple seconds of black frame with no signal. Suggested fix: keep lastUnloaded = hasUnloadedAssets(iframe, false) unconditionally, and instead add a reveal-delay to the asset overlay (the same 400 ms grace COMPOSITION_LOADING_OVERLAY_DELAY_MS pattern already used for the composition overlay at Player.tsx:39, 132-142). That eliminates the flash on cached-asset refreshes without dropping feedback on uncached-asset refreshes — the failure mode the prompt-text comment promises is rare ("long cold video or a broken asset can legitimately exceed 10s") but the new shortcut silences it on every refresh, not just fast ones.

nitpackages/studio/src/components/nle/NLEPreview.tsx:11, 20-22: refreshKey is now dead — declared on NLEPreviewProps and on getPreviewPlayerKey's param type, but never read in either body. NLELayout.tsx:397 still passes it. Either drop it from both signatures (and update the test name in NLEPreview.test.ts:5 to something like "player identity is independent of refresh-key signalling," since the test no longer exercises a real prop), or leave a one-line comment explaining it's retained for the parent's prop-spreading convenience. Right now the surface is misleading.

nitpackages/studio/src/components/nle/NLEPreview.tsx:26-36: the file-level JSDoc still describes the dual-Player crossfade behavior ("a new Player is mounted alongside the old one..."). Update to reflect the simpler key-swap reality so future readers don't go looking for the retiring path.

nitNLEPreview.test.ts:5-17: the "keeps the same player identity when only refreshKey changes" test still passes a refreshKey arg even though getPreviewPlayerKey no longer uses it. The assertion is still valid as a regression pin (it locks in that refreshKey is not a key-input), but a one-line comment in the test ("pinned: refreshKey must NOT participate in key identity; content refreshes go through refreshPlayer()") would tell the next reader why we're passing a value we don't use.

Verdict: APPROVE
Reasoning: Approach is sound, code is clean, CI is green. The asset-overlay shortcut is the one real risk and worth a follow-up, but it's a UX-feedback regression on a narrow edge case (refresh that introduces a new uncached asset) — not a correctness bug. Ship and file the follow-up.

Review by Vai

- deduplicateIds regex used \b which matched data-composition-id,
  data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone
  id="..." attributes are rewritten. Add test pinning this.
- Ctrl+C no longer calls preventDefault() before confirming there's
  a selected element. Native browser copy (text selections outside
  inputs) is preserved when nothing is selected in the Studio.
- Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V
  (paste-as-plain-text) and similar OS gestures.
- Remove no-op .replace(/"/g, '"') flagged by CodeQL.
@miguel-heygen miguel-heygen force-pushed the perf/studio-preview-reload branch from 12c859c to 28b28fa Compare May 16, 2026 07:27
…revert drive-by

- Cmd+X now pre-checks selection state before preventDefault, mirroring
  the Cmd+C fix. Native cut preserved when nothing is selected.
- handleCut returns Promise<boolean> so the caller can gate on it.
- data-start rewrite scoped to the outermost opening tag only, so nested
  clip timing is preserved on paste.
- Removed system clipboard write (cross-tab paste unsupported, in-memory
  ref is the only read path).
- Reverted the reloadPreview drive-by (setRefreshKey→location.reload);
  the perf branch (#895) handles this properly via refreshPlayer().
…rdown

Content refreshes (paste, move, resize, delete, asset drop) previously
triggered setRefreshKey which changed the Player's React key, causing
full web-component destruction + iframe teardown + crossfade animation
+ re-initialization of all event listeners and asset polling.

Now NLELayout intercepts refreshKey changes and calls refreshPlayer()
which just appends a cache-busting _t param to the iframe src. The
Player web component stays alive, event listeners persist, and the
reload is ~10x faster with no "waiting for media" flash.

Key-based teardown is preserved for actual structural changes (project
switch, composition drill-down via directUrl change).
The asset-loading overlay ("Preparing preview assets") polled for
video/audio readyState on every iframe load, including content
refreshes from paste/move/resize. On reloads the browser serves
assets from cache so they resolve near-instantly — the overlay
just created a disruptive flash. Now skips the polling on
subsequent loads (loadCountRef > 1), only showing it on the
initial cold load.
@miguel-heygen miguel-heygen force-pushed the perf/studio-preview-reload branch from 28b28fa to 410e2c4 Compare May 16, 2026 07:40
miguel-heygen added a commit that referenced this pull request May 16, 2026
…ements (#894)

* feat(studio): add clipboard payload types and ID deduplication

* feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements

* fix(studio): use duck-typing for cross-frame element access in clipboard

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.

* fix(studio): preserve playhead position after paste

reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.

* fix(studio): paste DOM elements as siblings, not at composition root

DOM element paste was inserting at the composition root, losing the
parent context that provides CSS styles and positioning. Now stores
the origin selector on copy and inserts the paste as a sibling
immediately after the original element, preserving style inheritance.
Falls back to root insertion if the selector can't be matched.

* fix(studio): address review — deduplicateIds, native copy, altKey guard

- deduplicateIds regex used \b which matched data-composition-id,
  data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone
  id="..." attributes are rewritten. Add test pinning this.
- Ctrl+C no longer calls preventDefault() before confirming there's
  a selected element. Native browser copy (text selections outside
  inputs) is preserved when nothing is selected in the Studio.
- Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V
  (paste-as-plain-text) and similar OS gestures.
- Remove no-op .replace(/"/g, '"') flagged by CodeQL.

* fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by

- Cmd+X now pre-checks selection state before preventDefault, mirroring
  the Cmd+C fix. Native cut preserved when nothing is selected.
- handleCut returns Promise<boolean> so the caller can gate on it.
- data-start rewrite scoped to the outermost opening tag only, so nested
  clip timing is preserved on paste.
- Removed system clipboard write (cross-tab paste unsupported, in-memory
  ref is the only read path).
- Reverted the reloadPreview drive-by (setRefreshKey→location.reload);
  the perf branch (#895) handles this properly via refreshPlayer().
Base automatically changed from feat/studio-copy-paste-core to main May 16, 2026 07:46
@miguel-heygen miguel-heygen dismissed stale reviews from vanceingalls and jrusso1020 May 16, 2026 07:46

The base branch was changed.

@miguel-heygen miguel-heygen merged commit 83b3eba into main May 16, 2026
32 of 38 checks passed
@miguel-heygen miguel-heygen deleted the perf/studio-preview-reload branch May 16, 2026 07:46
miguel-heygen added a commit that referenced this pull request May 16, 2026
* feat(studio): add clipboard payload types and ID deduplication

* feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements

* fix(studio): use duck-typing for cross-frame element access in clipboard

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.

* fix(studio): preserve playhead position after paste

reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.

* fix(studio): paste DOM elements as siblings, not at composition root

DOM element paste was inserting at the composition root, losing the
parent context that provides CSS styles and positioning. Now stores
the origin selector on copy and inserts the paste as a sibling
immediately after the original element, preserving style inheritance.
Falls back to root insertion if the selector can't be matched.

* fix(studio): address review — deduplicateIds, native copy, altKey guard

- deduplicateIds regex used \b which matched data-composition-id,
  data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone
  id="..." attributes are rewritten. Add test pinning this.
- Ctrl+C no longer calls preventDefault() before confirming there's
  a selected element. Native browser copy (text selections outside
  inputs) is preserved when nothing is selected in the Studio.
- Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V
  (paste-as-plain-text) and similar OS gestures.
- Remove no-op .replace(/"/g, '"') flagged by CodeQL.

* fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by

- Cmd+X now pre-checks selection state before preventDefault, mirroring
  the Cmd+C fix. Native cut preserved when nothing is selected.
- handleCut returns Promise<boolean> so the caller can gate on it.
- data-start rewrite scoped to the outermost opening tag only, so nested
  clip timing is preserved on paste.
- Removed system clipboard write (cross-tab paste unsupported, in-memory
  ref is the only read path).
- Reverted the reloadPreview drive-by (setRefreshKey→location.reload);
  the perf branch (#895) handles this properly via refreshPlayer().

* perf(studio): use lightweight iframe.src reload instead of Player teardown

Content refreshes (paste, move, resize, delete, asset drop) previously
triggered setRefreshKey which changed the Player's React key, causing
full web-component destruction + iframe teardown + crossfade animation
+ re-initialization of all event listeners and asset polling.

Now NLELayout intercepts refreshKey changes and calls refreshPlayer()
which just appends a cache-busting _t param to the iframe src. The
Player web component stays alive, event listeners persist, and the
reload is ~10x faster with no "waiting for media" flash.

Key-based teardown is preserved for actual structural changes (project
switch, composition drill-down via directUrl change).

* perf(studio): skip asset-loading overlay on content refreshes

The asset-loading overlay ("Preparing preview assets") polled for
video/audio readyState on every iframe load, including content
refreshes from paste/move/resize. On reloads the browser serves
assets from cache so they resolve near-instantly — the overlay
just created a disruptive flash. Now skips the polling on
subsequent loads (loadCountRef > 1), only showing it on the
initial cold load.

* feat(studio): add Timing section to inspector Design panel

Adds Start, End, and Duration fields to the Design panel when the
selected element has data-start/data-duration attributes. Editing
any field commits via the attribute patch pipeline (same as timeline
edits) and refreshes the preview. End is computed from start+duration
and writing End adjusts duration accordingly.

* fix(studio): preserve bare text nodes in mixed-content elements

collectDomEditTextFields only captured child HTML elements, ignoring
bare text nodes. For elements like:
  <div class="headline">If you're <span>turning 65</span> soon...</div>
only the <span> was collected as a text field. When commitDomTextFields
serialized back, "If you're " and " soon..." were lost.

Now walks childNodes and creates text-node fields for bare text nodes
alongside child element fields. serializeDomEditTextFields emits bare
text for text-node fields, preserving the complete mixed content.

* fix(studio): address #896 review — remove scrub from timing, add mixed-content test

- Remove scrub from Timing fields: 1px = 1 second is too coarse.
  Scroll-wheel and direct typing still work with sub-second precision.
- Add mixed-content text-node serialization test in a separate file
  (domEditingTextFields.test.ts) to avoid bloating the existing
  domEditing.test.ts past the filesize limit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants