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>
Fixes and improvements based on power-user testing feedback:
1. Fix stale selection after style edits — handleDomStyleCommit now
calls refreshDomEditSelectionFromPreview after persisting, matching
every other commit handler. Without this, the PropertyPanel showed
frozen computedStyles after color/radius/shadow edits, making it
look like editing "didn't work." Also adds error handling around
the persist call.
2. Add rotation field to the Design panel Layout section — reads the
current rotation angle from the manual edit manifest and commits
via the existing handleDomRotationCommit handler.
3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now
defaults to true so the Motion tab is discoverable without env vars.
4. Color controls only when element has color — fill color section now
only shows when the element has an explicit non-transparent
background-color. Text color shows only when the element has a
color style. Prevents showing color pickers on elements where
color edits have no visible effect.
5. Exclude canvas from selection — added "canvas" to
DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the
preview or listed in the layer panel.
6. Multi-selection feedback — shows "N elements selected" with
guidance instead of the generic empty state when multiple elements
are selected.
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>
Clips whose compositionStart is ahead of the current timeline position
were starting immediately because sourceNode.start() always received
when=0. Use the AudioContext scheduling API to defer future clips:
sourceNode.start(ctx.currentTime + delay, mediaStart).
Closes#674
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the two-clock architecture (GSAP rAF ticker + HTMLMediaElement
pipeline reconciled by a 50ms polling loop) with a single TransportClock.
GSAP is always paused and seeked to clock.now() on each rAF tick.
Drift between visual timeline and audio is structurally impossible.
Architecture:
TransportClock.now() ──rAF──▶ timeline.seek(t) + el.currentTime
▲
AudioContext.currentTime (~21µs) ← WebAudio active
OR
audio.currentTime (~33ms) ← HTMLMediaElement fallback
OR
performance.now() (~1ms) ← no audio
Key changes:
- TransportClock class with monotonic + audio-master clock sources
- WebAudioTransport: routes audio through AudioBufferSourceNode for
sample-accurate scheduling, falls back gracefully to HTMLMediaElement
- rAF tick loop replaces 50ms setInterval poll; GSAP always paused
- Strict sync (40ms threshold, consecutive-sample gated) + forceSync
on play/pause/seek transitions for sub-frame media accuracy
- Buffer-stall: visuals freeze when audio is buffering instead of
running ahead
- Frame quantization preserved in seek/renderSeek (parity contract)
Browser-verified: 0.0ms drift after 40 pause/play cycles (was 400ms+).
Also fixes: CDN script HTML error responses in validate (pre-existing).
54 tests across clock, clock-drift, webAudioTransport, and media.
Closes#668
The first dynamic `await import("./render.js")` cold-load takes >5 s on
Windows runners — long enough to blow vitest's default 5 s timeout in
whichever test ran it first. Subsequent imports are <10 ms because the
module is now cached, so only test #1 ever times out.
The downstream failure is more subtle: when test #1 times out, vitest
moves on, but its leaked async function eventually hits the synchronous
`producer.createRenderJob(...)` line and pushes a stale config to
`producerState.createdJobs`. That push lands AFTER test #2's `beforeEach`
clears the array, so test #2's `createdJobs[0]` is the leaked test #1
entry instead of its own. That's why test #2 saw `browserGpuMode: 'software'`
when it expected `'auto'`.
Hoist the import into `beforeAll` (matching the pattern the existing
`parseVariablesArg` and `validateVariablesAgainstProject` describe blocks
in this file already use). Cold-load happens once outside any test's
timeout window, every test stays fast, no leaked promise can corrupt
state.
Failing run: https://github.com/heygen-com/hyperframes/actions/runs/25470257972/job/74732502915
Started failing on main with the merge of #642 (auto-detect-browser-gpu),
which added the "forwards browserGpuMode='auto'" test as test #2.