* fix(player): drive composition ticks from widget-frame rAF via postMessage
Chromium throttles requestAnimationFrame in deeply nested cross-origin
iframes. In Claude desktop (Electron), the composition iframe's own rAF
loop stalls, so GSAP is never seeked and animation freezes even when
TransportClock.isPlaying() is true.
The correct fix is to drive ticks from the widget-frame rAF, which lives
one level up and is not subject to the same throttling. When play() takes
the runtime bridge path (no direct timeline adapter), the player now starts
a parent-frame rAF loop that sends "tick" postMessages to the composition
iframe on every frame. The runtime's control bridge handles "tick" by calling
seekTimelineAndAdapters(clock.now()) if the clock is playing — identical to
what transportTick does on each rAF, just driven from outside.
The composition iframe's own rAF loop is unchanged and keeps running
normally in standard browsers. Seeking GSAP twice per frame is idempotent,
so there is no regression on claude.ai or any other non-throttled environment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(player): address review issues in parent tick clock
- _paused must be false before _startParentTickClock runs; otherwise
the first RAF callback sees _paused=true and self-terminates immediately
- Guard _startParentTickClock behind this._ready && !this._directTimelineAdapter
so tick messages aren't sent into an uninitialized iframe when play() is
called before the composition probe has resolved
- Add clock.reachedEnd() check to onTick so end-of-composition handling
(pause, seek-to-end, postState) runs even when the composition iframe
RAF is fully throttled
- Stop the parent tick clock in seek() alongside _stopDirectTimelineClock
to avoid burning CPU frames while paused after a scrub
- Add onTick to bridge.test.ts createMockDeps() and add a dispatch test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: bump versions to 0.6.5-alpha.0 for testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(studio): decompose App.tsx from 4297 to 567 lines
Break the monolithic StudioApp component into focused modules:
Hooks (12 new):
- usePanelLayout: resizable/collapsible panel state
- useFileManager: file tree, CRUD, uploads, derived lists
- useManifestPersistence: manual edit + motion manifest save queue
- useTimelineEditing: clip move/resize/delete/drop handlers
- useDomEditSession: DOM selection, style/text commits, preview interaction
- useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync
- useCaptionDetection: auto-detect caption compositions
- useRenderClipContent: timeline clip thumbnail rendering
- useConsoleErrorCapture: preview iframe console error capture
- useFrameCapture: frame capture download flow
- useLintModal: lint execution and modal state
- useCompositionDimensions: stage-size message listener
Components (6 new):
- AskAgentModal: agent prompt modal
- StudioHeader: toolbar with undo/redo, capture, inspector toggle
- StudioLeftSidebar: file tree + code editor (handles collapsed state)
- StudioPreviewArea: NLELayout + overlays + caption timeline
- StudioRightPanel: Design/Motion/Renders tab panel
- TimelineToolbar: zoom controls + timeline toggle
Utilities (4 new):
- studioHelpers: types, path helpers, DOM utilities
- studioPreviewHelpers: preview pointer/player interaction
- domEditHelpers: selection group algebra
- studioFontHelpers: font injection + @font-face management
Also removes dead timeline layer inspector code (eye icon, thumbnail
toggle, layer panel) that was disabled behind a feature flag.
* feat(studio): add Layer (z-index) field to design panel
Adds a scrub-enabled "Layer" field below the W/H inputs in the Layout
section. Available for all elements regardless of style editing
capability since z-index is fundamental to composition stacking order.
* docs: architecture spec for studio domain contexts, hook split, and file-size lint
* docs: implementation plan for studio contexts, hook split, and file-size lint
* refactor(studio): consolidate duplicate helpers in useDomEditSession
Remove ~370 lines of helper functions that were copied into the hook
instead of imported. All removed functions already exist in the
canonical utility files (studioHelpers, studioFontHelpers,
studioPreviewHelpers, domEditHelpers). Also removes the duplicate
local type definitions for RightPanelTab, AgentModalAnchorPoint, and
PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl,
importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport).
Temporarily excludes useDomEditSession.ts from the 500 LOC file-size
check until Tasks 3-5 split it into focused hooks.
* refactor(studio): extract useDomSelection from useDomEditSession
* refactor(studio): extract useAskAgentModal from useDomEditSession
* refactor(studio): extract usePreviewInteraction from useDomEditSession
* refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator
Split the 897-line useDomEditSession into focused hooks:
- useDomEditCommits (439 LOC): manifest commits (path offset, box size,
rotation, manual edits reset, motion), persist operations, element delete,
font asset resolution
- useDomEditTextCommits (329 LOC): style/text/text-field commits
- useDomEditSession (339 LOC): thin orchestrator wiring selection, agent
modal, preview interaction, and commit hooks
All files now under 500 LOC limit. Removed the temporary lefthook
filesize exclusion for useDomEditSession.
* feat(studio): add 4 domain contexts (PanelLayout, FileManager, DomEdit, Studio)
Create context providers that wrap hook return values for prop-drilling
elimination. Each context destructures and reconstructs the value inside
useMemo so exhaustive-deps is satisfied and re-renders are minimized.
Not yet wired into App.tsx — that comes in a follow-up.
* refactor(studio): wire domain contexts, eliminate prop drilling in 4 components
Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and
DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar,
StudioPreviewArea, and StudioRightPanel to consume contexts instead
of props.
Prop counts reduced:
- StudioHeader: 13 -> 6
- StudioLeftSidebar: 19 -> 4
- StudioPreviewArea: 37 -> 11
- StudioRightPanel: 39 -> 3
Net: -118 lines, 108 props removed from call sites.
* chore: upgrade to React 19
Upgrade react and react-dom from 18.3 to 19.2.6 across the workspace.
Add resolutions/overrides in root package.json to prevent peer
dependency pins (e.g. @phosphor-icons/react) from pulling React 18.
Regenerate bun.lock.
This enables the React 19 context syntax (<Context value={...}>)
used by the new domain contexts.
* fix(studio): refresh preview after z-index change so stacking updates visually
* fix(studio): remove duplicate duration override causing oscillation
The timeline message handler set the duration twice: once via
processTimelineMessage and once via a raw durationInFrames override.
When drilled into a sub-composition, these could disagree, causing
the duration to oscillate after element deletion.
* fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs
Two changes to fix duration oscillation after deleting a timeline clip:
1. Replace setRefreshKey (full Player remount) with in-place
iframe.contentWindow.location.reload() after deleting a clip.
The full remount triggered a chaotic re-probing cycle with multiple
duration sources (adapter, manifest, postMessage) fighting each
other, causing the timeline to oscillate between durations.
In-place reload preserves the Player web component and its state.
2. Remove window.confirm dialogs from both timeline clip delete and
DOM element delete. Undo is available so the confirmation adds
friction without value.
* chore: gitignore docs/superpowers
* feat(studio): add favicon
* perf(studio): skip no-op state updates in timeline sync
syncTimelineElements was called 60+ times per page load, each time
triggering setElements/setDuration/setTimelineReady even when nothing
changed. This caused massive re-render churn and memory usage.
Add early-return guards to skip updates when values haven't changed.
Also fixes the duration oscillation after element delete.
* refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules
The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit.
Split into cohesive modules by responsibility:
- propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants
- propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField,
SliderControl, SegmentedControl, SelectField, Section
- propertyPanelColor.tsx (371) — ColorField, ColorSlider
- propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers
- propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers
- propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls
- propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill)
- PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers
All re-exports from PropertyPanel.tsx preserved for backwards compatibility.
No behavioral changes — pure structural split.
* fix(studio): use in-place iframe reload for all timeline operations
Replace setRefreshKey with in-place iframe reload for move, resize,
and asset drop — matching delete which was already fixed. Prevents
the Player remount probe cycle that causes duration oscillation.
* perf(studio): replace 5s polling loop with event-driven adapter init
The Player's onIframeLoad used a setInterval polling loop (25 attempts
× 200ms = 5 seconds) to detect when the runtime's __player/__timeline
globals appeared. Each poll that missed triggered wasted work, and
multiple duration sources fighting during the probe cycle caused
oscillation bugs.
Replace with event-driven initialization:
1. Fast path: try initializeAdapter() immediately (works for in-place
reloads where the adapter is already present)
2. If not ready, listen for the runtime's "state"/"timeline" postMessage
signals and initialize on the first one
3. Single 5s timeout as safety net (replaces 25 interval ticks)
This eliminates the polling overhead, reduces setDuration/setElements
calls to exactly 1 per load, and makes the Player responsive within
one frame of the runtime being ready instead of up to 200ms later.
* fix(studio): prevent duration oscillation after element delete
Two fixes for the duration display oscillating between sub-composition
and master durations after deleting an element in the preview:
1. Clear store elements before iframe reload in handleDomEditElementDelete.
Without this, stale pre-delete elements remain in the store and cause
mergeTimelineElementsPreservingDowngrades to alternate between REPLACE
and PRESERVE modes as the element count fluctuates.
2. Add 500ms cooldown on enrichMissingCompositions after timeline messages.
The "state" handler was calling enrichMissingCompositions every ~80ms,
which added extra elements from GSAP timelines. These fought with the
authoritative element list from "timeline" messages (~333ms), creating
a feedback loop where element count oscillated and triggered alternating
merge strategies with different durations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): single reloadPreview as source of truth for preview refresh
Create reloadPreview() in App.tsx that encapsulates the correct
behavior (in-place iframe reload with setRefreshKey fallback). Pass it
as the sole refresh mechanism to hooks, removing direct setRefreshKey
access from useTimelineEditing and useDomEditCommits.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add rotation (R) field to geometry row (X, Y, W, H, R) in property panel.
Goes through manifest via handleDomRotationCommit, resettable with Reset Edits.
- Auto-promote display:inline elements to inline-block when dragged so
translate works on inline spans.
- Fix regression from polling fix: iframe load now passes readFromDiskFirst
to load manifest from disk, so Reset Edits finds existing entries.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Line height and letter-spacing: convert from free-text to select with presets
- Font style: remove oblique (browser falls back to italic), keep normal/italic
- Font weight: detect available weights via document.fonts.check(), add labels
- Font source: local fonts matching Google catalog tagged as Google
- Font list: balanced per-source caps prevent any source from being cut off
- Sort order: Google fonts rank before Local so curated fonts appear first
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the eye icon (inspector) and image icon (thumbnail toggle) from timeline
clips. The timeline layer inspector feature and all supporting code is removed.
Enable manual dragging in the preview by default. Add scrub-to-drag on X/Y/W/H
fields in the design panel. Hide the Radius section when the element has no
visible background. Fix pre-existing ResolutionPreset type for square presets.
Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0
because CSS opacity is not inherited — getComputedStyle on the child still
returns 1. Walk the ancestor chain in the picker, domEditing, and overlay
visibility checks to catch this.
Also:
- Containers with all-invisible children are no longer selectable
- Selection/hover overlay hides during playback and while loading
- Undo/redo no longer double-refreshes (echo suppression for all file writes)
- Undo/redo reloads iframe in-place instead of recreating the Player,
preserving shader transition cache
- Preview routes return ETag + Cache-Control headers; composition HTML uses
project signature for conditional 304, binary assets use mtime+size
- Loading overlay deferred 400ms so cached loads never flash it
Three bugs that compound in Studio preview:
1. **Double audio on pause/resume**: syncRuntimeMedia played audio through
the HTML <audio> element while WebAudioTransport simultaneously played
the same source through AudioBufferSourceNode. Fixed by passing
webAudio.isActive() as outputMuted so HTML elements stay muted when
Web Audio owns playback. Also removed the priorMuted restore in
stopAll() which raced with the next play cycle.
2. **Manifest polling loop**: applyStudioManualEditsToPreview and
applyStudioMotionToPreview unconditionally fetched from disk on every
call, even without forceFromDisk. The runtime posts state messages
every frame via postMessage, triggering React re-renders that re-invoked
these functions ~60x/second. Fixed by returning early when no disk read
is requested, and using refs instead of callbacks in useEffect deps.
3. **Parent proxy double-play**: the player web component created parent-frame
audio proxies even when the runtime bridge was available, causing two
audio sources on autoplay-blocked promotion. Fixed by skipping proxy
creation when _hasRuntimeBridge returns true, and synchronously muting
iframe media on promotion to close the async race window.
Also fixes pre-existing ResolutionPreset type missing square variants.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
React registers onWheel passively, so preventDefault had no effect
on the parent scroll container. Replace with a native wheel listener
(passive: false) that blocks both default scroll and propagation.
- Move Text section to the top of the panel (before Layout)
- Remove Selection Colors section
- Rename "Blending" to "Transparency"
- Fix stroke Width/Style height mismatch by making SelectField
use inline label layout matching MetricField
Only show the composition loading overlay on the first iframe load.
Hot-reloads (source editor save, timeline edits, element delete)
no longer flash the full-screen loading state.
linkedom's document.querySelectorAll does not traverse <template>
content. Elements in template-based compositions (like .title-word,
.bullet-text) were invisible to the removal logic, so delete
returned changed: false and the element survived the reload.
Fall back to template.querySelectorAll when the document-level
query returns no matches. Uses template.querySelectorAll directly
(not template.content.querySelectorAll) because removing from
the content DocumentFragment doesn't update the serialized output.
The four existing presets only cover 16:9 (landscape) and 9:16 (portrait)
aspect ratios. A 1080×1080 square comp had nowhere to land at any scale:
"Auto" rendered at the comp's authored 1080×1080, and picking 1080p or 4K
mapped to a landscape/portrait preset whose aspect ratio mismatched, which
the producer's resolveDeviceScaleFactor validator rejects with
"does not match the aspect ratio of the composition".
Add `square` (1080×1080) and `square-4k` (2160×2160) to CANVAS_DIMENSIONS
in core. The existing `keyof typeof CANVAS_DIMENSIONS` derivation
extends the `CanvasResolution` union and `VALID_CANVAS_RESOLUTIONS` array
automatically, so the producer's validator, the render API route, and
the CLI `--resolution` flag pick the new presets up without further
changes.
- core: extend CANVAS_DIMENSIONS, RESOLUTION_ALIASES, and the
htmlParser to recognize `data-resolution="square|square-4k"` and to
infer square from equal width/height (vs. the prior "square defaults
to portrait" tie-breaker).
- studio: extend the local ResolutionPreset / CANVAS_DIMENSIONS mirrors;
collapse isPortraitComp into a 3-way `compAspect` helper so
resolveResolution returns the square preset for square comps.
- cli: update --resolution help text on `init` and `render` to mention
the new presets.
- tests: add square cases to renderOrchestrator's resolveDeviceScaleFactor
suite (returns 1 for square→square, 2 for square→square-4k, rejects
landscape preset on square comp), update the htmlParser test that
previously pinned the "square→portrait" tiebreaker.
The consolidated handleAppKeyDown was only added to the parent
window. When focus was inside the preview iframe (after clicking
an element), keydown events didn't reach the parent, so Delete
and other shortcuts didn't fire.
Replace the per-function iframe forwarding (handleTimelineToggleHotkey
only) with the full app-level handler via a ref-stable wrapper.
All app shortcuts (Delete, Undo/Redo, Shift+T, Cmd+1/2) now work
from within the preview iframe.
Leftover from moving Delete handling to the consolidated
keyboard handler in App.tsx. Also suppress pre-existing
exhaustive-deps warning on the intentional every-render
selection-change watcher.
Cleanup from the /simplify pass on PR #715.
- App.tsx: subscribe to the runtime's `stage-size` message (which
carries authoritative width/height post-applyCompositionSizing)
instead of re-parsing data-width/data-height from the iframe DOM.
Drops the cross-origin try/catch, querySelector, and parseInt logic,
and fires once per comp load instead of on every state/timeline tick.
- App.tsx: import CompositionDimensions from RenderQueue instead of
inlining the shape.
- RenderQueue.tsx: replace scaleLabel() with a SCALE_LABEL record,
inline the one-call formatDims helper, and trim the type comment to
the WHY.
Two bugs in getSharedBrowser() could take down the entire Vite dev
server:
1. Unhandled rejection from puppeteer.launch() — the timeout error
surfaces through puppeteer's internal RxJS chain, and any uncaught
path crashes the Node process. The thumbnail route's try/catch
doesn't always intercept it.
2. _browserLaunchPromise was never reset on failure, so subsequent
thumbnail requests reused a stale rejected promise instead of
retrying.
Wrap the IIFE in try/catch, return null on any failure (the thumbnail
route already handles a null adapter result with a 500), and reset
_browserLaunchPromise in a finally block so a transient launch failure
doesn't poison the singleton. Also drop the launch timeout from
puppeteer's 30s default to 10s so a wedged handshake fails fast instead
of stalling every pending thumbnail.
Verified locally: the dev server now logs
"[Studio] puppeteer launch failed — thumbnails disabled: ..." and
keeps serving the studio UI after a thumbnail request fails.
The consolidated keyboard handler only checked selectedElementId
(timeline clips). When a user selected a child element in the
preview via the inspector, selectedElementId was null because
the element didn't correspond to a top-level timeline clip, so
Delete/Backspace did nothing.
Add handleDomEditElementDelete that removes the element referenced
by the current domEditSelection via the remove-element mutation
API. The Delete key handler now falls through from timeline
selection to DOM edit selection.
Orientation is a property of the composition, not a user choice — the
backend's portrait/landscape presets are tied to the comp's authored
aspect ratio. Letting users pick "1080p portrait" for a landscape
composition just produces a wrong-aspect render.
The dropdown now exposes three scale choices (Auto / 1080p / 4K) and
maps to the correct portrait/landscape preset based on the active
composition's data-width / data-height. Native <select title> tooltips
are unreliable across browsers, so the resolved dimensions render
inline in each option label (e.g. "1080p · 1920×1080") — always
visible, no hover needed.
App.tsx tracks the active comp's dimensions by listening for the
existing hf-preview state/timeline postMessages (same source the
caption-detection logic uses) and passes them to RenderQueue. The
useRenderQueue / backend contract is unchanged: RenderQueue still emits
"landscape" | "portrait" | "landscape-4k" | "portrait-4k" | "auto".
1. Sidebar tabs: use equal 1fr columns, shorter "Comps" label, truncate
on overflow, tighter padding. Fixes tabs clipping outside the rounded
pill at narrow sidebar widths.
2. Hot reload: set domEditSaveTimestampRef before every save-then-refresh
path (source editor, timeline move/resize/delete, asset drop). The
file-change watcher already checks this timestamp and suppresses
echoed events — but source editor saves and timeline operations
weren't setting it, causing a double refreshKey increment that could
leave the player in a non-playable state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hoist duplicated test mock helpers (createMockAudioContext / setupTransport /
mockBuffer / mockEl) from the two describe blocks to module scope.
- Drop redundant math-derivation comments in schedulePlayback; the dedicated
rate-aware tests are the canonical proof.
- Tighten setRate JSDoc.
- Add no-op guard in setRate when the new rate equals the current rate, so a
duplicate set-playback-rate postMessage doesn't re-anchor or walk active
sources for nothing.
- Add a regression test for the no-op guard, and strengthen the clamp test
to schedule at rate=2 first so the clamp-to-1 assertion is non-vacuous.
WebAudioTransport scheduled AudioBufferSourceNodes with the implicit
default playbackRate of 1, so non-1x transport rates desynced visuals
from audio: GSAP timelines, the transport clock, and native <video>
all sped up while WebAudio-routed <audio> clips kept playing at 1x.
- schedulePlayback now accepts a rate, sets sourceNode.playbackRate,
and scales the future-clip start delay by the rate (the in-progress
buffer offset stays elapsed + mediaStart, which is rate-independent).
- New setRate() updates active sources in place and rebases the
getTime() reference frame so the audio-master clock stays continuous
across mid-playback rate changes.
- Runtime onSetPlaybackRate now forwards into webAudio.setRate, and
player.play() schedules each clip with state.playbackRate.
Fixes#713
Move all window-level keyboard shortcuts from 4 separate files into
one `handleAppKeyDown` listener in App.tsx:
- Shift+T: toggle timeline (was App.tsx, separate useMountEffect)
- Cmd/Ctrl+Z: undo (was App.tsx, separate useEffect)
- Cmd/Ctrl+Shift+Z: redo (was App.tsx, separate useEffect)
- Cmd/Ctrl+1: sidebar Compositions tab (was LeftSidebar.tsx)
- Cmd/Ctrl+2: sidebar Assets tab (was LeftSidebar.tsx)
- Delete/Backspace: remove selected element (was Timeline.tsx)
LeftSidebar exposes a ref handle for tab switching. Timeline watches
selectedElement becoming null to clean up popover/range UI state.
History hotkey kept as named function for iframe forwarding.
Playback shortcuts (Space, J/K/L, arrows) and caption nudge remain
in their component hooks — tightly coupled to component state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Seeking a playing video resets the browser's decoder pipeline, causing
a ~150ms freeze while it re-buffers. During that freeze the monotonic
clock advances, drift grows, and strict sync fires another seek —
creating a perpetual stutter loop (176 seek events / 8s observed on
the apple-presentation composition).
Skip strict and force drift corrections for playing video elements;
only hard sync (>0.5s catastrophic drift) warrants the decoder-reset
cost. Audio elements are unaffected and retain the full correction
tiers.
Also propagate the asset-loading overlay state to the timeline so
controls are disabled during "Preparing preview assets", matching the
existing behavior for the initial composition loading overlay.
Three changes that together caused audio play/stop/play/stop stutter
during transport-driven playback:
1. seekRuntimeTimeline called timeline.pause() before every totalTime()
seek, 60x per second. GSAP cascades pause to media elements on every
frame. Fix: restore original inline seek for the captured timeline
(totalTime without pause). The timeline is already paused once in
player.play(). seekRuntimeTimeline with pause() remains only for
standalone child timelines.
2. player.play() removed the !tl guard, allowing play without a
captured timeline. But getSafeTimelineDurationSeconds(null) returns
0, so the clock has no duration → immediately reaches end → stops →
restarts. Fix: when no timeline provides duration, fall back to the
root composition element's data-duration attribute.
3. Audio source attachment added networkState guard that could cause
the clock to flicker between audio-source and monotonic timing
on transient media states. Fix: keep !rawEl.error guard (prevents
errored audio from freezing the clock) but drop the networkState
check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restores handleDomAddTextField and handleDomRemoveTextField that were
dropped when resolving App.tsx conflicts during the main→next rebase.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The seekRuntimeTimeline helper added timeline.pause() before every
totalTime() seek. During transport-driven playback, this runs 60 times
per second, causing GSAP to cascade pause events to media elements on
every frame. The result: audio plays/stops/plays/stops in a stutter
pattern.
The captured root timeline is already paused once in player.play() —
the TransportClock drives it via totalTime(t) which keeps it paused.
The extra per-tick pause() was redundant for the root timeline but
actively harmful for media sync.
Fix: restore the original inline seek for the captured timeline
(totalTime without pause), keep seekRuntimeTimeline with pause() only
for standalone child timelines where explicit pause control is needed.
Also fixes rebase artifact: missing PropertyPanel props in App.tsx.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevent users from selecting elements in the preview while the
composition is still loading (showing "Loading composition" overlay).
Selection and hover highlighting are suppressed until the player fires
the ready event.
Also reverts motion panel and manual drag editing defaults to false —
these were accidentally set to true during the PR #693 merge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compositions with external sub-compositions (like apple-presentation
with 7 slides) load child compositions via fetch(). The root GSAP
timeline is only bound after all external compositions finish loading,
but the TransportClock duration was only set during initial setup.
When bindRootTimelineIfAvailable runs after the external compositions
load, it captures the root timeline but never updates the clock.
player.getDuration() continues returning 0, so the player's probe
interval never fires the 'ready' event, and the Studio shows "Loading
composition" indefinitely.
Now bindRootTimelineIfAvailable updates clock.setDuration when the
root timeline is late-bound. Guarded with try/catch for the early call
site where clock is not yet initialized (temporal dead zone).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Power-user audit fixes for the alpha studio:
- vite.config.ts: wrap thumbnail generation in try/catch so Puppeteer
TimeoutError doesn't crash the entire vite dev server as an uncaught
rejection. Close the page on error to prevent browser session leaks.
- manualEditingAvailability.ts: enable motion panel and manual canvas
drag editing by default (were both false, undiscoverable without
knowing the env vars).
- PropertyPanel.tsx: show "N elements selected" feedback when multiple
elements are selected instead of the generic "Select an element"
empty state.
- RenderQueue.tsx + App.tsx: add FPS selector (24/30/60) to the render
export bar instead of hardcoding 30fps. Pass the user's choice
through to startRender.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs found via automated e2e testing of the v0.6.0-alpha preview:
1. core: add missing package.json export specifiers for
studio-api/manual-edits-render-script and
studio-api/studio-motion-render-script — the alpha.3 npm publish
failed because the studio build could not resolve these sub-paths.
2. cli: fix init --example creating empty projects — tsup leaves empty
template directories in dist/ during the build, causing
existsSync(templateDir) to return true and skip the remote fetch
fallback. Now checks for index.html inside the dir instead.
3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg
stdin/stdout had no error handlers, so a write after the ffmpeg
process exits throws an uncaught error that crashes the process.
Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The seek/play/applyAfter wrapper functions in manualEdits.ts crashed
with "Cannot set property X which has only a getter" when the player
or timeline objects define seek/play as getter-only properties. This
prevented ALL manual edits (position, rotation, size) from persisting
to disk — the error thrown during applyCurrentStudioManualEditsToPreview
aborted the save queue.
Wrapped all three property assignments in try/catch so wrapping
gracefully degrades when the target object is non-configurable.
Verified: position edit (X=42px) now persists to
.hyperframes/studio-manual-edits.json and survives page refresh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Motion panel stays opt-in via env var per product direction. Only
the Design panel is enabled by default.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The shared Puppeteer browser pool in getSharedBrowser() could throw a
30s TimeoutError during launch. This error propagated as an uncaught
rejection and killed the vite process, even though generateThumbnail
had its own try/catch — the browser launch promise rejected outside
that scope. Now getSharedBrowser itself catches launch failures and
returns null, so thumbnails degrade gracefully instead of crashing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>