Skip to content

fix: align Studio capture with preview#595

Merged
miguel-heygen merged 1 commit intomainfrom
fix/studio-capture-preview-routing
May 2, 2026
Merged

fix: align Studio capture with preview#595
miguel-heygen merged 1 commit intomainfrom
fix/studio-capture-preview-routing

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Problem

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.

What this fixes

  • 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.

Root cause

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.

Verification

Local checks

  • 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.

Browser verification

Using agent-browser, I mounted /Users/miguel07code/Downloads/Notion Showcase into Studio's project data and opened:

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.

Notes

  • 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.

@miguel-heygen miguel-heygen marked this pull request as ready for review May 2, 2026 01:28
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 — single commit, two distinct bugs, two distinct tests. Clean PR shape after the bundling concerns from #591.

What's covered

Andy's sub-composition capture issue → "align Studio capture with preview"

Root cause: capture's preview-seek path only knew about window.__timelines (the GSAP timeline registry). Compositions exposing the newer window.__player runtime API never got seeked at capture time, so the captured frame was off-time. The new vite.thumbnail.ts:seekThumbnailPreview checks __player.seek first and falls back to __timelines.pause(t) only when __player isn't present. Returns "player" | "timelines" | "none" so failures are debuggable.

Tests in vite.thumbnail.test.ts cover both paths:

  • __player.seek present → it gets called, __timelines.pause does NOT, returns "player".
  • Only __timelines present → all entries get pause(t), gsap ticker ticks, returns "timelines".

Bonus: the helper extraction also resolves the file-organization concern from #591 — instead of keeping seek logic inline in vite.config.ts, it now lives in a peer file (vite.thumbnail.ts) imported relatively, no Node-can't-load-.ts issue.

Names with spaces → projectRouting.ts

Root cause: App.tsx's useMountEffect did window.location.hash.match(/^#project\/([^/]+)/) and used the raw match, so #project/Notion%20Showcase produced projectId = "Notion%20Showcase" (encoded). Downstream frameCapture.buildFrameCaptureUrl then double-encoded that into Notion%2520Showcase for the API path. Capture endpoint received the wrong project id and 404'd.

parseProjectIdFromHash decodes properly, gracefully falls back on malformed %XX escapes, and buildProjectApiPath encodes exactly once. 11 test cases cover: legacy raw-space hash, malformed escapes, reserved chars (#/?), unicode round-trip, double-encoding-prevention, and end-to-end via buildFrameCaptureUrl.

Bonus seek-time fix in thumbnail.ts

Server-side: parseFloat(t || "0.5") || 0.5 was coercing t=0 to 0.5 (because 0 || 0.5 === 0.5). Fix uses Number.isFinite check so explicit t=0 is preserved. Cache version bumped v2v3 to invalidate stale cached thumbnails. Test "preserves an explicit zero seek time" added.

Symmetric proof

  • Seek-time fix: reverted thumbnail.ts while keeping the new test → "preserves an explicit zero seek time" fails red (1/3 fail). Restore → 3/3 pass.
  • Project routing: removed projectRouting.ts while keeping its test → file load fails, 0 tests run. Restore → 11/11 pass.

Both fixes have tests that genuinely cover the bugs.

Tests

  • @hyperframes/studio 269/269 (was 256, +13 from the new test files)
  • @hyperframes/core 602/602 (was 601, +1 from the new thumbnail-test case)

CI gate added: the new preview-regression.yml step runs the four relevant test files specifically, so future regressions on the capture-vs-preview alignment surface immediately.

One nit, non-blocking

Looking at hf#591's lessons re: bundled fixes — this PR does the right thing by keeping the two bugs in one commit but with distinct test files (vite.thumbnail.test.ts for sub-comp, projectRouting.test.ts for spaces, thumbnail.test.ts for the t=0 case). If anything, the t=0 fix probably warranted its own commit (it's third-bug-territory, not part of the two Miguel called out), but the test exists and the change is small/safe. Not worth blocking on.

— Review by Rames Jusso

@miguel-heygen miguel-heygen merged commit 04bd56a into main May 2, 2026
41 checks passed
Copy link
Copy Markdown
Collaborator Author

Merge activity

@miguel-heygen miguel-heygen deleted the fix/studio-capture-preview-routing branch May 2, 2026 01:45
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.

2 participants