Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404.
While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview.
- Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction.
- Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode.
- Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages.
- Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds.
- Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing.
Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched.
The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time.
The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`.
- `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts`
- `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts`
- `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bun run --cwd packages/studio typecheck`
- `bun run --cwd packages/core build:hyperframes-runtime`
- `bun run --cwd packages/core typecheck`
- `git diff --check`
Pre-commit also reran lint, format, and typecheck successfully for the committed files.
Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened:
```text
http://127.0.0.1:5197/#project/Notion%20Showcase
```
Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`.
After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared.
Mean pixel diffs for preview vs capture were:
- `0s`: `0.0`
- `2s`: `0.8641`
- `10s`: `0.3496`
- `18s`: `0.2309`
The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions.
- Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed.
- The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed.
- Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused.
Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.
The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.
- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.
Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.
Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.
- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass
- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.
- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.
* fix: stabilize studio preview and runtime sync
* fix: pass selector through timeline thumbnails
* feat: add studio timeline editing
* fix: disambiguate timeline edit targets
* fix: stop timeline auto-scroll in fit mode
* feat: use percentage-based timeline zoom
* fix: sync timeline playhead on zoom changes
* fix: reset timeline scroll when returning to fit
* feat(studio): add manual DOM editing inspector
* docs: update studio manual dom editing guide
* feat(studio): add image asset picker for fills
* feat(studio): add inline image uploads for fills
* fix(studio): use real file input for image fill uploads
* fix(studio): restore toast plumbing after rebase
* fix(studio): explain in-app upload limitation
* fix(studio): reuse asset-tab upload pattern in fills
* feat(studio): refine manual design inspector
* fix(studio): polish manual design inspector
* fix(studio): keep color picker in viewport
* fix(studio): clarify color picker selection
* docs: update manual DOM editing guide
* fix(studio): keep gradient color picker open
* fix(studio): scope text color to text layers
* fix(studio): add agent fallback for immovable layers
* fix(studio): address manual editing review feedback
* fix(studio): make local font selection reliable
* fix(player): replay from start when play is pressed after video ends
When a non-looping composition reaches its end, pressing play again
had no effect because the playhead stayed at the final frame. Now
play() detects the ended state and seeks to 0 before resuming.
* chore: fix pre-existing format issues in registry files
The add command's tag fallback (e.g. `hyperframes add html-in-canvas`)
uses the same 24h cached manifest as single-item lookups. When new items
are added to the registry, the stale cache returns an incomplete item
list, causing tag resolution to find zero matches.
Pass skipCache: true in the tag fallback path so it always fetches the
latest manifest from the registry.
* docs: group VFX blocks under HTML-in-Canvas, captions under Captions in sidebar
* docs: add HTML-in-Canvas guide, Chrome flag disclaimer, remove captions
- Add docs/guides/html-in-canvas.mdx — comprehensive guide covering the
API, feature detection, re-capture patterns, and catalog blocks
- Add Chrome flag Warning banner to every HTML-in-Canvas block page
- Remove captions blocks from registry (will ship separately)
- Add html-in-canvas guide to docs navigation (top of Guides section)
* feat: update liquid glass, portal, shatter with HyperFrames branding
Replace generic placeholder content with HyperFrames-themed text:
- Liquid Glass: 'Ship videos 10x faster' with stats and gradient text
- Portal: 'Write HTML / Render Video' with HyperFrames nav
- Shatter: 'HTML is Video' with render speed/file size metrics
Re-rendered and uploaded preview videos to S3.
Merges main's refactored capture (CaptureSceneOptions, forceVisible,
stabilizeTransformedBoxShadows, foreignObjectRendering fallback) with
our HTML-in-Canvas drawElementImage capture path. The native capture
tries first and falls back to html2canvas on failure.
hyperframes add now auto-detects whether the argument is a single
block name or a tag. No --tag flag needed:
hyperframes add html-in-canvas # installs all 7 html-in-canvas blocks
hyperframes add captions # installs all 5 caption blocks
hyperframes add vfx-shatter # installs one block
When the name doesn't match a registry item, the CLI falls back to
tag-based resolution and bulk-installs all matching blocks.
Also fixes tag assignments:
- VFX blocks tagged "html-in-canvas" (not "vfx")
- Caption blocks tagged "captions" only (no html-in-canvas)
All 12 blocks now share the 'html-in-canvas' tag, enabling:
hyperframes add --tag html-in-canvas
This installs all VFX + caption blocks in one command.
Install all registry blocks matching a tag in one command:
hyperframes add --tag vfx # installs all 7 VFX blocks
hyperframes add --tag captions # installs all 5 caption blocks
The resolver loads each item's full manifest to check tags, then
installs matching blocks sequentially. Failed items are skipped
with a warning so one broken block doesn't abort the batch.
Also supports JSON output for CI: hyperframes add --tag vfx --json
Add 12 new registry blocks across two new categories:
**VFX Blocks (7):**
- vfx-text-cursor: dramatic text reveal with cursor glow and chromatic shadows
- vfx-liquid-background: organic liquid simulation with vertex displacement
- vfx-iphone-device: real GLTF iPhone + MacBook with live HTML screens
- vfx-magnetic: magnetic field particle visualization
- vfx-portal: dimension breach portal transition
- vfx-liquid-glass: Voronoi glass fracture with parallax reveal
- vfx-shatter: glass shatter with physics-driven fragments
**Caption Blocks (5):**
- captions-slam: bold uppercase scale-pop with back.out overshoot
- captions-karaoke: word-by-word color reveal on frosted glass pill
- captions-minimal: clean Netflix-subtitle style fade
- captions-bounce: playful elastic bounce with colorful word pills
- captions-cinematic: elegant slow fade with letter-spacing animation
All blocks include registry-item.json and are registered in registry.json.
VFX blocks use experimental stability, caption blocks use stable.
* fix(runtime): clear play guard after hard seek to prevent audio desync on scrub
When scrubbing the timeline during playback, syncRuntimeMedia detects
the offset jump and hard-seeks the media element. But the in-flight
play() guard (playRequested WeakSet) from the previous play() call
prevented the next sync tick from re-issuing play() — leaving the
element paused at the new position for 50-150ms while the GSAP timeline
continued advancing. This caused audible audio desync after every scrub.
Fix: clear playRequested on the element after a hard seek so the very
next sync tick can re-issue play().
Also adds a lint rule (video_audio_double_source) that catches
compositions where an unmuted <video> and a separate <audio> point to
the same source — a pattern that causes double playback at runtime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime): detect failed seeks past MP3 buffer and force full fetch
Root cause: streaming MP3 with preload="metadata" only buffers the first
~15 seconds. Seeking past the buffered range silently fails — currentTime
stays at 0 while the timeline advances, causing permanent audio desync
that only a page refresh fixes.
Three changes:
1. Move preload="auto" enforcement to run for ALL active elements on
every sync tick (not just during play). This catches elements whose
preload was overridden after init.ts set it.
2. After a hard seek, check if currentTime actually reached the target.
If not (drift > 0.5s), call load() once to force the browser to
fully fetch the media and build a complete seek index.
3. Clear the load-retry guard when the clip leaves its active window
so re-entry can retry if needed.
Reproduced on hyperframes.dev Hermes launch video: vo.mp3 buffered to
15.96s, seeking to 20s failed silently. bg-music.wav (fully buffered)
was unaffected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per CodeQL's `js/bad-tag-filter` recommendation, replace the regex-based
`<script>` body extraction with a `parseHTML` + `querySelectorAll`
walk. The rule explicitly says "use a parser library" — and linkedom
is already imported in this file, so the diff is small.
This eliminates the regex entirely, so the rule can no longer fire on
this site (instead of chasing whitespace / case / trailing-content
edge cases one at a time).
CodeQL still flagged `</script\s*>` as too narrow — the rule wants
tolerance for `</script\t\n bar>` (HTML parser treats trailing content
in a close tag as part of the tag). Switched to `</script[^>]*>` for
full coverage.
The bundler still always emits the canonical `</script>`; this is
test-side hardening, not a runtime fix.
CodeQL's `js/bad-tag-filter` rule flagged `</script>` as too strict —
`</script >` (with whitespace before `>`) is valid HTML and would slip
past the matcher. Changed to `</script\s*>` for full defense-in-depth.
The bundler always emits the canonical form, so no real-traffic miss —
this is hardening the test's parse-loop, not fixing a downstream bug.
Addresses CodeQL alert on #641.
CodeQL flagged the inline `<script>...</script>` regex as case-sensitive,
which would miss `<SCRIPT>` tags. The bundler always emits lowercase, so
this is a defense-in-depth fix matching the `/i` flag already used by the
sibling regexes in this file (lines 37 & 75).
Addresses CodeQL review on #641.
Per @vai-bot's review on hf#641:
Important #1: dead `src=""` substitution sites
=============================================
Now that `bundleToSingleHtml` inlines the runtime IIFE by default, the empty
`src=""` placeholder is never emitted in the no-env-var path — the 5 downstream
substitution sites that grep for `src=""` were dead.
Two of them (studio dev server + studio vite preview) genuinely WANT the
placeholder so they can hot-reload a local /api/runtime.js endpoint without
re-inlining ~150 KB on every composition edit. Three of them (CLI validate,
snapshot, layout) were just doing the same inlining the bundler already does.
Resolution:
- Add a `runtime: "inline" | "placeholder"` option to `BundleOptions`. Default
is "inline" (matches the self-contained-bundle promise the function name
makes). The two studio surfaces explicitly pass `{ runtime: "placeholder" }`
to opt in.
- studioServer.ts + studio/vite.config.ts: pass the option, keep their
existing string-replace logic unchanged.
- validate.ts + snapshot.ts + layout.ts: delete the now-redundant runtime
substitution code (regex never matches the new inlined-runtime shape).
Important #2: joinJsChunks ASI hazard
======================================
The new helper appended `;` to chunks not already ending in `;` and joined
on `\n`. If a chunk ended with a `// line comment`, the appended semicolon
was eaten by the comment, leaving the next chunk's first statement attached
to the previous chunk's last expression — exactly the ASI hazard the helper
exists to prevent.
Fix: append `\n;` instead of `;` for chunks not already terminated. The
newline closes the line comment, the standalone `;` becomes the statement
separator. For typical chunks (already ending in `;`), output is unchanged
— still clean `\n`-joined chunks with no bare-semicolon lines.
Also added a trailing `;` to `wrapScopedCompositionScript`'s IIFE close
(`})()` → `})();`) so composition scripts join cleanly without falling
through to the `\n;` fallback.
New test: regression guard at the chunk boundary verifies every inline
script body in the bundle parses cleanly via esbuild even when a source JS
file ends with a line comment.
Verification
============
- `bun run --filter @hyperframes/core test` — 653/653 pass
- `bun run --filter @hyperframes/cli test` — 243/243 pass
- `bun run --filter @hyperframes/{core,cli,studio} typecheck` — clean
- `bunx oxfmt --check` + `bunx oxlint` on all touched files — clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues in `bundleToSingleHtml` reported via Abhay's LLM-based code-validity
eval against the bundled output. Each is independently small; they share a single
PR because they're all artifacts of the bundler-output shape.
1. Empty `src=""` runtime placeholder (real bug)
`htmlBundler.ts:injectInterceptor` emitted
`<script data-hyperframes-preview-runtime="1" src=""></script>`
when no `HYPERFRAME_RUNTIME_URL` was configured. Empty `src` resolves to the
page URL itself; Chrome flags this as an infinite-fetch hazard. Three other
consumers (studioServer, validate, snapshot) post-process the placeholder to
substitute either a real URL or an inlined body — `bundleToSingleHtml` did
not, so the bundle wasn't actually self-contained despite the function name.
Fix: when no URL is configured, inline the runtime IIFE directly via
`getHyperframeRuntimeScript()`. Otherwise emit `src=…` as before.
2. Bare-semicolon lines between joined JS chunks (cosmetic)
Three sites used `chunks.join("\n;\n")` (body-script coalesce, local JS,
composition scripts) which produced a lone `;` on its own line between
chunks. Valid JS but a code smell. Replace with a `joinJsChunks()` helper
that ensures each chunk ends in `;` and joins on `\n`.
3. Empty `catch (_err) {}` in compositionScoping.ts (lint-noisy)
The `_err` underscore prefix signals "intentionally swallowed" but bundle-time
linters often don't honor that convention. Replaced with `catch { /* ... */ }`
(no binding, explanatory comment) — same behavior, no rule fires.
Tests: 2 new regression guards (runtime-not-empty-src, no-bare-semi) plus
existing tests updated to reflect the new inlined-runtime shape (the previous
"runtime block must not contain getElementById" assertion no longer holds
because the inlined body itself uses getElementById; replaced with a more
specific "author script not merged into runtime tag" check).
Issue #4 from the original report (Unterminated string at line 1111 col 18,
char 65497) was not directly reproducible after applying these fixes — esbuild
parses all 4 inline scripts in the rebundled output cleanly. The unterminated-
string symptom was likely a downstream artifact of the bare-semicolon joining
or the empty-src placeholder confusing the lint tool. If the original symptom
persists on a clean re-run against the fixed bundle, will open a follow-up PR
with a focused repro.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract applyMask helper from postprocess and add 5 unit tests pinning
the contract this PR is selling: fg.alpha + bg.alpha === 255 per pixel,
RGB triples byte-identical between fg and bg, and bg=null path leaves
the bg buffer untouched. Without these, a future postprocess change
(mask threshold, premultiplied alpha, gamma) could silently break the
inverse-alpha relationship and the existing plumbing tests would all
still pass.
- Add stdin 'error' listener inside spawnFfmpeg. If either encoder dies
mid-render, Node emits an unhandled error on the dead writable on the
next .write() and crashes the CLI before waitForExit's reject path
can surface the encoder's stderr tail. Doubled encoder count = doubled
failure surface, so this is worth pinning down.
- Tighten stdio param to a 3-tuple so an accidental 1-element array fails
at type-check.
- Sharpen backpressure comment: write→true means "highWaterMark not
exceeded," not "libuv flushed." Reuse-without-corruption is safe only
because session.process is slow enough that libuv drains in between.
Addresses review on PR #637.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>