# 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//` 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 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":""} {"phase":"error","message":"..."} ``` Pipeline: 1. `createJobDir()` → `{ id, dir, pngDir, zipPath }` under `tmpdir()/kk-card-jobs//` 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 `Download ids.zip` - 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.