Files
Miguel Ángel 04bd56a7ae fix: align Studio capture with preview (#595)
## 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:

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

## 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.
2026-05-02 03:45:38 +02:00

57 lines
1.9 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { seekThumbnailPreview } from "./vite.thumbnail";
describe("seekThumbnailPreview", () => {
it("prefers the HyperFrames player seek path over raw timelines", async () => {
const evaluate = vi.fn(async (fn: (time: number) => string, time: number) => {
const playerSeek = vi.fn();
const timelinePause = vi.fn();
const previousWindow = globalThis.window;
vi.stubGlobal("window", {
__player: { seek: playerSeek },
__timelines: {
main: { pause: timelinePause },
nested: { pause: timelinePause },
},
});
try {
const result = fn(time);
expect(playerSeek).toHaveBeenCalledWith(10);
expect(timelinePause).not.toHaveBeenCalled();
return result;
} finally {
vi.stubGlobal("window", previousWindow);
}
});
await expect(seekThumbnailPreview({ evaluate }, 10)).resolves.toBe("player");
});
it("falls back to all registered timelines for standalone composition pages", async () => {
const evaluate = vi.fn(async (fn: (time: number) => string, time: number) => {
const firstPause = vi.fn();
const secondPause = vi.fn();
const tickerTick = vi.fn();
const previousWindow = globalThis.window;
vi.stubGlobal("window", {
__timelines: {
first: { pause: firstPause },
second: { pause: secondPause },
},
gsap: { ticker: { tick: tickerTick } },
});
try {
const result = fn(time);
expect(firstPause).toHaveBeenCalledWith(2.5);
expect(secondPause).toHaveBeenCalledWith(2.5);
expect(tickerTick).toHaveBeenCalled();
return result;
} finally {
vi.stubGlobal("window", previousWindow);
}
});
await expect(seekThumbnailPreview({ evaluate }, 2.5)).resolves.toBe("timelines");
});
});