206 lines
9.5 KiB
Markdown
206 lines
9.5 KiB
Markdown
# Prompt: add KK Card Gen to this Next.js app
|
||
|
||
Implement the **KK Card Gen** feature inside **this** existing Next.js app. Do not create a second Next app. Do not iframe the old app. Merge it as a route + APIs + libs.
|
||
|
||
Source of truth if you can read files from disk: `F:\Projects\Node\kk_card_gen`
|
||
If you cannot access that folder, reimplement from this spec exactly.
|
||
|
||
## Product
|
||
|
||
Personal tool to stamp sequential user IDs onto a local base card image and download a zip of PNGs.
|
||
|
||
User flow:
|
||
|
||
1. Pick a base image (PNG/JPG/WebP, max 10MB) from disk or drop it.
|
||
2. Configure ID: prefix, suffix, start number, pad length 0–6, count 1–1000 (default 1000).
|
||
3. Style text: preset font or uploaded TTF/OTF, size, color, bold, italic, left/center/right.
|
||
4. Optional drop shadow: color, opacity, blur, offset X/Y.
|
||
5. Optional outline: color, opacity, width.
|
||
6. Position: drag the sample ID on the preview, click to place, or nudge X/Y %.
|
||
7. Click Generate. Server generates PNGs in parallel, then zips them.
|
||
8. UI shows live progress: `Generating 12 / 1000` then `Zipping 12 / 1000`.
|
||
9. When done, show a **Download ids.zip** link. Do **not** fetch the zip as a blob in JS and auto-download. That hung on the server.
|
||
10. Filenames = sanitized ID + `.png` (example `KK0001.png`).
|
||
|
||
No header/marketing chrome. No helper text like “Drop a card image with an empty ID area…”. Empty preview only needs “Select a base photo”.
|
||
|
||
## Hosting constraints (must follow)
|
||
|
||
- Route handlers: `export const runtime = "nodejs"` (never Edge).
|
||
- Native module `@napi-rs/canvas` + Node `worker_threads`.
|
||
- Host must be a real Node server (VPS / `next start`). Vercel-style serverless is a poor fit.
|
||
- `maxDuration = 300` on generate.
|
||
- Zip files live on disk under `os.tmpdir()/kk-card-jobs/<uuid>/` for 15 minutes so any Node worker on the same machine can serve the download. Do **not** store jobs in an in-memory `Map`.
|
||
|
||
## Dependencies
|
||
|
||
```bash
|
||
npm install @napi-rs/canvas archiver
|
||
npm install -D @types/archiver
|
||
```
|
||
|
||
`next.config` must include:
|
||
|
||
```ts
|
||
serverExternalPackages: ["@napi-rs/canvas"]
|
||
```
|
||
|
||
Merge with existing config. Do not remove other settings.
|
||
|
||
## Files to add
|
||
|
||
Adapt `@/` aliases if this repo uses `src/`. Keep the **worker file name and cwd-relative path** unless you update `generate-pool.ts` to match.
|
||
|
||
```
|
||
app/cards/page.tsx # or another unused route; render <Editor />
|
||
app/cards/editor.tsx # "use client" editor UI
|
||
app/api/generate/route.ts
|
||
app/api/download/[jobId]/route.ts
|
||
lib/id-config.ts
|
||
lib/draw-id.ts
|
||
lib/fonts.ts
|
||
lib/progress.ts
|
||
lib/jobs.ts
|
||
lib/create-zip.ts
|
||
lib/generate-pool.ts
|
||
lib/register-fonts.ts # optional if only workers register fonts
|
||
workers/generate-id.cjs # MUST be a real file on disk at repo root
|
||
public/fonts/Inter-Regular.ttf
|
||
public/fonts/Inter-Bold.ttf
|
||
public/fonts/Roboto-Regular.ttf
|
||
public/fonts/Roboto-Bold.ttf
|
||
public/fonts/Oswald-Regular.ttf
|
||
public/fonts/Oswald-Bold.ttf
|
||
public/fonts/Montserrat-Regular.ttf
|
||
public/fonts/Montserrat-Bold.ttf
|
||
public/fonts/PlayfairDisplay-Regular.ttf
|
||
public/fonts/PlayfairDisplay-Bold.ttf
|
||
```
|
||
|
||
If this app already has `app/page.tsx` you care about, **do not overwrite it**. Put the editor on `/cards` (or `/kk-card-gen`).
|
||
|
||
Copy fonts from `F:\Projects\Node\kk_card_gen\public\fonts` if possible. Otherwise download Inter / Roboto / Oswald / Montserrat / Playfair Display regular+bold TTF (latin 400 + 700) into those exact filenames.
|
||
|
||
Add matching `@font-face` rules to the app’s global CSS so the **browser preview** uses the same files as the server. Families: `Inter`, `Roboto`, `Oswald`, `Montserrat`, `Playfair Display`.
|
||
|
||
## Shared drawing rules (preview MUST match export)
|
||
|
||
Both client canvas and worker canvas must use the same logic:
|
||
|
||
- Position: `x = xPercent/100 * width`, `y = yPercent/100 * height`
|
||
- `textBaseline = "alphabetic"` — **never** `"middle"` (browser vs Skia disagree; export sat too low)
|
||
- Measure `actualBoundingBoxAscent/Descent` (fallback `fontSize * 0.8` / `0.2`)
|
||
- Alphabetic Y for visual center: `baselineY = y + (ascent - descent) / 2`
|
||
- Font string: `` `${italic?} ${bold?700:400} ${fontSize}px "${fontFamily}"` ``
|
||
- Outline: `strokeText` first (`lineJoin: "round"`), then clear shadow, then `fillText`
|
||
- Shadow via `shadowColor/Blur/OffsetX/OffsetY` (hex + opacity → rgba)
|
||
- Font size is pixels at **source image resolution**. Preview canvas internal size = naturalWidth/Height; CSS scales it. Mouse coords: map from `getBoundingClientRect()` to canvas pixels, then to percents.
|
||
|
||
ID string:
|
||
|
||
```ts
|
||
`${prefix}${String(n).padStart(pad, "0")}${suffix}`
|
||
```
|
||
|
||
Filename: strip `/ \ : * ? " < > |`, trim, fallback `"id"`.
|
||
|
||
Limits: count 1–1000, pad 0–6, start 0–1_000_000, fontSize 8–400, image 10MB, font 5MB, hex colors `#rgb` / `#rrggbb` / `#rrggbbaa`.
|
||
|
||
Default config: prefix `KK`, start `1`, pad `4`, count `1000`, font `Inter`, size `48`, color `#111111`, align `center`, position 50/50, shadow/outline off.
|
||
|
||
Custom uploaded font family name: `CustomUpload` (FontFace in browser; `GlobalFonts.registerFromPath` in workers).
|
||
|
||
## Worker pool (required for speed)
|
||
|
||
Do **not** generate 1000 images on the Next.js request thread in a single loop.
|
||
|
||
`lib/generate-pool.ts`:
|
||
|
||
- Split tasks across `min(cpu, 8, taskCount)` workers
|
||
- Spawn with `createRequire(...).("node:worker_threads").Worker` — **do not** `import { Worker } from "node:worker_threads"` then `new Worker(path)`. Turbopack/webpack intercepts `new Worker()` and breaks (`__dirname is not defined` / missing worker module).
|
||
- Worker path: `path.join(process.cwd(), "workers", "generate-id.cjs")`
|
||
- Each worker gets `{ imagePath, fonts: [{path,family}], style, tasks: [{text,outPath}] }`
|
||
- Worker posts `{ type: "progress" }` after each PNG
|
||
- Parent aggregates and streams generate progress
|
||
|
||
`workers/generate-id.cjs` must be **CommonJS** (`require("@napi-rs/canvas")`). ESM workers broke canvas. Worker: register fonts, `loadImage`, one canvas, draw, `encode("png")`, `writeFile`.
|
||
|
||
## Generate API — `POST /api/generate`
|
||
|
||
`multipart/form-data`:
|
||
|
||
- `image` File
|
||
- `font` File (only if custom)
|
||
- `config` JSON string of `GenerateConfig`
|
||
|
||
Validation errors: JSON `{ error }` with 4xx.
|
||
|
||
Success: `Content-Type: application/x-ndjson`, `Cache-Control: no-store`, `X-Accel-Buffering: no`.
|
||
|
||
Stream one JSON object per line:
|
||
|
||
```json
|
||
{"phase":"generate","current":12,"total":1000}
|
||
{"phase":"zip","current":12,"total":1000}
|
||
{"phase":"done","id":"<uuid>"}
|
||
{"phase":"error","message":"..."}
|
||
```
|
||
|
||
Pipeline:
|
||
|
||
1. `createJobDir()` → `{ id, dir, pngDir, zipPath }` under `tmpdir()/kk-card-jobs/<uuid>/`
|
||
2. Write base image to disk; write custom font if any
|
||
3. Build file list of `{ text, name, path }`
|
||
4. `generatePngsInWorkers` + stream generate events
|
||
5. Zip with `archiver` `ZipArchive` (`new ZipArchive({ zlib: { level: 6 } })`). `@types/archiver` has no default factory; import `{ ZipArchive } from "archiver"`.
|
||
6. Stream zip progress via archiver `progress`
|
||
7. `{"phase":"done","id"}` — zip stays on disk. Do not stream the zip in this response.
|
||
|
||
## Download API — `GET /api/download/[jobId]`
|
||
|
||
- Validate UUID (prevent path traversal)
|
||
- `stat` `ids.zip`; 404 if missing/empty
|
||
- Stream file with `Content-Type: application/zip`, `Content-Length`, `Content-Disposition: attachment; filename="ids.zip"`
|
||
- Do **not** delete the zip on first download (link should work if they click twice). TTL sweep on new jobs is enough.
|
||
- Next 16 params: `const { jobId } = await context.params`
|
||
|
||
## Editor UI
|
||
|
||
Client component. Two columns: preview left, controls right (base image, user ID, text style, drop shadow, outline, position, progress, download link, generate button).
|
||
|
||
Preview: canvas at natural image size, overlay sample ID (`prefix+pad(start)+suffix`), dashed selection box, drag/click-to-place.
|
||
|
||
Generate:
|
||
|
||
- POST FormData to `/api/generate`
|
||
- Read body as NDJSON (buffer split on `\n`)
|
||
- Update progress bar + button label (`Generating 12 / 1000`, `Zipping 12 / 1000`)
|
||
- On `done`, set `downloadHref = /api/download/${id}` and show `<a href={...} download="ids.zip">Download ids.zip</a>`
|
||
- Never `response.blob()` the zip
|
||
|
||
Match existing app look if there is a design system. If the app already has Tailwind, reuse it (dark zinc/indigo is fine). If not, add minimal CSS; do not force a full Tailwind install unless the app already has it.
|
||
|
||
Do not add a marketing header.
|
||
|
||
## Pitfalls
|
||
|
||
1. `textBaseline: "middle"` → generated text sits lower than preview.
|
||
2. Auto-download via `fetch` + `blob()` → hangs on server. Use a link.
|
||
3. In-memory job map → download 404/hang with multiple Node workers. Use disk + UUID.
|
||
4. `new Worker()` imported from `worker_threads` → Next bundles it and breaks. Use `createRequire` + `threads.Worker`.
|
||
5. Worker must be `.cjs` on disk at `workers/generate-id.cjs` relative to `process.cwd()` (the Next app root).
|
||
6. Preview fonts need `@font-face`; server fonts need TTF files under `public/fonts` with the names in `lib/fonts.ts`.
|
||
7. Do not put generate/download on Edge.
|
||
8. Archiver v8: `import { ZipArchive } from "archiver"` then `new ZipArchive(...)`.
|
||
|
||
## Verify before done
|
||
|
||
1. Typecheck / lint the new files.
|
||
2. Open the new route in the browser. No leftover header copy.
|
||
3. Upload a card, place the ID, generate **12** images. Confirm progress then a working **Download ids.zip** link. Unzip: `KK0001.png` … `KK0012.png`.
|
||
4. Confirm preview position matches a generated PNG (not shifted down).
|
||
5. Toggle shadow + outline and generate 1 image; both appear.
|
||
6. Confirm `/api/generate` is not Edge.
|
||
|
||
If `F:\Projects\Node\kk_card_gen` is readable, **copy those files and adapt imports/routes** rather than rewriting from scratch. Preserve worker + draw behavior.
|