founders card

This commit is contained in:
2026-09-14 13:23:14 +00:00
parent c5084170b8
commit 64759ce17d
57 changed files with 5013 additions and 188 deletions
+205
View File
@@ -0,0 +1,205 @@
# 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 06, count 11000 (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 apps 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 11000, pad 06, start 01_000_000, fontSize 8400, 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.