Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
0709343
fix: avoid null currentTarget in panel resize pointerup cleanup
4isper Aug 30, 2026
2b927ff
fix: honor mediaFit for video in live preview
4isper Aug 30, 2026
f97f18c
fix(export): prevent blank leading frames in video export
4isper Aug 30, 2026
126acd3
fix(export): remove blank head from video exports and speed up MP4 en…
4isper Aug 30, 2026
be0b0cb
feat: support multi-file media upload distributed across empty devices
4isper Aug 30, 2026
a372e2c
fix(editor): stabilize bulk actions layer selector
4isper Aug 30, 2026
2d32fb0
test(export/editor): raise coverage for export modules and editor com…
4isper Aug 30, 2026
46062d5
fix(spin-render): promote LRU cache entries on read
4isper Aug 30, 2026
9a124af
chore(deps): apply in-range dependency updates
4isper Aug 30, 2026
bad5d59
fix(i18n): translate uploadMediaShortHint across 15 locales and repai…
4isper Aug 30, 2026
2486d61
test(render): cover mockupRenderWorker message handler
4isper Aug 30, 2026
d1356c5
docs: sync README and AGENTS with spin API, shortcut rebinding and cu…
4isper Aug 30, 2026
b20bfbf
chore: use vitest.config.mts to silence ESM/CJS config warning
4isper Aug 30, 2026
6f8616f
feat: publish hreflang alternates and canonical URL in page metadata
4isper Aug 30, 2026
073b19c
fix: repair 26 bugs across exports, renderers, state and components
4isper Aug 31, 2026
645c7bf
fix: repair 27 high and medium bugs across exports, editor UX, state …
4isper Aug 31, 2026
4345c17
feat(ux): editor UX pass 1-10 - reset clears canvas, mobile toolbar c…
4isper Sep 1, 2026
cf470a1
fix: pass lhci accessibility + disable advisory insights
4isper Sep 1, 2026
f989141
chore: refresh og image after frame material variants
4isper Sep 1, 2026
2f0a236
test(vrt): refresh baselines for current renderer
4isper Sep 1, 2026
01def7a
feat: realistic screen chrome with per-frame specs and notifications
4isper Sep 2, 2026
6100ea0
feat: editable Android home screen, custom grid/dock icons, widgets a…
4isper Sep 2, 2026
f3906e7
fix(ui): repair broken design tokens and unify color swatch styling
4isper Sep 2, 2026
4a98dac
refactor(ui): enforce design-token consistency across styles and comp…
4isper Sep 2, 2026
4ff310f
fix(ui): normalize form controls, buttons and background section
4isper Sep 2, 2026
cec8714
fix(ui): cover remaining unstyled form primitives and fallback UI
4isper Sep 2, 2026
8d53bce
feat(zoom): make layer zoom scale the media inside the screen, not th…
4isper Sep 2, 2026
66b2f9a
fix(editor): unpark shortcuts after registry-backed dialogs and uncli…
4isper Sep 2, 2026
4e5437e
fix(deps): override qs to 6.16.0 to resolve two moderate advisories
4isper Sep 3, 2026
802111e
feat(ui): mobile/tablet interaction fixes for the mockup editor
4isper Sep 3, 2026
d7081ad
fix(a11y): enlarge preview zoom buttons to meet 24px target-size minimum
4isper Sep 3, 2026
1f41c05
test(vrt): refresh baselines for 24px zoom buttons
4isper Sep 3, 2026
4098dfa
fix(toolbar): stabilize single-row layout with dedicated status slot
4isper Sep 3, 2026
f24653e
fix(export): hints, size summary and closable dialog during busy exports
4isper Sep 3, 2026
d94afda
fix(single-frame): make legacy one-device scene movable and annotatio…
4isper Sep 3, 2026
0a50426
fix(tour): keep onboarding card on-screen and guide mobile sheets
4isper Sep 3, 2026
dae6ec9
feat(editor): dock preview controls into a single bottom bar
4isper Sep 4, 2026
650a385
fix(editor): compact icon tabs for narrow right panel
4isper Sep 4, 2026
2b5fb74
test(vrt): refresh baselines for dock bar, icon tabs and toolbar UI
4isper Sep 4, 2026
434e910
chore(i18n): sync canvasControls fallback across locales
4isper Sep 4, 2026
576ff95
feat(editor): group control panel sections by task
4isper Sep 4, 2026
6e44057
test(vrt): refresh baselines for grouped control panel
4isper Sep 4, 2026
2e1a09c
fix(a11y): announce tab count badges to screen readers
4isper Sep 4, 2026
be93b7f
feat(editor): roving arrow-key navigation for the preview dock
4isper Sep 4, 2026
cbfcd20
feat(editor): fit frames to canvas after aspect changes
4isper Sep 4, 2026
7cf4117
fix(mobile): peek-height bottom sheets and ultra-narrow dock
4isper Sep 4, 2026
7fa2ab1
test(vrt): refresh baselines for fit-to-canvas, sheets and dock UI
4isper Sep 4, 2026
5e41104
fix(editor): drive dock compactness from panel container queries
4isper Sep 4, 2026
27b26ad
fix(mobile): fill tall viewports, keep scroll on short landscape
4isper Sep 4, 2026
71e6267
test(vrt): refresh mobile viewport baseline
4isper Sep 4, 2026
ddf99fb
fix(projects): move secondary row actions into an overflow menu
4isper Sep 4, 2026
e98a8f1
fix(css): stop horizontal overflow outbreaks in side panels
4isper Sep 4, 2026
4ab844b
test(vrt): refresh baselines for wrapped arrange rows
4isper Sep 4, 2026
2d742e2
fix(video): deliver deterministic frames to the MediaRecorder capture
4isper Sep 4, 2026
48b919a
test(e2e): realign specs with deliberate renderer and dock changes
4isper Sep 4, 2026
ba97383
test+fix(editor): toast escapes the collapsed toolbar slot; unit pari…
4isper Sep 4, 2026
b9674b9
ci: pin macOS runners, tolerant VRT/OG thresholds, prod-parity checks
4isper Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ jobs:

visual-regression:
name: Visual regression (VRT)
# VRT baselines are darwin-specific (font rendering); macOS runners are
# also slower/more contended, so keep the flaky editor suite on ubuntu.
runs-on: macos-latest
# VRT baselines are darwin-specific (font rendering); pin the major macOS
# version so a `macos-latest` migration doesn't silently invalidate every
# baseline. When bumping this, regenerate snapshots and expect one red run.
runs-on: macos-26
needs: [lint-and-typecheck, unit-tests]
steps:
- name: Checkout
Expand Down Expand Up @@ -156,8 +157,10 @@ jobs:
og-image:
name: OG image freshness
# macOS to match the font rendering of the committed public/og-image.png
# (scripts/generate-og.mjs screenshots with system fonts).
runs-on: macos-latest
# (scripts/generate-og.mjs screenshots with system fonts). Pinned for the
# same reason as the VRT job: system font rasterization drifts between
# major macOS versions.
runs-on: macos-26
needs: [lint-and-typecheck, unit-tests]
steps:
- name: Checkout
Expand All @@ -175,11 +178,16 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install chromium

- name: Save committed OG image
run: git show HEAD:public/og-image.png > /tmp/og-committed.png

- name: Regenerate public/og-image.png
run: node scripts/generate-og.mjs

- name: Fail if the committed OG image is stale
run: git diff --exit-code -- public/og-image.png
# Byte-exact `git diff` fails on font-rasterization noise between
# macOS minor versions; allow a small pixel-difference ratio instead.
run: node scripts/check-og.mjs /tmp/og-committed.png public/og-image.png

- name: Upload refreshed image on failure
if: failure()
Expand Down
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Free browser-based mockup editor (Next.js 16 + React 19 + TypeScript). Media is

### Path alias

`@/` maps to the project root (configured in `tsconfig.json` and `vitest.config.ts`). Always use `@/lib/...` imports, never relative paths above one level.
`@/` maps to the project root (configured in `tsconfig.json` and `vitest.config.mts`). Always use `@/lib/...` imports, never relative paths above one level.

### Generated files

Expand Down Expand Up @@ -90,8 +90,8 @@ Scene serialized as deflate-compressed base64url JSON (`CompressionStream` with
## Project structure

```
app/ Next.js router ([locale]/layout, page, error boundary)
components/editor/ 50 React components (EditorShell, ControlPanel, PreviewCanvas, ...)
app/ Next.js router ([locale]/layout, page) + api/spin route, og/spin-render harness pages
components/editor/ 52 React components (EditorShell, ControlPanel, PreviewCanvas, ...)
i18n/ Locale request config + canonical locale list (locales.ts)
lib/state/ Zustand stores, normalization, id generators, share URL, projects, relativeTime
lib/render/ Frame specs, CSS/canvas geometry, video timeline, canvas drawing
Expand All @@ -100,19 +100,21 @@ lib/commands/ Command-palette command factories, search/matching (per featur
lib/media/ File loading, demo image, palette extraction, IndexedDB media store, AI background removal
lib/presets/ Background swatches, scene style presets
lib/hooks/ Client hooks (commands, focus trap, frame transform, scene palette)
lib/shortcuts/ Single source of truth for keyboard shortcuts + rebinding store
lib/server/ Server-side PNG renderer for the spin API (headless Chromium)
lib/types/ TypeScript interfaces
messages/ 57 locale JSON files (en.json is the source of truth)
public/devices/ SVG device skins for overlay frames
proxy.ts Locale middleware for next-intl
tests/unit/ Vitest (mirrors lib/ structure)
tests/components/ Vitest + Testing Library (component tests)
tests/e2e/ Playwright (editor, UX flows, visual regression, preview/export parity)
tests/e2e/ Playwright (editor, UX flows, visual regression, preview/export parity, spin render)
```

## Testing

- `npm run test` — Vitest (1,765 tests, 127 files)
- `npm run test:e2e` — Playwright (120 tests: 75 in editor.spec.ts, 8 in ux.spec.ts, 9 visual regression, 2 a11y, 7 mobile, 19 preview/export parity; requires browser install, needs dev server)
- `npm run test` — Vitest (2,333 tests, 154 files)
- `npm run test:e2e` — Playwright (128 tests: 75 in editor.spec.ts, 11 in ux.spec.ts, 12 a11y, 9 visual regression, 7 mobile, 4 preview/export parity, 4 spin render, 2 export shadow, 2 i18n, 2 tablet; requires browser install, needs dev server)
- `npm run test:vrt` — Visual regression tests via Playwright
- `npm run test:vrt:update` — Update visual regression baselines
- `npm run test:lhci` — Lighthouse CI (requires built app + server)
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ tests/e2e/ Playwright e2e tests (editor, UX, visual regression,
### Path alias

The project uses `@/` as an alias for the project root (configured in
`tsconfig.json` and `vitest.config.ts`). Import like:
`tsconfig.json` and `vitest.config.mts`). Import like:

```ts
import { normalizeScene } from "@/lib/state/normalizeScene";
Expand Down
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Free browser-based mockup editor. No auth, no paywall, no subscriptions — and 100% client-side: your media never leaves the device.
Free browser-based mockup editor. No auth, no paywall, no subscriptions — and the editor is 100% client-side: your media never leaves the device (an optional server [Spin API](#spin-api) can render PNGs for you — see below).

**Try it live: [mocksy-ashen.vercel.app](https://mocksy-ashen.vercel.app)**

Expand Down Expand Up @@ -48,6 +48,7 @@ npm start

- Multi-panel layout: controls, canvas preview, layers, annotations, templates, projects
- Undo/redo (`⌘Z` / `⇧⌘Z`) with keyboard shortcuts; rapid slider drags coalesce into one step
- Rebindable keyboard shortcuts — click-to-record in the Shortcuts dialog, overrides persist and remap instantly
- Command palette (`⌘K`) with actions grouped by category and match highlighting
- Right-click context menus for canvas, frames, annotations and layers
- "Surprise me" action — randomizes style, background, shadow and corners; media stays untouched
Expand All @@ -63,7 +64,7 @@ npm start
- Auto layout presets for multi-frame scenes: grid, fan, cascade, masonry, stack
- Align (left/center/right/top/middle/bottom) and distribute buttons for frame instances
- Smart guides snap frame instances to canvas edges and sibling frames while dragging
- Drop a file onto a specific device in multi-frame mode — it targets that device's layer
- Drop a file onto a specific device in multi-frame mode — it targets that device's layer; dropping a batch distributes the files across empty devices
- View navigation over the canvas: smooth wheel zoom anchored at the cursor (25–400%, snaps to familiar stops), continuous slider with −/+ steps, Space+drag / middle-button panning, pinch on touch, double-click to reset — pure view state, exports are unaffected
- `⌘C`/`⌘V` copies and duplicates the selected annotation or frame instance (media paste still wins when the OS clipboard holds files)
- 3D tilt (`tiltX`/`tiltY`, ±25°) kept in sync across CSS preview, canvas/SVG exports and video
Expand Down Expand Up @@ -201,6 +202,15 @@ npm start
- Share dialog renders a QR code for the URL
- Reset confirmation modal before clearing

### Spin API

`POST /api/spin` — a public, CORS-open endpoint that rolls a seeded "spin" roulette and returns a ready-to-embed mockup as JSON or a server-rendered PNG.

- Request body (all optional): `pack` — weighted rules (`frames`, `backgrounds`, `styles`, a `tilt`/`shadow` range, `borderRadius` pool, `aspectRatio` list, `watermark`) — plus `media` (image/video `data:` URL, ≤32MB) with `mediaType`, `seed` for deterministic output, and `format: "json" | "png"` (also selectable via `?format=`), `scale` (1–4) and `width`/`height`
- JSON response `{ scene, seed }`: a fully normalized `EditorScene` you can load straight into the editor
- PNG response: rendered server-side by headless Chromium (playwright-core) through the `[locale]/spin-render` harness, which runs the exact same pipeline as the client export — API renders match what the editor would produce; results are LRU-cached per (scene, size), and the endpoint falls back to JSON with `image: null` when the renderer is unavailable
- Deterministic: the same pack + seed always produces the same scene

---

## Stack
Expand All @@ -214,18 +224,18 @@ npm start
| Video | @ffmpeg/ffmpeg (client-side WebM→MP4 / GIF / WebP) |
| AI | @huggingface/transformers (in-browser background removal) |
| i18n | next-intl (57 locales) |
| Unit tests | Vitest (1,765 tests, 127 files) |
| E2E tests | Playwright (120 tests: 75 editor, 8 UX flows, 9 visual regression, 2 a11y, 7 mobile, 19 preview/export parity) |
| Unit tests | Vitest (2,333 tests, 154 files) |
| E2E tests | Playwright (128 tests: 75 editor, 11 UX flows, 12 a11y, 9 visual regression, 7 mobile, 4 preview/export parity, 4 spin render, 2 export shadow, 2 i18n, 2 tablet) |
| Language | TypeScript (strict) |

---

## Project layout

```
app/ Next.js router ([locale]/layout, page, error boundary)
app/ Next.js router ([locale]/layout, page, error boundary, og/spin-render harness pages) + api/spin route
proxy.ts Locale detection middleware for next-intl
components/editor/ 50 React components
components/editor/ 52 React components
EditorShell Main orchestrator with keyboard shortcuts
ControlPanel Frame, style, media, background, watermark controls
sections/ Control panel sections (frame, media, position, filters, animation)
Expand Down Expand Up @@ -266,7 +276,8 @@ lib/
media/ File loading, demo image, palette extraction, AI background removal
presets/ Background swatches, scene style presets
hooks/ Client hooks (commands, shortcuts, frame transform, palette)
search/ Frame search utilities
shortcuts/ Single source of truth for keyboard shortcuts + rebinding store
server/ Server-side PNG renderer for the spin API (headless Chromium)
types/ TypeScript interfaces
messages/ 57 locale JSON files (en.json is the source of truth)
public/devices/ SVG device skins for overlay frames
Expand All @@ -282,7 +293,7 @@ tests/

```bash
npm run typecheck # TypeScript strict check
npm run test # Vitest (1,765 tests, 127 files)
npm run test # Vitest (2,333 tests, 154 files)
npm run test:coverage # Unit tests with coverage report
npm run test:e2e # Playwright (requires browser install)
npm run test:vrt # Visual regression tests
Expand All @@ -299,7 +310,7 @@ npm run lint # ESLint (Next.js core-web-vitals)

## Keyboard shortcuts

Press `?` in the editor (or click the Shortcuts button) for the in-app cheat sheet. `⌘` is Cmd on macOS, Ctrl on Windows/Linux.
Press `?` in the editor (or click the Shortcuts button) for the in-app cheat sheet. `⌘` is Cmd on macOS, Ctrl on Windows/Linux. Most shortcuts are rebindable — click the pencil icon on a row and press the new combo to record it; overrides persist per browser and remap instantly.

| Shortcut | Action |
|----------|--------|
Expand All @@ -314,6 +325,11 @@ Press `?` in the editor (or click the Shortcuts button) for the in-app cheat she
| `⌘E` / `Ctrl+E` | Export PNG |
| `⇧⌘C` / `Ctrl+⇧C` | Copy PNG to clipboard |
| `⌘C` / `Ctrl+C` | Copy selected annotation / frame instance |
| `⇧⌘T` / `Ctrl+⇧T` | Add text annotation |
| `⇧⌘I` / `Ctrl+⇧I` | Add arrow annotation |
| `⇧⌘R` / `Ctrl+⇧R` | Add rectangle annotation |
| `⇧⌘O` / `Ctrl+⇧O` | Add circle annotation |
| `⇧⌘B` / `Ctrl+⇧B` | Add blur-region annotation |
| `⇧⌘E` / `Ctrl+⇧E` | Export MP4 |
| `⇧⌘G` / `Ctrl+⇧G` | Export GIF |
| `⇧⌘W` / `Ctrl+⇧W` | Export WebM |
Expand All @@ -326,6 +342,7 @@ Press `?` in the editor (or click the Shortcuts button) for the in-app cheat she
| `⌘D` / `Ctrl+D` | Duplicate active layer |
| `⌘↑` / `⌘↓` | Move active layer up/down |
| `⌘[` / `⌘]` | Select previous / next layer |
| `⌘1` / `⌘2` / `⌘3` | Switch to the Layers / Annotations / History panel |
| `F` | Toggle full-screen preview |
| `Esc` | Exit full-screen preview |
| `R` | Reset to defaults |
Expand Down
14 changes: 10 additions & 4 deletions app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,28 @@ import { PwaRegister } from "@/components/editor/PwaRegister";
import { SkipLink } from "@/components/editor/SkipLink";
import { ErrorBoundary, LocalizedErrorBoundary } from "@/components/editor/ErrorBoundary";
import { isRtlLocale } from "@/i18n/request";
import { isValidLocale } from "@/i18n/locales";
import { isValidLocale, locales } from "@/i18n/locales";
import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "@/lib/state/ogScene";

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const resolvedLocale = isValidLocale(locale) ? locale : "en";
const messages = await getMessages({ locale: resolvedLocale });
const t = messages.metadata;
const errors = messages.errors;
const title = t?.title ?? "Mocksy — Free mockup editor";
const description = t?.description ?? "Create mockups, animations and exports without subscriptions.";
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// Mirror the hreflang map from sitemap so every localized route advertises
// its siblings to search engines. The editor is a single route in [locale].
const languages = Object.fromEntries(locales.map((locale) => [locale, `${base}/${locale}`]));
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"),
metadataBase: new URL(base),
title,
description,
alternates: {
canonical: `${base}/${resolvedLocale}`,
languages
},
openGraph: {
title,
description,
Expand Down Expand Up @@ -62,7 +69,6 @@ export default async function LocaleLayout({
const { locale } = await params;
const resolvedLocale = isValidLocale(locale) ? locale : "en";
const messages = await getMessages({ locale: resolvedLocale });
const errors = messages.errors;

return (
<html lang={resolvedLocale} dir={isRtlLocale(resolvedLocale) ? "rtl" : "ltr"} suppressHydrationWarning>
Expand Down
6 changes: 5 additions & 1 deletion app/[locale]/og/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ export default function OgImagePage() {
<div style={{ ...css.frame, width: "100%", height: "100%", position: "relative" }}>
<FrameContent
css={css}
media={<img src={layer?.mediaUrl ?? DEMO_MEDIA_URL} alt="" style={css.mediaStyle} />}
media={
<div style={css.mediaSlotStyle}>
<img src={layer?.mediaUrl ?? DEMO_MEDIA_URL} alt="" style={css.mediaStyle} />
</div>
}
/>
</div>
</div>
Expand Down
14 changes: 5 additions & 9 deletions app/[locale]/spin-render/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,11 @@ export default function SpinRenderPage() {
if (!root) return { error: "render container missing" };

root.replaceChildren();
const active =
req.scene.layers.find((l) => l.id === req.scene.activeLayerId) ?? req.scene.layers[0];
if (active && active.mediaUrl && !active.hidden && active.mediaType !== "video") {
const img = document.createElement("img");
img.src = active.mediaUrl;
img.dataset.layerMedia = active.id;
img.decoding = "async";
root.appendChild(img);
}
// No manual DOM seeding needed: the export pipeline (fixed
// renderSceneToImageBlob) loads every visible layer's media itself —
// images via loadImage and video layers via loadVideoFrame (poster
// frame), directly from the scene's data URLs. The old <img>-only
// seeding silently rendered video scenes with an empty device screen.

const blob = await renderSceneToImageBlob(
req.scene,
Expand Down
73 changes: 62 additions & 11 deletions app/api/spin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,48 @@ import { initialScene } from "@/lib/state/editorScene";
* caller can't OOM the renderer. */
const MAX_MEDIA_LENGTH = 32 * 1024 * 1024;

/** Total request-body budget (media + scene JSON overhead). Enforced while
* streaming the body in, so an oversized payload is rejected without being
* buffered — `request.json()` alone would read the whole body into memory
* before any validation runs. */
const MAX_BODY_BYTES = 40 * 1024 * 1024;

type ReadBodyResult = { ok: true; body: unknown } | { ok: false; reason: "too-large" | "invalid" };

async function readJsonBody(request: Request): Promise<ReadBodyResult> {
// Fast path: an honest Content-Length above the budget is rejected before
// a single byte is read.
const contentLength = Number(request.headers.get("content-length") ?? "");
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
return { ok: false, reason: "too-large" };
}
if (!request.body) return { ok: false, reason: "invalid" };
try {
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_BODY_BYTES) {
void reader.cancel().catch(() => undefined);
return { ok: false, reason: "too-large" };
}
chunks.push(value);
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return { ok: true, body: JSON.parse(new TextDecoder().decode(merged)) };
} catch {
return { ok: false, reason: "invalid" };
}
}

/** Media must arrive as a data URL so the scene stays self-contained (the
* editor renders data: URLs only; remote URLs would need server-side fetch
* and open an SSRF/validation hole). */
Expand All @@ -22,18 +64,27 @@ function parseSeed(value: unknown): number | null {
}

export async function POST(request: Request): Promise<Response> {
let body: SpinRequest;
try {
const parsed = (await request.json()) as unknown;
if (typeof parsed !== "object" || parsed === null) {
return NextResponse.json({ error: "Request body must be a JSON object." }, { status: 400 });
}
body = parsed as SpinRequest;
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
const read = await readJsonBody(request);
if (!read.ok) {
return NextResponse.json(
{ error: read.reason === "too-large" ? "Request body exceeds the 40MB limit." : "Invalid JSON body." },
{ status: read.reason === "too-large" ? 413 : 400 }
);
}
const parsed = read.body;
if (typeof parsed !== "object" || parsed === null) {
return NextResponse.json({ error: "Request body must be a JSON object." }, { status: 400 });
}
const body = parsed as SpinRequest;

const { pack, media, mediaType, seed, format, scale, width, height } = body;
const { pack, media, mediaType, format, scale, width, height } = body;
// A non-numeric seed previously flowed into the renderer's PRNG, which
// coerces every string to 0 — silently collapsing all bad seeds onto one
// result. Reject it instead of pretending the request succeeded.
const usedSeedInput = parseSeed(body.seed);
if (body.seed != null && usedSeedInput === null) {
return NextResponse.json({ error: "`seed` must be a finite number." }, { status: 400 });
}

if (media != null) {
if (typeof media !== "string" || media.length === 0) {
Expand All @@ -55,7 +106,7 @@ export async function POST(request: Request): Promise<Response> {
const { scene, seed: usedSeed } = spinScene(
structuredClone(initialScene),
pack ?? {},
seed ?? undefined
usedSeedInput ?? undefined
);

const framed = media != null ? applySpinMedia(scene, media, type) : scene;
Expand Down
Loading
Loading