Skip to content

Local development ​

This page covers running HoloCard locally, which verification script to run after a change, and common problems. For the code layout see Architecture; for production deployment see Self-hosting.

Requirements ​

DependencyVersionNotes
Node.js22.18 or laterThe server uses node:sqlite; verify-gyro and verify-config import .ts sources directly and rely on type stripping, which Node enables by default from 22.18
pnpm10After corepack enable, the version in the packageManager field of package.json is installed automatically
ffmpeganyOnly needed by pnpm capture
ImageMagickanyOnly needed by film-pack.mjs to assemble the contact sheet; without it only single frames are written

There is no unit test framework and no lint or formatter configuration. Correctness is checked by scripts/verify-*.mjs. Each script runs on its own, and its header comment states what it checks and what it needs.

First-time setup ​

bash
pnpm install

The warning Ignored build scripts: onnxruntime-node, protobufjs can be ignored: the CPU builds of onnxruntime-node for each platform ship inside the package.

Model weights ​

The layering service reads weights from .models/ (not committed).

WeightsSizeRequiredBehaviour when missing
Depth Anything V2-Small (onnx-community/depth-anything-v2-small)27 MBYesThe layering service cannot process images
BiRefNet_lite (onnx-community/BiRefNet_lite-ONNX)214 MBNoNo subject matting; layers are cut by depth only. Matting one image peaks at about 7 GB of memory
NudeNet 320n12 MBNoNudity detection is skipped; /v1 cards fail with moderation_unavailable, so it is required for verify-api

Download the depth model:

bash
D=.models/onnx-community/depth-anything-v2-small
mkdir -p $D/onnx
B=https://huggingface.co/onnx-community/depth-anything-v2-small/resolve/main
curl -sL $B/config.json -o $D/config.json
curl -sL $B/preprocessor_config.json -o $D/preprocessor_config.json
curl -sL $B/onnx/model_quantized.onnx -o $D/onnx/model_quantized.onnx

Download commands for the other two models are in Self-hosting; replace /srv/holocard-models with .models.

Day-to-day development ​

Run in two terminals:

bash
pnpm dev          # frontend, http://localhost:5273
bash
pnpm dev:server   # layering service on port 8791
  • The frontend hot-reloads. Vite proxies /api to HOLOCARD_API (default http://127.0.0.1:8791). The port is fixed at 5273 (strictPort); if it is taken, Vite exits with an error.
  • pnpm dev:server builds and then starts the server; restart it after changing anything under server/. It loads local directory settings with node --env-file=server/dev.env; values already set in the environment take precedence.
  • Without the layering service, the Vite proxy returns 502 and the frontend falls back to in-browser layering, which first downloads the depth model (about 50 MB fp16 on WebGPU, about 25 MB q8 on WASM).
  • Locally generated cards and the database live under out/; delete it to start clean.
  • pnpm dev:server does not serve the frontend (HOLOCARD_WEB_DIR is empty), so [preview] 渲染失败 in its log is expected. Use pnpm og to work on share images and exported animations locally.

server/dev.env:

VariableValuePurpose
HOLOCARD_MODEL_DIR.modelsModel weights
HOLOCARD_OUT_DIRout/layersCard output
HOLOCARD_DBout/data/holocard.dbSQLite database

package.json scripts ​

CommandPurpose
pnpm devFrontend dev server (Vite, port 5273)
pnpm dev:serverBuild and start the local layering service (port 8791, directories from server/dev.env)
pnpm typechecktsc --noEmit over src/, server/, scripts/og-preview.ts and the Vite configs
pnpm buildRuns pearl-keyframes.mjs --check on the generated file, then type-checks and builds the frontend (dist/) and this documentation site (dist/docs)
pnpm previewPreview the dist/ build
pnpm build:serverType-check and build the server into dist-server/
pnpm start:serverRun dist-server/holocard-server.mjs with production defaults (directories under /srv)
pnpm build:playerBuild the npm package @holocard/player into packages/player/dist
pnpm ogPreview share images and exported animations, see below
pnpm captureRecord a demo GIF, see Other scripts
pnpm docs:devPreview this documentation site (VitePress)

Share images and exported animations ​

pnpm og renders through the same path as production: the frontend is built by Vite on the fly and screenshots call the server's renderPreview directly. Layering results are cached in .og-cache/ by image content hash, so style changes do not re-run inference. Requires the weights in .models/.

bash
pnpm og                    # render scripts/fixtures/og-test.jpg to out/og-preview.jpg and open it
pnpm og path/to/image      # use another image
pnpm og --watch            # watch src/ and re-render on save
pnpm og --pose 78,22       # pose (pointer position on the card, in percent)
pnpm og --size 1280x640    # size (GitHub social preview is 1280×640)
pnpm og --lang en          # caption on the right in English (en / ja)
pnpm og --out path --png --no-open
pnpm og --export gif       # exported animation: gif / motion / apng, written to out/export/

Which verification script to run ​

Run pnpm typecheck first and pnpm build before committing. Then run the scripts that match the change:

ChangedCommandPrerequisites
Card strip, card pack, panel status line, upload area (deck.ts, pack.ts, main.ts and related styles)node scripts/verify-pack.mjs [--url …] [--out /tmp/verify-pack]HOLOCARD_API=http://127.0.0.1:9 pnpm dev (the script intercepts every /api request in the browser; pointing the backend at a dead address prevents accidental real calls)
Pack-opening animation (frame by frame, pack-gl.ts, deck.ts)node scripts/film-pack.mjs [--url …] [--out /tmp/film-pack]Same as above; ImageMagick for the contact sheet
Pearl background (gradients in style.css, configuration in pearl-keyframes.mjs)node scripts/pearl-keyframes.mjs to regenerate src/demo/pearl-drift.css, then node scripts/verify-pearl.mjs [--browser chromium|webkit]pnpm dev
When to fall back to in-browser layering (src/demo/api.ts)pnpm build && node scripts/verify-fallback.mjsNone; the script runs its own fake server
Gyroscope (src/renderer/gyro.ts)node scripts/verify-gyro.mjsNone
Author config validation (src/format/config.ts)node scripts/verify-config.mjsNone
Performance beacon (src/demo/perf.ts, server/store/perf.ts)node scripts/verify-perf.mjs [--url …] [--browser chromium|webkit|firefox]pnpm dev and pnpm dev:server
Public API /v1 (server/routes/v1.ts, server/account/apikeys.ts)HOLOCARD_DB=out/data/holocard.db HOLOCARD_OUT_DIR=out/layers node scripts/verify-api.mjs http://127.0.0.1:8791pnpm dev:server, with NudeNet weights in .models/
Sign-in, card ownership and claiming, API keys in the account panel (server/account/auth.ts and related routes)pnpm build:server && HOLOCARD_MODEL_DIR=.models node scripts/verify-auth.mjsDepth model weights. The script starts its own fake IH and a temporary server; no dev servers needed
Card mask endpoints and page (server/pipeline/cardmask.ts, server/routes/cardmask.ts, masks.html)pnpm build && pnpm build:server && node scripts/verify-cardmask.mjsMatting and text-detection weights, with the model directory in HOLOCARD_MODEL_DIR (default /srv/holocard-models). The script starts its own temporary server
Card mask quality (src/cardmask/)node scripts/cardmask-fetch.mjs to download the evaluation set, then pnpm cardmask:eval [--matte] [--only <prefix>] [--debug]; after changing the mask rules, also pnpm cardmask:eval --masksThe set is downloaded to .cardmask/data/ (card images are copyrighted by their publishers and not committed); --matte needs the matting weights, with the model directory in HOLOCARD_MODEL_DIR
Header and account panel UI (account-ui.ts)pnpm build && pnpm build:server && node scripts/verify-account.mjsNone. The script starts a temporary server with fake sign-in
Player <holo-card> (src/player/, packages/player/)pnpm build:player && node scripts/verify-player.mjs [screenshot dir]Chromium, see below
Pack sound effects (src/demo/sfx.ts)node scripts/render-sfx.mjs, then listen to /tmp/sfx/reel.wavpnpm dev
Look of share images and exported animationspnpm ogNone

What each script checks:

  • verify-pack: the full flow of upload → WebGL card pack → switching the card strip → tearing / tapping open → the new card's entrance; a shared card shows a pack on first open; the album opens straight to the grid (without sign-in, the header button opens the album directly); status line text; the upload area's accessible name and keyboard activation. Key moments are screenshotted to --out.
  • verify-pearl: freezes the current implementation (12 updates per second, plus will-change on Chromium) and the per-frame smooth version at the same moments and compares them pixel by pixel; fails above the threshold. Run WebKit separately.
  • verify-fallback: a fake server answers 404 (no backend → fall back), 502 / dropped connection (retry 3 times, then error without downloading the model) and 503 queue full (error immediately, no re-upload).
  • verify-gyro: tilting moves the pointer; holding still recenters within seconds; slow continuous rotation does not accumulate; reading jumps when crossing vertical; axes swap in landscape.
  • verify-config: valid configs merge unchanged and leave other fields alone; layer-count mismatches, unknown foil types, out-of-range values and attempts to change file names are rejected or ignored.
  • verify-perf: an idle page sends one beacon and the server stores it; malformed payloads are rejected.
  • verify-api: authentication, privacy (API cards do not exist on /api, share, export or delete routes), delivery and cache headers, quota, revocation, deletion during processing, web jobs jumping ahead, malformed request lines and oversized bodies. Each card is really layered, taking 30–60 seconds.
  • verify-cardmask: card mask endpoint error codes (405, 415, 404), upload through completion, the size and channels of the card, the six masks and the region overview, the six masks not overlapping (adding up to exactly 255 at every pixel), the layout and text roles in the status, download headers and the result directory; on the /masks page, the language, the preview, six masks and region overview appearing after an upload in the browser, the preview surviving a region change, and no page errors.
  • cardmask-eval: evaluates art-window detection and character matting on three kinds of cards, using IoU. Ground truth: the official foil masks of Pokémon regular holo cards (swholo, cosmos) — the window is the bounding box of the mask's white area, the character is the part of the window the mask leaves uncovered; for Magic and Yu-Gi-Oh!, the window is where the separate artwork image sits in the full card (multi-scale template matching; cards with a poor match are skipped). The set has two halves (cardmask-fetch.mjs --split holdout downloads the second); rules are tuned on dev only and holdout is scored separately. Writes a comparison image per card to .cardmask/out/ and a summary to report.json; --debug also saves every candidate rectangle with its features for comparing selection rules offline; --train trains the logistic regression that scores blocks as artwork in window detection and prints ART_MODEL; --masks builds the six masks, combines them per Pokémon rarity (adding masks together, see RECIPES in the script; where a rarity has several combinations the best one is taken, since whether the character, the border or effects in the artwork are foiled depends on the card design) and compares with the official masks (blurred first so etched textures are compared by coverage rather than pixel by pixel), summarising by dev / holdout, rarity and layout (window, panel, neither) in masks-report.json; the comparison images show the card, the official mask, the combination and the coloured region overview; matting and text-detection outputs are cached in .cardmask/out/cache/; --model and --crops are for comparing matting models, see "Card masks: comparing matting models" below.
  • verify-account: the header "Docs" link follows the interface language; the sign-in entry and local album in the account panel when signed out; requesting several keys when signed in, the new key shown only once, revoking one of them.
  • verify-auth: the sign-in flow (state cookie, code exchange, PKCE, single-use codes, next limited to same-site paths); ownership of cards made while signed in; claiming by token; other accounts cannot act on a card; sessions end on sign-out; account cards do not expire; requesting (up to 10 per account) and revoking API keys in the account panel, the quota shared across an account's keys, and unlimited admin accounts; the server refuses to start with fake sign-in on the production origin.
  • verify-player: both the directory URL and the manifest.json URL load and fire load; a bad URL fires error; host page CSS cannot reach inside the card and the component adds no styles to the host; the card tilts under the pointer. Set PLAYER_URL=https://cdn.jsdelivr.net/npm/@holocard/player@<version>/dist/holocard.js to check a published CDN build.
  • render-sfx: renders each sound effect to wav offline and checks it is not silent and peaks below 1.

Browsers and test data ​

  • verify-pack, verify-pearl and verify-fallback use the system Chrome on macOS, Edge on Windows and Playwright's bundled Chromium elsewhere; override with HOLOCARD_BROWSER_CHANNEL (chrome / msedge, empty for the bundled build).
  • film-pack, render-sfx and verify-perf only use Playwright's bundled browsers. Install once with pnpm exec playwright-core install chromium; verify-pearl --browser webkit needs webkit, verify-perf --browser firefox needs firefox.
  • verify-player and verify-account use Playwright's bundled Chromium by default; set HOLOCARD_BROWSER_CHANNEL to use an installed browser instead.
  • verify-pack and film-pack intercept every /api request inside the browser and never reach a backend. verify-api and verify-perf write test data into the target server's database; never run them against production.

Other scripts ​

ScriptPurposePrerequisites
pnpm capture --image photo [--out out/demo.gif]Record a demo GIF. Poses are set frame by frame with the renderer's setPose, so output is deterministic. --dump also exports each layer as PNG; --no-gif skips recordingSystem Edge, ffmpeg, pnpm dev
node scripts/probe-matte.mjs --image photo [--out out/matte.png]Run the matting model alone in the browser and save the alpha, to reproduce why BiRefNet cannot run in browsersSystem Edge, pnpm dev
node scripts/verify-live.mjs --image photo [--url …]Production smoke test with a fresh browser profile: upload and layering, recording cross-origin isolation, large downloads and layer countSystem Edge
HOLOCARD_DB=out/data/holocard.db node scripts/db.mjs [id | SQL | perf [days] | nsfw [threshold]]Read-only queries on the card database: recent cards and status counts, one card, any read-only SQL, performance beacon summary, suspected nudityNone
node scripts/apikey.mjs create <name> [daily limit] | list | revoke <id> | admin <account> | unadmin <account>Issue, list and revoke public API keys; set admin accounts exempt from quotas. A key is printed once on create; only its SHA-256 is storedDatabase from HOLOCARD_DB
node scripts/takedown.mjs [restore] <card id>Take a card down or restore it. Soft delete: files move to .removed/<id> under the output directory and the status becomes removedHOLOCARD_OUT_DIR, HOLOCARD_DB
node scripts/webp-layers.mjs [--apply | --cleanup]Convert older cards with PNG layers to WebP. Without flags it only lists; --apply writes WebP and atomically replaces the manifest; --cleanup deletes PNGs no longer referencedHOLOCARD_OUT_DIR
bash scripts/deploy.shManual release, see Self-hostingSSH access to the server

apikey.mjs, takedown.mjs, webp-layers.mjs and db.mjs default to production paths (/srv/...); point the environment variables at out/ when using them locally.

Blind comparison (scripts/blind/) ​

Compares the output of two server configurations (for example two matting models): each configuration makes one card per source image, a reviewer picks the better side pair by pair on a comparison page, and the answers are decoded into wins per configuration afterwards. The page source is lab/blind/index.html with src/lab/blind.ts; it uses the site's renderer and keeps cards A and B in the same pose.

bash
# 1. Run each configuration in turn (not in parallel: matting peaks at about 7 GB)
pnpm build && pnpm build:server
node scripts/blind/run-variant.mjs --in source-dir --out /tmp/exp/lite --port 8781
HOLOCARD_MATTE_MODEL=onnx-community/BiRefNet_512x512-ONNX HOLOCARD_MATTE_SIZE=512 HOLOCARD_MATTE_DTYPE=q8 \
  node scripts/blind/run-variant.mjs --in source-dir --out /tmp/exp/512 --port 8782

# 2. Pack: keep only pairs whose subjects differ a lot, plus 6 near-identical pairs as a calibration group
node scripts/blind/pack.mjs --variant lite=/tmp/exp/lite --variant 512=/tmp/exp/512 --out /tmp/exp/pack \
  --min-iou 0.95 --calibration 6 --focus "Subject edges: halos, ghosting, missing parts"

# 3. Give /tmp/exp/pack/page to the reviewer and collect the result string from "copy results"

# 4. Decode
node scripts/blind/score.mjs /tmp/exp/pack/key.json "1A 2= 3B 4X ..." --candidate 512
ScriptPurpose
run-variant.mjsStarts a temporary layering service at the lowest priority (nice 19), submits images one by one, and writes layers/, ids.tsv (source name → card id) and timing.json. The configuration under test is passed as environment variables; see server/config.ts for what the server reads (matting uses HOLOCARD_MATTE_*)
pack.mjsPairs cards, filters by --min-iou, randomises A/B, and writes the page page/ and the answer key key.json. The manifest generator field (which names the model) is removed from page data
score.mjsIn the result string A / B means that side is better, = similar, X both have problems, A! / B! the other side has an obvious defect. Preferences and defect rates are counted separately; the calibration group is reported on its own

Notes:

  • Source images and experiment directories usually contain users' photos. Keep them outside the repository (/tmp or similar), delete them after review, and never commit them.
  • Never send key.json along with the page; that breaks blinding.

Card masks: comparing matting models ​

Card masks matte the character with BiRefNet_lite. Larger models take about a minute per card on the server's 4-core ARM CPU with a peak of 7–8 GB of memory, so for comparisons the matting runs on a machine with a GPU and the evaluation stays local:

bash
pnpm cardmask:eval --crops                      # 1. locally: crop the region to matte on each card into .cardmask/out/crops/
python scripts/cardmask-matte.py lite matting   # 2. GPU machine: crops/ → out/<model>/; needs onnxruntime-gpu, numpy, Pillow
pnpm cardmask:eval --masks --model matting      # 3. locally: copy out/<model>/ to .cardmask/out/remote/<model>/, then evaluate

cardmask-matte.py supports lite, birefnet, hrsod, toonout and matting and downloads the ONNX weights from Hugging Face on first use (URLs in MODELS in the script, all MIT licensed). The weights can also go in .models/cmp/ for the evaluation script to run them locally, but that is slow.

Results from October 2026 (mean --masks IoU over 40 Pokémon cards, before the layout prior was added):

ModelMean IoUSize
BiRefNet_lite (in production)0.686214 MB
BiRefNet-matting0.693897 MB
BiRefNet0.636928 MB
BiRefNet-HRSOD0.603928 MB
ToonOut (anime fine-tune)0.530929 MB

BiRefNet and HRSOD often take the whole full-art card as the subject; ToonOut swaps foreground and background on cards with an art window; matting is only slightly better than lite at more than four times the size, so production keeps lite. On the 23 cards whose cached results are usable, even picking the best of the six models for every card only raises the mean IoU from 0.723 to 0.758: most rarity combinations are insensitive to the character matte, and the remaining error lies mainly in the other masks.

Troubleshooting ​

Port 5273 is already in use ​

The port is fixed and is not changed automatically. Find the process with lsof -nP -iTCP:5273 -sTCP:LISTEN and stop it; the same applies to 8791.

The layering service exits on start with mkdir '/srv/...' ​

pnpm start:server and dist-server/holocard-server.mjs use production directories under /srv. Use pnpm dev:server locally.

Scripts cannot connect to 5273 ​

Use localhost, not 127.0.0.1. On macOS, Vite listens only on the IPv6 localhost.

verify-gyro / verify-config fail with ERR_UNKNOWN_FILE_EXTENSION ​

Node is older than 22.18 and type stripping is not on by default. Upgrade Node, or on 22.15–22.17 run with --experimental-strip-types.