9.5 KiB
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:
- Pick a base image (PNG/JPG/WebP, max 10MB) from disk or drop it.
- Configure ID: prefix, suffix, start number, pad length 0–6, count 1–1000 (default 1000).
- Style text: preset font or uploaded TTF/OTF, size, color, bold, italic, left/center/right.
- Optional drop shadow: color, opacity, blur, offset X/Y.
- Optional outline: color, opacity, width.
- Position: drag the sample ID on the preview, click to place, or nudge X/Y %.
- Click Generate. Server generates PNGs in parallel, then zips them.
- UI shows live progress:
Generating 12 / 1000thenZipping 12 / 1000. - 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.
- Filenames = sanitized ID +
.png(exampleKK0001.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+ Nodeworker_threads. - Host must be a real Node server (VPS /
next start). Vercel-style serverless is a poor fit. maxDuration = 300on 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-memoryMap.
Dependencies
npm install @napi-rs/canvas archiver
npm install -D @types/archiver
next.config must include:
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(fallbackfontSize * 0.8/0.2) - Alphabetic Y for visual center:
baselineY = y + (ascent - descent) / 2 - Font string:
`${italic?} ${bold?700:400} ${fontSize}px "${fontFamily}"` - Outline:
strokeTextfirst (lineJoin: "round"), then clear shadow, thenfillText - 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:
`${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 notimport { Worker } from "node:worker_threads"thennew Worker(path). Turbopack/webpack interceptsnew 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:
imageFilefontFile (only if custom)configJSON string ofGenerateConfig
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:
{"phase":"generate","current":12,"total":1000}
{"phase":"zip","current":12,"total":1000}
{"phase":"done","id":"<uuid>"}
{"phase":"error","message":"..."}
Pipeline:
createJobDir()→{ id, dir, pngDir, zipPath }undertmpdir()/kk-card-jobs/<uuid>/- Write base image to disk; write custom font if any
- Build file list of
{ text, name, path } generatePngsInWorkers+ stream generate events- Zip with
archiverZipArchive(new ZipArchive({ zlib: { level: 6 } })).@types/archiverhas no default factory; import{ ZipArchive } from "archiver". - Stream zip progress via archiver
progress {"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)
statids.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, setdownloadHref = /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
textBaseline: "middle"→ generated text sits lower than preview.- Auto-download via
fetch+blob()→ hangs on server. Use a link. - In-memory job map → download 404/hang with multiple Node workers. Use disk + UUID.
new Worker()imported fromworker_threads→ Next bundles it and breaks. UsecreateRequire+threads.Worker.- Worker must be
.cjson disk atworkers/generate-id.cjsrelative toprocess.cwd()(the Next app root). - Preview fonts need
@font-face; server fonts need TTF files underpublic/fontswith the names inlib/fonts.ts. - Do not put generate/download on Edge.
- Archiver v8:
import { ZipArchive } from "archiver"thennew ZipArchive(...).
Verify before done
- Typecheck / lint the new files.
- Open the new route in the browser. No leftover header copy.
- Upload a card, place the ID, generate 12 images. Confirm progress then a working Download ids.zip link. Unzip:
KK0001.png…KK0012.png. - Confirm preview position matches a generated PNG (not shifted down).
- Toggle shadow + outline and generate 1 image; both appear.
- Confirm
/api/generateis 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.