diff --git a/.gitignore b/.gitignore index 9193811..879add9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ dist/enhanced/ # public/cards/hacking/ are the committed record; these are intermediates. # node scripts/hacking-legends.mjs all dist/hacking/ + +# Security Professionals (Series Three) working dirs. Same deal — the published +# faces under public/cards/security/ are the committed record. +# node scripts/security-pros.mjs all +dist/security/ diff --git a/README.md b/README.md index 01053de..f04580e 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,30 @@ Chromium — set `CHROME_PATH` if it is not on the usual paths. `dist/hacking/` is gitignored, so the full-resolution faces are local only. Archive them before wiping the directory if you want print masters. -**Series Three — Security Professionals** (`src/data/security.ts`, roster only): +**Series Three — Security Professionals** (`src/data/security.ts`, 50 cards): -50 hand-curated cards for the defensive side of the field. Copy and sources are -written; no art pipeline exists yet, so the set renders as a data-only roster at -`/security-professionals` and its cards have no `front`/`back` and no card pages. -When art starts, copy `scripts/hacking-legends.mjs` — the data module already -carries every field that pipeline reads. +```bash +pnpm security validate # roster checks, reports what art is missing +pnpm security all # portraits -> render -> publish +pnpm security all 1 5 12 # ...only these card numbers +pnpm security render # re-render faces from the template, no API calls +``` + +Same stages and the same resumability as Series Two, with one deliberate +difference: **`all` does not run `enhance`.** On Series Two that image-to-image +finish pass rewrote text on the card faces despite the prompt forbidding it — it +fabricated quotes attributed to living people, invented stat panels that were not +on the card, and misspelled a name. Nothing in the pipeline catches that, because +there is no OCR step. Since this set's whole premise is documented, sourced +history, an invented quote is the worst defect it can carry, so the pass is +opt-in: it refuses to run unless `ENHANCE_I_WILL_CHECK_EVERY_FACE=1` is set, and +running it means checking every face by eye before publish. + +Two template changes follow from the same finding. The curator's note renders as a +labelled panel rather than wrapped in quote marks, because on Series Two backs the +quoted note reads as something the subject said — the field is editorial voice. +And the front's portrait window is taller, so the caption band is sized to what it +holds instead of leaving a third of the card empty. Nobody appears in more than one series; `src/data/roster.ts` holds the shared card vocabulary that Series Two and Three both render through. diff --git a/assets/portraits/security-pros/card_001.png b/assets/portraits/security-pros/card_001.png new file mode 100644 index 0000000..a984533 Binary files /dev/null and b/assets/portraits/security-pros/card_001.png differ diff --git a/assets/portraits/security-pros/card_002.png b/assets/portraits/security-pros/card_002.png new file mode 100644 index 0000000..33e3bf0 Binary files /dev/null and b/assets/portraits/security-pros/card_002.png differ diff --git a/assets/portraits/security-pros/card_003.png b/assets/portraits/security-pros/card_003.png new file mode 100644 index 0000000..99951f1 Binary files /dev/null and b/assets/portraits/security-pros/card_003.png differ diff --git a/assets/portraits/security-pros/card_004.png b/assets/portraits/security-pros/card_004.png new file mode 100644 index 0000000..43e7367 Binary files /dev/null and b/assets/portraits/security-pros/card_004.png differ diff --git a/assets/portraits/security-pros/card_005.png b/assets/portraits/security-pros/card_005.png new file mode 100644 index 0000000..f470845 Binary files /dev/null and b/assets/portraits/security-pros/card_005.png differ diff --git a/assets/portraits/security-pros/card_006.png b/assets/portraits/security-pros/card_006.png new file mode 100644 index 0000000..7ddc4c6 Binary files /dev/null and b/assets/portraits/security-pros/card_006.png differ diff --git a/assets/portraits/security-pros/card_007.png b/assets/portraits/security-pros/card_007.png new file mode 100644 index 0000000..d790e1f Binary files /dev/null and b/assets/portraits/security-pros/card_007.png differ diff --git a/assets/portraits/security-pros/card_008.png b/assets/portraits/security-pros/card_008.png new file mode 100644 index 0000000..d9ab256 Binary files /dev/null and b/assets/portraits/security-pros/card_008.png differ diff --git a/assets/portraits/security-pros/card_009.png b/assets/portraits/security-pros/card_009.png new file mode 100644 index 0000000..debf88d Binary files /dev/null and b/assets/portraits/security-pros/card_009.png differ diff --git a/assets/portraits/security-pros/card_010.png b/assets/portraits/security-pros/card_010.png new file mode 100644 index 0000000..795a602 Binary files /dev/null and b/assets/portraits/security-pros/card_010.png differ diff --git a/assets/portraits/security-pros/card_011.png b/assets/portraits/security-pros/card_011.png new file mode 100644 index 0000000..e2787a2 Binary files /dev/null and b/assets/portraits/security-pros/card_011.png differ diff --git a/assets/portraits/security-pros/card_012.png b/assets/portraits/security-pros/card_012.png new file mode 100644 index 0000000..22054c3 Binary files /dev/null and b/assets/portraits/security-pros/card_012.png differ diff --git a/assets/portraits/security-pros/card_013.png b/assets/portraits/security-pros/card_013.png new file mode 100644 index 0000000..76ce6dc Binary files /dev/null and b/assets/portraits/security-pros/card_013.png differ diff --git a/assets/portraits/security-pros/card_014.png b/assets/portraits/security-pros/card_014.png new file mode 100644 index 0000000..2770667 Binary files /dev/null and b/assets/portraits/security-pros/card_014.png differ diff --git a/assets/portraits/security-pros/card_015.png b/assets/portraits/security-pros/card_015.png new file mode 100644 index 0000000..584188f Binary files /dev/null and b/assets/portraits/security-pros/card_015.png differ diff --git a/assets/portraits/security-pros/card_016.png b/assets/portraits/security-pros/card_016.png new file mode 100644 index 0000000..bca299f Binary files /dev/null and b/assets/portraits/security-pros/card_016.png differ diff --git a/assets/portraits/security-pros/card_017.png b/assets/portraits/security-pros/card_017.png new file mode 100644 index 0000000..5d282fd Binary files /dev/null and b/assets/portraits/security-pros/card_017.png differ diff --git a/assets/portraits/security-pros/card_018.png b/assets/portraits/security-pros/card_018.png new file mode 100644 index 0000000..dbc2054 Binary files /dev/null and b/assets/portraits/security-pros/card_018.png differ diff --git a/assets/portraits/security-pros/card_019.png b/assets/portraits/security-pros/card_019.png new file mode 100644 index 0000000..4a15018 Binary files /dev/null and b/assets/portraits/security-pros/card_019.png differ diff --git a/assets/portraits/security-pros/card_020.png b/assets/portraits/security-pros/card_020.png new file mode 100644 index 0000000..8c946de Binary files /dev/null and b/assets/portraits/security-pros/card_020.png differ diff --git a/assets/portraits/security-pros/card_021.png b/assets/portraits/security-pros/card_021.png new file mode 100644 index 0000000..2735548 Binary files /dev/null and b/assets/portraits/security-pros/card_021.png differ diff --git a/assets/portraits/security-pros/card_022.png b/assets/portraits/security-pros/card_022.png new file mode 100644 index 0000000..7b5ffa0 Binary files /dev/null and b/assets/portraits/security-pros/card_022.png differ diff --git a/assets/portraits/security-pros/card_023.png b/assets/portraits/security-pros/card_023.png new file mode 100644 index 0000000..d8867a9 Binary files /dev/null and b/assets/portraits/security-pros/card_023.png differ diff --git a/assets/portraits/security-pros/card_024.png b/assets/portraits/security-pros/card_024.png new file mode 100644 index 0000000..c2edea6 Binary files /dev/null and b/assets/portraits/security-pros/card_024.png differ diff --git a/assets/portraits/security-pros/card_025.png b/assets/portraits/security-pros/card_025.png new file mode 100644 index 0000000..5d858e1 Binary files /dev/null and b/assets/portraits/security-pros/card_025.png differ diff --git a/assets/portraits/security-pros/card_026.png b/assets/portraits/security-pros/card_026.png new file mode 100644 index 0000000..02d3847 Binary files /dev/null and b/assets/portraits/security-pros/card_026.png differ diff --git a/assets/portraits/security-pros/card_027.png b/assets/portraits/security-pros/card_027.png new file mode 100644 index 0000000..1ced391 Binary files /dev/null and b/assets/portraits/security-pros/card_027.png differ diff --git a/assets/portraits/security-pros/card_028.png b/assets/portraits/security-pros/card_028.png new file mode 100644 index 0000000..73c6a6f Binary files /dev/null and b/assets/portraits/security-pros/card_028.png differ diff --git a/assets/portraits/security-pros/card_029.png b/assets/portraits/security-pros/card_029.png new file mode 100644 index 0000000..63011ec Binary files /dev/null and b/assets/portraits/security-pros/card_029.png differ diff --git a/assets/portraits/security-pros/card_030.png b/assets/portraits/security-pros/card_030.png new file mode 100644 index 0000000..e076d03 Binary files /dev/null and b/assets/portraits/security-pros/card_030.png differ diff --git a/assets/portraits/security-pros/card_031.png b/assets/portraits/security-pros/card_031.png new file mode 100644 index 0000000..b798931 Binary files /dev/null and b/assets/portraits/security-pros/card_031.png differ diff --git a/assets/portraits/security-pros/card_032.png b/assets/portraits/security-pros/card_032.png new file mode 100644 index 0000000..ed0648a Binary files /dev/null and b/assets/portraits/security-pros/card_032.png differ diff --git a/assets/portraits/security-pros/card_033.png b/assets/portraits/security-pros/card_033.png new file mode 100644 index 0000000..c28a487 Binary files /dev/null and b/assets/portraits/security-pros/card_033.png differ diff --git a/assets/portraits/security-pros/card_034.png b/assets/portraits/security-pros/card_034.png new file mode 100644 index 0000000..2a92e9c Binary files /dev/null and b/assets/portraits/security-pros/card_034.png differ diff --git a/assets/portraits/security-pros/card_035.png b/assets/portraits/security-pros/card_035.png new file mode 100644 index 0000000..eaf43ec Binary files /dev/null and b/assets/portraits/security-pros/card_035.png differ diff --git a/assets/portraits/security-pros/card_036.png b/assets/portraits/security-pros/card_036.png new file mode 100644 index 0000000..d0b980e Binary files /dev/null and b/assets/portraits/security-pros/card_036.png differ diff --git a/assets/portraits/security-pros/card_037.png b/assets/portraits/security-pros/card_037.png new file mode 100644 index 0000000..d93595f Binary files /dev/null and b/assets/portraits/security-pros/card_037.png differ diff --git a/assets/portraits/security-pros/card_038.png b/assets/portraits/security-pros/card_038.png new file mode 100644 index 0000000..32d741c Binary files /dev/null and b/assets/portraits/security-pros/card_038.png differ diff --git a/assets/portraits/security-pros/card_039.png b/assets/portraits/security-pros/card_039.png new file mode 100644 index 0000000..99709be Binary files /dev/null and b/assets/portraits/security-pros/card_039.png differ diff --git a/assets/portraits/security-pros/card_040.png b/assets/portraits/security-pros/card_040.png new file mode 100644 index 0000000..8395c29 Binary files /dev/null and b/assets/portraits/security-pros/card_040.png differ diff --git a/assets/portraits/security-pros/card_041.png b/assets/portraits/security-pros/card_041.png new file mode 100644 index 0000000..f19528a Binary files /dev/null and b/assets/portraits/security-pros/card_041.png differ diff --git a/assets/portraits/security-pros/card_042.png b/assets/portraits/security-pros/card_042.png new file mode 100644 index 0000000..47a8fa2 Binary files /dev/null and b/assets/portraits/security-pros/card_042.png differ diff --git a/assets/portraits/security-pros/card_043.png b/assets/portraits/security-pros/card_043.png new file mode 100644 index 0000000..b4ddc87 Binary files /dev/null and b/assets/portraits/security-pros/card_043.png differ diff --git a/assets/portraits/security-pros/card_044.png b/assets/portraits/security-pros/card_044.png new file mode 100644 index 0000000..4586780 Binary files /dev/null and b/assets/portraits/security-pros/card_044.png differ diff --git a/assets/portraits/security-pros/card_045.png b/assets/portraits/security-pros/card_045.png new file mode 100644 index 0000000..54611ab Binary files /dev/null and b/assets/portraits/security-pros/card_045.png differ diff --git a/assets/portraits/security-pros/card_046.png b/assets/portraits/security-pros/card_046.png new file mode 100644 index 0000000..b778eb1 Binary files /dev/null and b/assets/portraits/security-pros/card_046.png differ diff --git a/assets/portraits/security-pros/card_047.png b/assets/portraits/security-pros/card_047.png new file mode 100644 index 0000000..093ccf8 Binary files /dev/null and b/assets/portraits/security-pros/card_047.png differ diff --git a/assets/portraits/security-pros/card_048.png b/assets/portraits/security-pros/card_048.png new file mode 100644 index 0000000..d804524 Binary files /dev/null and b/assets/portraits/security-pros/card_048.png differ diff --git a/assets/portraits/security-pros/card_049.png b/assets/portraits/security-pros/card_049.png new file mode 100644 index 0000000..bb4eb64 Binary files /dev/null and b/assets/portraits/security-pros/card_049.png differ diff --git a/assets/portraits/security-pros/card_050.png b/assets/portraits/security-pros/card_050.png new file mode 100644 index 0000000..d872803 Binary files /dev/null and b/assets/portraits/security-pros/card_050.png differ diff --git a/package.json b/package.json index 7185428..cceed96 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "db:migrate": "node scripts/migrate.mjs", "hacking": "node scripts/hacking-legends.mjs", "hacking:validate": "node scripts/hacking-legends.mjs validate", - "hacking:render": "node scripts/hacking-legends.mjs render" + "hacking:render": "node scripts/hacking-legends.mjs render", + "security": "node scripts/security-pros.mjs", + "security:validate": "node scripts/security-pros.mjs validate", + "security:render": "node scripts/security-pros.mjs render" }, "dependencies": { "@libsql/client": "^0.17.4", diff --git a/public/cards/security/001-whitfield-diffie-back.png b/public/cards/security/001-whitfield-diffie-back.png new file mode 100644 index 0000000..915e9f9 Binary files /dev/null and b/public/cards/security/001-whitfield-diffie-back.png differ diff --git a/public/cards/security/001-whitfield-diffie-front.png b/public/cards/security/001-whitfield-diffie-front.png new file mode 100644 index 0000000..a94cd2a Binary files /dev/null and b/public/cards/security/001-whitfield-diffie-front.png differ diff --git a/public/cards/security/002-martin-hellman-back.png b/public/cards/security/002-martin-hellman-back.png new file mode 100644 index 0000000..13e8958 Binary files /dev/null and b/public/cards/security/002-martin-hellman-back.png differ diff --git a/public/cards/security/002-martin-hellman-front.png b/public/cards/security/002-martin-hellman-front.png new file mode 100644 index 0000000..4c44c51 Binary files /dev/null and b/public/cards/security/002-martin-hellman-front.png differ diff --git a/public/cards/security/003-ron-rivest-back.png b/public/cards/security/003-ron-rivest-back.png new file mode 100644 index 0000000..065bb24 Binary files /dev/null and b/public/cards/security/003-ron-rivest-back.png differ diff --git a/public/cards/security/003-ron-rivest-front.png b/public/cards/security/003-ron-rivest-front.png new file mode 100644 index 0000000..9e84ffd Binary files /dev/null and b/public/cards/security/003-ron-rivest-front.png differ diff --git a/public/cards/security/004-adi-shamir-back.png b/public/cards/security/004-adi-shamir-back.png new file mode 100644 index 0000000..5a55fd0 Binary files /dev/null and b/public/cards/security/004-adi-shamir-back.png differ diff --git a/public/cards/security/004-adi-shamir-front.png b/public/cards/security/004-adi-shamir-front.png new file mode 100644 index 0000000..19d8ce3 Binary files /dev/null and b/public/cards/security/004-adi-shamir-front.png differ diff --git a/public/cards/security/005-leonard-adleman-back.png b/public/cards/security/005-leonard-adleman-back.png new file mode 100644 index 0000000..ee6ecde Binary files /dev/null and b/public/cards/security/005-leonard-adleman-back.png differ diff --git a/public/cards/security/005-leonard-adleman-front.png b/public/cards/security/005-leonard-adleman-front.png new file mode 100644 index 0000000..1e72d2f Binary files /dev/null and b/public/cards/security/005-leonard-adleman-front.png differ diff --git a/public/cards/security/006-phil-zimmermann-back.png b/public/cards/security/006-phil-zimmermann-back.png new file mode 100644 index 0000000..013dcb2 Binary files /dev/null and b/public/cards/security/006-phil-zimmermann-back.png differ diff --git a/public/cards/security/006-phil-zimmermann-front.png b/public/cards/security/006-phil-zimmermann-front.png new file mode 100644 index 0000000..88e40f7 Binary files /dev/null and b/public/cards/security/006-phil-zimmermann-front.png differ diff --git a/public/cards/security/007-bruce-schneier-back.png b/public/cards/security/007-bruce-schneier-back.png new file mode 100644 index 0000000..0aa4626 Binary files /dev/null and b/public/cards/security/007-bruce-schneier-back.png differ diff --git a/public/cards/security/007-bruce-schneier-front.png b/public/cards/security/007-bruce-schneier-front.png new file mode 100644 index 0000000..09ee26d Binary files /dev/null and b/public/cards/security/007-bruce-schneier-front.png differ diff --git a/public/cards/security/008-paul-kocher-back.png b/public/cards/security/008-paul-kocher-back.png new file mode 100644 index 0000000..f59ff81 Binary files /dev/null and b/public/cards/security/008-paul-kocher-back.png differ diff --git a/public/cards/security/008-paul-kocher-front.png b/public/cards/security/008-paul-kocher-front.png new file mode 100644 index 0000000..1ad6909 Binary files /dev/null and b/public/cards/security/008-paul-kocher-front.png differ diff --git a/public/cards/security/009-dorothy-denning-back.png b/public/cards/security/009-dorothy-denning-back.png new file mode 100644 index 0000000..316ee33 Binary files /dev/null and b/public/cards/security/009-dorothy-denning-back.png differ diff --git a/public/cards/security/009-dorothy-denning-front.png b/public/cards/security/009-dorothy-denning-front.png new file mode 100644 index 0000000..7cc90d9 Binary files /dev/null and b/public/cards/security/009-dorothy-denning-front.png differ diff --git a/public/cards/security/010-gene-spafford-back.png b/public/cards/security/010-gene-spafford-back.png new file mode 100644 index 0000000..0e7499e Binary files /dev/null and b/public/cards/security/010-gene-spafford-back.png differ diff --git a/public/cards/security/010-gene-spafford-front.png b/public/cards/security/010-gene-spafford-front.png new file mode 100644 index 0000000..53deabb Binary files /dev/null and b/public/cards/security/010-gene-spafford-front.png differ diff --git a/public/cards/security/011-ross-anderson-back.png b/public/cards/security/011-ross-anderson-back.png new file mode 100644 index 0000000..5b828bd Binary files /dev/null and b/public/cards/security/011-ross-anderson-back.png differ diff --git a/public/cards/security/011-ross-anderson-front.png b/public/cards/security/011-ross-anderson-front.png new file mode 100644 index 0000000..3e2d83e Binary files /dev/null and b/public/cards/security/011-ross-anderson-front.png differ diff --git a/public/cards/security/012-matt-blaze-back.png b/public/cards/security/012-matt-blaze-back.png new file mode 100644 index 0000000..f62ff8a Binary files /dev/null and b/public/cards/security/012-matt-blaze-back.png differ diff --git a/public/cards/security/012-matt-blaze-front.png b/public/cards/security/012-matt-blaze-front.png new file mode 100644 index 0000000..7a04bb5 Binary files /dev/null and b/public/cards/security/012-matt-blaze-front.png differ diff --git a/public/cards/security/013-radia-perlman-back.png b/public/cards/security/013-radia-perlman-back.png new file mode 100644 index 0000000..1ec4b05 Binary files /dev/null and b/public/cards/security/013-radia-perlman-back.png differ diff --git a/public/cards/security/013-radia-perlman-front.png b/public/cards/security/013-radia-perlman-front.png new file mode 100644 index 0000000..52e33b4 Binary files /dev/null and b/public/cards/security/013-radia-perlman-front.png differ diff --git a/public/cards/security/014-steven-bellovin-back.png b/public/cards/security/014-steven-bellovin-back.png new file mode 100644 index 0000000..1b0a1bf Binary files /dev/null and b/public/cards/security/014-steven-bellovin-back.png differ diff --git a/public/cards/security/014-steven-bellovin-front.png b/public/cards/security/014-steven-bellovin-front.png new file mode 100644 index 0000000..a6c48d4 Binary files /dev/null and b/public/cards/security/014-steven-bellovin-front.png differ diff --git a/public/cards/security/015-william-cheswick-back.png b/public/cards/security/015-william-cheswick-back.png new file mode 100644 index 0000000..1b2d5b6 Binary files /dev/null and b/public/cards/security/015-william-cheswick-back.png differ diff --git a/public/cards/security/015-william-cheswick-front.png b/public/cards/security/015-william-cheswick-front.png new file mode 100644 index 0000000..f96a23e Binary files /dev/null and b/public/cards/security/015-william-cheswick-front.png differ diff --git a/public/cards/security/016-marcus-ranum-back.png b/public/cards/security/016-marcus-ranum-back.png new file mode 100644 index 0000000..ce73574 Binary files /dev/null and b/public/cards/security/016-marcus-ranum-back.png differ diff --git a/public/cards/security/016-marcus-ranum-front.png b/public/cards/security/016-marcus-ranum-front.png new file mode 100644 index 0000000..c877732 Binary files /dev/null and b/public/cards/security/016-marcus-ranum-front.png differ diff --git a/public/cards/security/017-wietse-venema-back.png b/public/cards/security/017-wietse-venema-back.png new file mode 100644 index 0000000..e667238 Binary files /dev/null and b/public/cards/security/017-wietse-venema-back.png differ diff --git a/public/cards/security/017-wietse-venema-front.png b/public/cards/security/017-wietse-venema-front.png new file mode 100644 index 0000000..b40d480 Binary files /dev/null and b/public/cards/security/017-wietse-venema-front.png differ diff --git a/public/cards/security/018-dan-farmer-back.png b/public/cards/security/018-dan-farmer-back.png new file mode 100644 index 0000000..5cb3d65 Binary files /dev/null and b/public/cards/security/018-dan-farmer-back.png differ diff --git a/public/cards/security/018-dan-farmer-front.png b/public/cards/security/018-dan-farmer-front.png new file mode 100644 index 0000000..473abf7 Binary files /dev/null and b/public/cards/security/018-dan-farmer-front.png differ diff --git a/public/cards/security/019-gordon-lyon-back.png b/public/cards/security/019-gordon-lyon-back.png new file mode 100644 index 0000000..cd2b3f9 Binary files /dev/null and b/public/cards/security/019-gordon-lyon-back.png differ diff --git a/public/cards/security/019-gordon-lyon-front.png b/public/cards/security/019-gordon-lyon-front.png new file mode 100644 index 0000000..9ec0efb Binary files /dev/null and b/public/cards/security/019-gordon-lyon-front.png differ diff --git a/public/cards/security/020-martin-roesch-back.png b/public/cards/security/020-martin-roesch-back.png new file mode 100644 index 0000000..02d4a65 Binary files /dev/null and b/public/cards/security/020-martin-roesch-back.png differ diff --git a/public/cards/security/020-martin-roesch-front.png b/public/cards/security/020-martin-roesch-front.png new file mode 100644 index 0000000..8c832f0 Binary files /dev/null and b/public/cards/security/020-martin-roesch-front.png differ diff --git a/public/cards/security/021-gerald-combs-back.png b/public/cards/security/021-gerald-combs-back.png new file mode 100644 index 0000000..b8cc12d Binary files /dev/null and b/public/cards/security/021-gerald-combs-back.png differ diff --git a/public/cards/security/021-gerald-combs-front.png b/public/cards/security/021-gerald-combs-front.png new file mode 100644 index 0000000..e1a455e Binary files /dev/null and b/public/cards/security/021-gerald-combs-front.png differ diff --git a/public/cards/security/022-renaud-deraison-back.png b/public/cards/security/022-renaud-deraison-back.png new file mode 100644 index 0000000..0217894 Binary files /dev/null and b/public/cards/security/022-renaud-deraison-back.png differ diff --git a/public/cards/security/022-renaud-deraison-front.png b/public/cards/security/022-renaud-deraison-front.png new file mode 100644 index 0000000..ce87913 Binary files /dev/null and b/public/cards/security/022-renaud-deraison-front.png differ diff --git a/public/cards/security/023-paul-vixie-back.png b/public/cards/security/023-paul-vixie-back.png new file mode 100644 index 0000000..e6c6456 Binary files /dev/null and b/public/cards/security/023-paul-vixie-back.png differ diff --git a/public/cards/security/023-paul-vixie-front.png b/public/cards/security/023-paul-vixie-front.png new file mode 100644 index 0000000..821a620 Binary files /dev/null and b/public/cards/security/023-paul-vixie-front.png differ diff --git a/public/cards/security/024-niels-provos-back.png b/public/cards/security/024-niels-provos-back.png new file mode 100644 index 0000000..f89601b Binary files /dev/null and b/public/cards/security/024-niels-provos-back.png differ diff --git a/public/cards/security/024-niels-provos-front.png b/public/cards/security/024-niels-provos-front.png new file mode 100644 index 0000000..cf9a1aa Binary files /dev/null and b/public/cards/security/024-niels-provos-front.png differ diff --git a/public/cards/security/025-jeremiah-grossman-back.png b/public/cards/security/025-jeremiah-grossman-back.png new file mode 100644 index 0000000..d047bb6 Binary files /dev/null and b/public/cards/security/025-jeremiah-grossman-back.png differ diff --git a/public/cards/security/025-jeremiah-grossman-front.png b/public/cards/security/025-jeremiah-grossman-front.png new file mode 100644 index 0000000..ad6925a Binary files /dev/null and b/public/cards/security/025-jeremiah-grossman-front.png differ diff --git a/public/cards/security/026-robert-hansen-back.png b/public/cards/security/026-robert-hansen-back.png new file mode 100644 index 0000000..0f23d79 Binary files /dev/null and b/public/cards/security/026-robert-hansen-back.png differ diff --git a/public/cards/security/026-robert-hansen-front.png b/public/cards/security/026-robert-hansen-front.png new file mode 100644 index 0000000..cb5823c Binary files /dev/null and b/public/cards/security/026-robert-hansen-front.png differ diff --git a/public/cards/security/027-mark-curphey-back.png b/public/cards/security/027-mark-curphey-back.png new file mode 100644 index 0000000..bb236cb Binary files /dev/null and b/public/cards/security/027-mark-curphey-back.png differ diff --git a/public/cards/security/027-mark-curphey-front.png b/public/cards/security/027-mark-curphey-front.png new file mode 100644 index 0000000..11ec5d1 Binary files /dev/null and b/public/cards/security/027-mark-curphey-front.png differ diff --git a/public/cards/security/028-jeff-williams-back.png b/public/cards/security/028-jeff-williams-back.png new file mode 100644 index 0000000..f432ca9 Binary files /dev/null and b/public/cards/security/028-jeff-williams-back.png differ diff --git a/public/cards/security/028-jeff-williams-front.png b/public/cards/security/028-jeff-williams-front.png new file mode 100644 index 0000000..d5c7f86 Binary files /dev/null and b/public/cards/security/028-jeff-williams-front.png differ diff --git a/public/cards/security/029-gary-mcgraw-back.png b/public/cards/security/029-gary-mcgraw-back.png new file mode 100644 index 0000000..c7086da Binary files /dev/null and b/public/cards/security/029-gary-mcgraw-back.png differ diff --git a/public/cards/security/029-gary-mcgraw-front.png b/public/cards/security/029-gary-mcgraw-front.png new file mode 100644 index 0000000..0605914 Binary files /dev/null and b/public/cards/security/029-gary-mcgraw-front.png differ diff --git a/public/cards/security/030-michael-howard-back.png b/public/cards/security/030-michael-howard-back.png new file mode 100644 index 0000000..05e5226 Binary files /dev/null and b/public/cards/security/030-michael-howard-back.png differ diff --git a/public/cards/security/030-michael-howard-front.png b/public/cards/security/030-michael-howard-front.png new file mode 100644 index 0000000..8ecbb8d Binary files /dev/null and b/public/cards/security/030-michael-howard-front.png differ diff --git a/public/cards/security/031-adam-shostack-back.png b/public/cards/security/031-adam-shostack-back.png new file mode 100644 index 0000000..2fba100 Binary files /dev/null and b/public/cards/security/031-adam-shostack-back.png differ diff --git a/public/cards/security/031-adam-shostack-front.png b/public/cards/security/031-adam-shostack-front.png new file mode 100644 index 0000000..2120988 Binary files /dev/null and b/public/cards/security/031-adam-shostack-front.png differ diff --git a/public/cards/security/032-ivan-ristic-back.png b/public/cards/security/032-ivan-ristic-back.png new file mode 100644 index 0000000..33dd533 Binary files /dev/null and b/public/cards/security/032-ivan-ristic-back.png differ diff --git a/public/cards/security/032-ivan-ristic-front.png b/public/cards/security/032-ivan-ristic-front.png new file mode 100644 index 0000000..8b9f45d Binary files /dev/null and b/public/cards/security/032-ivan-ristic-front.png differ diff --git a/public/cards/security/033-mark-dowd-back.png b/public/cards/security/033-mark-dowd-back.png new file mode 100644 index 0000000..f231912 Binary files /dev/null and b/public/cards/security/033-mark-dowd-back.png differ diff --git a/public/cards/security/033-mark-dowd-front.png b/public/cards/security/033-mark-dowd-front.png new file mode 100644 index 0000000..8700220 Binary files /dev/null and b/public/cards/security/033-mark-dowd-front.png differ diff --git a/public/cards/security/034-halvar-flake-back.png b/public/cards/security/034-halvar-flake-back.png new file mode 100644 index 0000000..6a0d853 Binary files /dev/null and b/public/cards/security/034-halvar-flake-back.png differ diff --git a/public/cards/security/034-halvar-flake-front.png b/public/cards/security/034-halvar-flake-front.png new file mode 100644 index 0000000..86064ce Binary files /dev/null and b/public/cards/security/034-halvar-flake-front.png differ diff --git a/public/cards/security/035-alex-sotirov-back.png b/public/cards/security/035-alex-sotirov-back.png new file mode 100644 index 0000000..fd1cb3f Binary files /dev/null and b/public/cards/security/035-alex-sotirov-back.png differ diff --git a/public/cards/security/035-alex-sotirov-front.png b/public/cards/security/035-alex-sotirov-front.png new file mode 100644 index 0000000..94dba9a Binary files /dev/null and b/public/cards/security/035-alex-sotirov-front.png differ diff --git a/public/cards/security/036-thomas-ptacek-back.png b/public/cards/security/036-thomas-ptacek-back.png new file mode 100644 index 0000000..8112421 Binary files /dev/null and b/public/cards/security/036-thomas-ptacek-back.png differ diff --git a/public/cards/security/036-thomas-ptacek-front.png b/public/cards/security/036-thomas-ptacek-front.png new file mode 100644 index 0000000..d1bed8f Binary files /dev/null and b/public/cards/security/036-thomas-ptacek-front.png differ diff --git a/public/cards/security/037-matthew-green-back.png b/public/cards/security/037-matthew-green-back.png new file mode 100644 index 0000000..605ad9a Binary files /dev/null and b/public/cards/security/037-matthew-green-back.png differ diff --git a/public/cards/security/037-matthew-green-front.png b/public/cards/security/037-matthew-green-front.png new file mode 100644 index 0000000..a7a100c Binary files /dev/null and b/public/cards/security/037-matthew-green-front.png differ diff --git a/public/cards/security/038-j-alex-halderman-back.png b/public/cards/security/038-j-alex-halderman-back.png new file mode 100644 index 0000000..68721d7 Binary files /dev/null and b/public/cards/security/038-j-alex-halderman-back.png differ diff --git a/public/cards/security/038-j-alex-halderman-front.png b/public/cards/security/038-j-alex-halderman-front.png new file mode 100644 index 0000000..9074d70 Binary files /dev/null and b/public/cards/security/038-j-alex-halderman-front.png differ diff --git a/public/cards/security/039-nadia-heninger-back.png b/public/cards/security/039-nadia-heninger-back.png new file mode 100644 index 0000000..fb9eaa0 Binary files /dev/null and b/public/cards/security/039-nadia-heninger-back.png differ diff --git a/public/cards/security/039-nadia-heninger-front.png b/public/cards/security/039-nadia-heninger-front.png new file mode 100644 index 0000000..8a47094 Binary files /dev/null and b/public/cards/security/039-nadia-heninger-front.png differ diff --git a/public/cards/security/040-troy-hunt-back.png b/public/cards/security/040-troy-hunt-back.png new file mode 100644 index 0000000..d24a191 Binary files /dev/null and b/public/cards/security/040-troy-hunt-back.png differ diff --git a/public/cards/security/040-troy-hunt-front.png b/public/cards/security/040-troy-hunt-front.png new file mode 100644 index 0000000..5572fad Binary files /dev/null and b/public/cards/security/040-troy-hunt-front.png differ diff --git a/public/cards/security/041-window-snyder-back.png b/public/cards/security/041-window-snyder-back.png new file mode 100644 index 0000000..984b62d Binary files /dev/null and b/public/cards/security/041-window-snyder-back.png differ diff --git a/public/cards/security/041-window-snyder-front.png b/public/cards/security/041-window-snyder-front.png new file mode 100644 index 0000000..9d4d275 Binary files /dev/null and b/public/cards/security/041-window-snyder-front.png differ diff --git a/public/cards/security/042-heather-adkins-back.png b/public/cards/security/042-heather-adkins-back.png new file mode 100644 index 0000000..7a13a8b Binary files /dev/null and b/public/cards/security/042-heather-adkins-back.png differ diff --git a/public/cards/security/042-heather-adkins-front.png b/public/cards/security/042-heather-adkins-front.png new file mode 100644 index 0000000..1a6b554 Binary files /dev/null and b/public/cards/security/042-heather-adkins-front.png differ diff --git a/public/cards/security/043-parisa-tabriz-back.png b/public/cards/security/043-parisa-tabriz-back.png new file mode 100644 index 0000000..1f8a070 Binary files /dev/null and b/public/cards/security/043-parisa-tabriz-back.png differ diff --git a/public/cards/security/043-parisa-tabriz-front.png b/public/cards/security/043-parisa-tabriz-front.png new file mode 100644 index 0000000..950dff9 Binary files /dev/null and b/public/cards/security/043-parisa-tabriz-front.png differ diff --git a/public/cards/security/044-alex-stamos-back.png b/public/cards/security/044-alex-stamos-back.png new file mode 100644 index 0000000..7357be2 Binary files /dev/null and b/public/cards/security/044-alex-stamos-back.png differ diff --git a/public/cards/security/044-alex-stamos-front.png b/public/cards/security/044-alex-stamos-front.png new file mode 100644 index 0000000..2b3c68e Binary files /dev/null and b/public/cards/security/044-alex-stamos-front.png differ diff --git a/public/cards/security/045-dan-geer-back.png b/public/cards/security/045-dan-geer-back.png new file mode 100644 index 0000000..9afe5c4 Binary files /dev/null and b/public/cards/security/045-dan-geer-back.png differ diff --git a/public/cards/security/045-dan-geer-front.png b/public/cards/security/045-dan-geer-front.png new file mode 100644 index 0000000..98f7dd9 Binary files /dev/null and b/public/cards/security/045-dan-geer-front.png differ diff --git a/public/cards/security/046-kevin-mandia-back.png b/public/cards/security/046-kevin-mandia-back.png new file mode 100644 index 0000000..0d7eadf Binary files /dev/null and b/public/cards/security/046-kevin-mandia-back.png differ diff --git a/public/cards/security/046-kevin-mandia-front.png b/public/cards/security/046-kevin-mandia-front.png new file mode 100644 index 0000000..e4e68ae Binary files /dev/null and b/public/cards/security/046-kevin-mandia-front.png differ diff --git a/public/cards/security/047-mikko-hypponen-back.png b/public/cards/security/047-mikko-hypponen-back.png new file mode 100644 index 0000000..49aefc2 Binary files /dev/null and b/public/cards/security/047-mikko-hypponen-back.png differ diff --git a/public/cards/security/047-mikko-hypponen-front.png b/public/cards/security/047-mikko-hypponen-front.png new file mode 100644 index 0000000..d2aa740 Binary files /dev/null and b/public/cards/security/047-mikko-hypponen-front.png differ diff --git a/public/cards/security/048-robert-m-lee-back.png b/public/cards/security/048-robert-m-lee-back.png new file mode 100644 index 0000000..cb39e91 Binary files /dev/null and b/public/cards/security/048-robert-m-lee-back.png differ diff --git a/public/cards/security/048-robert-m-lee-front.png b/public/cards/security/048-robert-m-lee-front.png new file mode 100644 index 0000000..71b9c74 Binary files /dev/null and b/public/cards/security/048-robert-m-lee-front.png differ diff --git a/public/cards/security/049-lesley-carhart-back.png b/public/cards/security/049-lesley-carhart-back.png new file mode 100644 index 0000000..39ae333 Binary files /dev/null and b/public/cards/security/049-lesley-carhart-back.png differ diff --git a/public/cards/security/049-lesley-carhart-front.png b/public/cards/security/049-lesley-carhart-front.png new file mode 100644 index 0000000..62a29c6 Binary files /dev/null and b/public/cards/security/049-lesley-carhart-front.png differ diff --git a/public/cards/security/050-katie-nickels-back.png b/public/cards/security/050-katie-nickels-back.png new file mode 100644 index 0000000..8fea037 Binary files /dev/null and b/public/cards/security/050-katie-nickels-back.png differ diff --git a/public/cards/security/050-katie-nickels-front.png b/public/cards/security/050-katie-nickels-front.png new file mode 100644 index 0000000..118e8db Binary files /dev/null and b/public/cards/security/050-katie-nickels-front.png differ diff --git a/scripts/security-pros.mjs b/scripts/security-pros.mjs new file mode 100644 index 0000000..50bdfde --- /dev/null +++ b/scripts/security-pros.mjs @@ -0,0 +1,606 @@ +#!/usr/bin/env node +/* + Security Professionals (Series Three) — end-to-end card pipeline. + + Same shape as scripts/hacking-legends.mjs. Every stage skips work that already + exists, so re-running is cheap and a failed batch can just be run again. + + portraits AI portrait art, one per professional -> assets/portraits-art/security-pros/ + render HTML template -> exact card PNGs -> dist/security/html/ + enhance image-to-image premium finish pass -> dist/security/enhanced/ (OPT-IN, see below) + publish pick the best face available -> public/cards/security/ + validate roster + asset sanity checks + all portraits -> render -> publish + + WHY `all` DOES NOT RUN `enhance` + -------------------------------- + On Series Two the image-to-image finish pass rewrote text on the card faces + despite the prompt forbidding it: it fabricated quotes attributed to living + people, invented stat panels that were not on the card, and misspelled a name. + Nothing in the pipeline catches that, because there is no OCR step — it was + found by eye, after the art shipped. + + This set's whole premise is documented, sourced history, so an invented quote + attributed to a real person is the worst defect it can have. `enhance` is + therefore opt-in only and never part of `all`. Running it means accepting that + every affected face must be checked by eye before publish. + + The roster is src/data/security.ts, imported directly (Node strips the types), + so there is no second copy of the data to keep in sync. + + Portrait resolution order, highest authority first: + 1. assets/portraits/security-pros/card_NNN.png (human-approved, committed) + 2. assets/portraits-art/security-pros/card_NNN.png (model output, gitignored) + 3. generate a new one + + Env: + OPENAI_API_KEY / GEMINI_API_KEY at least one, picks the provider + IMAGE_PROVIDER=openai|gemini override auto-detection + OPENAI_IMAGE_MODEL default gpt-image-2 + GEMINI_IMAGE_MODEL default gemini-3-pro-image-preview + CHROME_PATH override browser discovery + + Run: + node scripts/security-pros.mjs all # whole set + node scripts/security-pros.mjs all 1 5 12 # only these numbers + node scripts/security-pros.mjs render # no API calls, no spend +*/ +import fs from 'node:fs/promises'; +import fssync from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright-core'; +import sharp from 'sharp'; +import { buildFront, buildBack, CARD_W, CARD_H } from './security-template.mjs'; + +// What the site serves. Series One publishes at exactly this size and all three +// decks appear in the same grids, so Series Three has to match. +const WEB_W = 500; +const WEB_H = 745; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// Importing src/data/security.ts makes Node warn that package.json has no "type". +// Setting it would change module resolution for the Next app, so mute just this +// one warning rather than 30 lines of noise on every run. +const emitWarning = process.emitWarning.bind(process); +process.emitWarning = (warning, ...rest) => { + const code = rest.find((r) => typeof r === 'string' && r.startsWith('MODULE_')) ?? + rest.find((r) => r && typeof r === 'object')?.code; + if (code === 'MODULE_TYPELESS_PACKAGE_JSON') return; + return emitWarning(warning, ...rest); +}; + +const DIRS = { + approvedArt: path.join(ROOT, 'assets', 'portraits', 'security-pros'), + workingArt: path.join(ROOT, 'assets', 'portraits-art', 'security-pros'), + html: path.join(ROOT, 'dist', 'security', 'html'), + enhanced: path.join(ROOT, 'dist', 'security', 'enhanced'), + published: path.join(ROOT, 'public', 'cards', 'security'), +}; + +const CONCURRENCY = 3; +const cardId = (n) => `card_${String(n).padStart(3, '0')}`; + +// ---------------------------------------------------------------- roster ---- + +async function loadRoster(only = []) { + const mod = await import(path.join(ROOT, 'src', 'data', 'security.ts')); + const all = mod.pros; + if (!only.length) return all; + const found = all.filter((h) => only.includes(h.number)); + const missing = only.filter((n) => !all.some((h) => h.number === n)); + if (missing.length) throw new Error(`no such card number: ${missing.join(', ')}`); + return found; +} + +// ------------------------------------------------------------- providers ---- + +function provider() { + const forced = process.env.IMAGE_PROVIDER; + if (forced) { + if (!['openai', 'gemini'].includes(forced)) throw new Error(`unknown IMAGE_PROVIDER: ${forced}`); + return forced; + } + if (process.env.OPENAI_API_KEY) return 'openai'; + if (process.env.GEMINI_API_KEY) return 'gemini'; + throw new Error('set OPENAI_API_KEY or GEMINI_API_KEY to generate art'); +} + +const OPENAI_MODEL = () => process.env.OPENAI_IMAGE_MODEL || 'gpt-image-2'; +const GEMINI_MODEL = () => process.env.GEMINI_IMAGE_MODEL || 'gemini-3-pro-image-preview'; + +const RETRYABLE = /quota|rate|429|500|502|503|timeout|unavailable|internal|overloaded/i; + +async function withRetry(label, fn, attempts = 3) { + let lastErr; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt === attempts || !RETRYABLE.test(String(err.message))) break; + const wait = 4000 * attempt; + process.stdout.write(` … ${label} retry ${attempt}/${attempts - 1} in ${wait / 1000}s\n`); + await new Promise((r) => setTimeout(r, wait)); + } + } + throw lastErr; +} + +/** Text prompt -> PNG buffer. */ +async function generateImage(prompt, { size = '1024x1024' } = {}) { + if (provider() === 'gemini') { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL()}:generateContent?key=${process.env.GEMINI_API_KEY}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { responseModalities: ['IMAGE'] }, + }), + } + ); + const d = await res.json(); + if (!d.candidates) throw new Error(JSON.stringify(d.error || d).slice(0, 200)); + const part = d.candidates[0].content.parts.find((p) => p.inlineData || p.inline_data); + if (!part) throw new Error('response contained no image'); + return Buffer.from((part.inlineData || part.inline_data).data, 'base64'); + } + + const res = await fetch('https://api.openai.com/v1/images/generations', { + method: 'POST', + headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: OPENAI_MODEL(), prompt, size, n: 1 }), + }); + const d = await res.json(); + if (d.error) throw new Error(d.error.message?.slice(0, 200) || 'openai error'); + if (!d.data?.[0]?.b64_json) throw new Error('response contained no image'); + return Buffer.from(d.data[0].b64_json, 'base64'); +} + +/** Source PNG + instruction -> revised PNG buffer. */ +async function editImage(pngBuffer, prompt, { size = '1024x1536' } = {}) { + if (provider() === 'gemini') { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL()}:generateContent?key=${process.env.GEMINI_API_KEY}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { inline_data: { mime_type: 'image/png', data: pngBuffer.toString('base64') } }, + { text: prompt }, + ], + }, + ], + generationConfig: { responseModalities: ['IMAGE'] }, + }), + } + ); + const d = await res.json(); + if (!d.candidates) throw new Error(JSON.stringify(d.error || d).slice(0, 200)); + const part = d.candidates[0].content.parts.find((p) => p.inlineData || p.inline_data); + if (!part) throw new Error('response contained no image'); + return Buffer.from((part.inlineData || part.inline_data).data, 'base64'); + } + + const form = new FormData(); + form.append('model', OPENAI_MODEL()); + form.append('prompt', prompt); + form.append('size', size); + form.append('image', new Blob([pngBuffer], { type: 'image/png' }), 'card.png'); + const res = await fetch('https://api.openai.com/v1/images/edits', { + method: 'POST', + headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` }, + body: form, + }); + const d = await res.json(); + if (d.error) throw new Error(d.error.message?.slice(0, 200) || 'openai error'); + if (!d.data?.[0]?.b64_json) throw new Error('response contained no image'); + return Buffer.from(d.data[0].b64_json, 'base64'); +} + +// -------------------------------------------------------------- prompts ---- + +function portraitPrompt(h) { + const motif = (h.domains || []).slice(0, 2).join(' and ') || 'computer security'; + const era = h.era ? ` Period-accurate to ${h.era}.` : ''; + return [ + `Realistic semi-painterly digital portrait of ${h.name}, ${h.title}.`, + 'Head and shoulders, facing the camera, warm cinematic studio lighting with a cool rim light,', + 'detailed painted realism in the style of a premium collectible trading-card portrait.', + `Dark background with subtle faint motifs of ${motif}.${era}`, + 'Recognizable likeness, dignified and serious, treated as a documented historical figure.', + 'No text, no letters, no logos, no border, no card frame, no watermark.', + ].join(' '); +} + +const ENHANCE_PROMPT = + 'Enhance this collectible trading card into a premium, professionally-printed card with real depth and polish: ' + + 'cinematic lighting and painterly refinement on the portrait, refined dark metal and matte material textures, ' + + 'a tasteful metallic foil accent border, embossed elements, soft inner shadows and subtle gloss — like a high-end ' + + 'collectible trading card. ABSOLUTELY CRITICAL: keep ALL text, numbers, names, aliases, titles, labels, stat values, ' + + 'bar lengths, the quote and the entire layout EXACTLY the same, fully legible and in identical positions — do not ' + + 'change, add, remove, reflow or misspell any text. Only improve the visual finish, depth, materials and lighting.'; + +// -------------------------------------------------------------- browser ---- + +function findChrome() { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH; + const candidates = [ + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + ...playwrightChromiums(), + ]; + const found = candidates.find((p) => p && fssync.existsSync(p)); + if (!found) { + throw new Error( + 'no Chrome/Chromium found. Set CHROME_PATH, or install one with `npx playwright install chromium`.' + ); + } + return found; +} + +/** + * A Playwright Chromium unpacked without root has no system GTK/ATK libraries. + * If a staged copy is present, put it on the loader path so the browser starts. + * Absent that directory this is a no-op and the system libraries are used. + */ +function browserEnv() { + const staged = path.join( + process.env.HOME || '', + '.local', + 'share', + 'chrome-deps', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ); + if (!fssync.existsSync(staged)) return undefined; + const existing = process.env.LD_LIBRARY_PATH; + return { ...process.env, LD_LIBRARY_PATH: existing ? `${staged}:${existing}` : staged }; +} + +function playwrightChromiums() { + const base = path.join(process.env.HOME || '', '.cache', 'ms-playwright'); + if (!fssync.existsSync(base)) return []; + return fssync + .readdirSync(base) + .filter((d) => d.startsWith('chromium')) + .sort() + .reverse() + .map((d) => path.join(base, d, 'chrome-linux', 'chrome')); +} + +// --------------------------------------------------------------- stages ---- + +async function ensureDirs() { + for (const d of Object.values(DIRS)) await fs.mkdir(d, { recursive: true }); +} + +function approvedPortrait(h) { + const p = path.join(DIRS.approvedArt, `${cardId(h.number)}.png`); + return fssync.existsSync(p) ? p : null; +} +function workingPortrait(h) { + const p = path.join(DIRS.workingArt, `${cardId(h.number)}.png`); + return fssync.existsSync(p) ? p : null; +} +function portraitFor(h) { + return approvedPortrait(h) || workingPortrait(h); +} + +/** Run `task` over `items` with a small worker pool, reporting as it goes. */ +async function pool(items, label, task) { + let i = 0; + let ok = 0; + let skipped = 0; + const failed = []; + async function worker() { + while (i < items.length) { + const item = items[i++]; + const name = `${String(item.number).padStart(2, '0')} ${item.name}`; + try { + const r = await task(item); + if (r === 'skip') { + skipped++; + } else { + ok++; + process.stdout.write(`✓ ${name}\n`); + } + } catch (err) { + failed.push(item.number); + process.stdout.write(`✗ ${name}: ${err.message}\n`); + } + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); + console.log( + `${label}: ${ok} done, ${skipped} already present, ${failed.length} failed` + + (failed.length ? ` (${failed.join(', ')})` : '') + ); + return { ok, skipped, failed }; +} + +async function portraits(roster) { + await ensureDirs(); + console.log(`portraits: ${provider()} / ${provider() === 'openai' ? OPENAI_MODEL() : GEMINI_MODEL()}`); + return pool(roster, 'portraits', async (h) => { + if (portraitFor(h)) return 'skip'; + const buf = await withRetry(cardId(h.number), () => generateImage(portraitPrompt(h))); + await fs.writeFile(path.join(DIRS.workingArt, `${cardId(h.number)}.png`), buf); + }); +} + +async function render(roster) { + await ensureDirs(); + const executablePath = findChrome(); + console.log(`render: ${executablePath}`); + const browser = await chromium.launch({ + executablePath, + args: ['--no-sandbox'], + env: browserEnv(), + }); + const page = await browser.newPage({ + viewport: { width: CARD_W, height: CARD_H }, + deviceScaleFactor: 2, + }); + + const shoot = async (html, id, side) => { + // Keep the self-contained HTML next to the PNG so a card can be re-rendered + // or hand-corrected later without regenerating the portrait. + await fs.writeFile(path.join(DIRS.html, `${id}-${side}.html`), html); + await page.setContent(html, { waitUntil: 'networkidle' }); + await page.evaluate(() => document.fonts.ready); + await page.locator('.card').screenshot({ path: path.join(DIRS.html, `${id}-${side}.png`) }); + }; + + let ok = 0; + const failed = []; + for (const h of roster) { + const id = cardId(h.number); + try { + const art = portraitFor(h); + if (!art) throw new Error('no portrait art — run `portraits` first'); + const uri = `data:image/png;base64,${(await fs.readFile(art)).toString('base64')}`; + await shoot(buildFront(h, uri), id, 'front'); + await shoot(buildBack(h), id, 'back'); + ok++; + console.log(`✓ ${String(h.number).padStart(2, '0')} ${h.name} (${h.rarity})`); + } catch (err) { + failed.push(h.number); + console.log(`✗ ${String(h.number).padStart(2, '0')} ${h.name}: ${err.message}`); + } + } + await browser.close(); + console.log(`render: ${ok} done, ${failed.length} failed${failed.length ? ` (${failed.join(', ')})` : ''}`); + return { ok, failed }; +} + +async function enhance(roster) { + // See the header comment. This pass rewrote text on shipped Series Two faces. + if (!process.env.ENHANCE_I_WILL_CHECK_EVERY_FACE) { + console.error( + 'enhance: refusing to run.\n' + + ' On Series Two this pass fabricated quotes attributed to living people,\n' + + ' invented stat panels and misspelled a name, despite the prompt forbidding it.\n' + + ' There is no OCR check in this pipeline, so nothing catches it but your eyes.\n' + + ' Set ENHANCE_I_WILL_CHECK_EVERY_FACE=1 to run it anyway, then inspect every\n' + + ' face in dist/security/enhanced/ before publish.' + ); + process.exit(1); + } + await ensureDirs(); + console.log(`enhance: ${provider()} / ${provider() === 'openai' ? OPENAI_MODEL() : GEMINI_MODEL()}`); + console.log('enhance: WARNING — this pass rewrites text. Check every face before publish.'); + const jobs = []; + for (const h of roster) { + for (const side of ['front', 'back']) { + const src = path.join(DIRS.html, `${cardId(h.number)}-${side}.png`); + if (fssync.existsSync(src)) jobs.push({ ...h, side, src, name: `${h.name} ${side}` }); + } + } + if (!jobs.length) { + console.log('enhance: nothing rendered yet — run `render` first'); + return { ok: 0, failed: [] }; + } + return pool(jobs, 'enhance', async (job) => { + const out = path.join(DIRS.enhanced, `${cardId(job.number)}-${job.side}.png`); + if (fssync.existsSync(out)) return 'skip'; + const buf = await withRetry( + `${cardId(job.number)}-${job.side}`, + async () => editImage(await fs.readFile(job.src), ENHANCE_PROMPT) + ); + await fs.writeFile(out, buf); + }); +} + +async function publish(roster) { + await ensureDirs(); + let ok = 0; + const missing = []; + const linked = []; + let fromHtml = 0; + for (const h of roster) { + let faces = 0; + for (const side of ['front', 'back']) { + const id = cardId(h.number); + const enhanced = path.join(DIRS.enhanced, `${id}-${side}.png`); + const plain = path.join(DIRS.html, `${id}-${side}.png`); + const src = fssync.existsSync(enhanced) ? enhanced : fssync.existsSync(plain) ? plain : null; + if (!src) { + missing.push(`${id}-${side}`); + continue; + } + if (src === plain) fromHtml++; + const dest = path.join( + DIRS.published, + `${String(h.number).padStart(3, '0')}-${h.slug}-${side}.png` + ); + // The full-resolution face stays in dist/ as the print master; the copy the + // site loads is resized and recompressed, or a deck costs ~180MB to load. + await sharp(src) + .resize(WEB_W, WEB_H, { fit: 'fill' }) + .png({ compressionLevel: 9, palette: true }) + .toFile(dest); + ok++; + faces++; + } + // Only advertise a card on the site once both of its faces exist. + if (faces === 2) linked.push(h); + } + await writeArtPaths(linked); + console.log(`publish: ${ok} faces -> ${path.relative(ROOT, DIRS.published)}`); + if (fromHtml) console.log(`publish: ${fromHtml} used the text-accurate HTML render (the expected path for this set)`); + if (missing.length) console.log(`publish: ${missing.length} missing (${missing.join(', ')})`); + return { ok, missing }; +} + +/** + * Point the roster's `front`/`back` fields at the faces we just published, so the + * site and its "artwork rendered" counter pick them up. Rewrites only those two + * lines per card and leaves the hand-written copy untouched. + */ +async function writeArtPaths(published) { + if (!published.length) return; + const file = path.join(ROOT, 'src', 'data', 'security.ts'); + const lines = (await fs.readFile(file, 'utf8')).split('\n'); + const wanted = new Map(published.map((h) => [h.number, h])); + const out = []; + const inserted = new Set(); + let current = null; + let changed = 0; + + for (const line of lines) { + const num = line.match(/^ {4}number: (\d+),$/); + if (num) current = Number(num[1]); + const rewriting = current !== null && wanted.has(current); + // Drop any previous art lines for a card we are about to rewrite. `current` + // has to stay set past the insert below, or existing lines sitting *after* + // `status:` slip through and the card ends up with duplicate keys. + if (rewriting && /^ {4}(front|back): '.*',$/.test(line)) continue; + out.push(line); + if (rewriting && !inserted.has(current) && /^ {4}status: '.*',$/.test(line)) { + const h = wanted.get(current); + const stem = `/cards/security/${String(h.number).padStart(3, '0')}-${h.slug}`; + out.push(` front: '${stem}-front.png',`); + out.push(` back: '${stem}-back.png',`); + changed++; + inserted.add(current); + } + } + + await fs.writeFile(file, out.join('\n')); + console.log(`publish: linked art paths for ${changed} card(s) in src/data/security.ts`); +} + +async function validate(roster) { + const all = await loadRoster(); + const errors = []; + const warnings = []; + + const numbers = new Set(); + const slugs = new Set(); + for (const h of all) { + if (numbers.has(h.number)) errors.push(`duplicate card number ${h.number}`); + if (slugs.has(h.slug)) errors.push(`duplicate slug ${h.slug}`); + numbers.add(h.number); + slugs.add(h.slug); + } + + for (const h of roster) { + const id = `${cardId(h.number)} ${h.name}`; + for (const field of ['slug', 'name', 'title', 'knownFor', 'rarity', 'nationality', 'era', 'scouting', 'note', 'status']) { + if (!h[field]) errors.push(`${id}: missing ${field}`); + } + if (!h.domains?.length) errors.push(`${id}: needs at least one domain`); + // The set claims to be documented history, so an unsourced card is a defect, + // not a warning. Series Two holds the same bar by hand; here it is enforced. + if (!h.sources?.length) errors.push(`${id}: needs at least one source`); + for (const s of h.sources || []) { + if (!s.label || !/^https?:\/\//.test(String(s.url))) { + errors.push(`${id}: source needs a label and an http(s) url, got ${JSON.stringify(s)}`); + } + } + if (h.domains?.length > 5) errors.push(`${id}: ${h.domains.length} domains, the back panel fits 5`); + for (const stat of ['impact', 'technical', 'defense', 'research', 'influence']) { + const v = h[stat]; + if (typeof v !== 'number' || v < 0 || v > 100) errors.push(`${id}: ${stat} must be 0-100, got ${v}`); + } + const scoutWords = String(h.scouting).trim().split(/\s+/).length; + const noteWords = String(h.note).trim().split(/\s+/).length; + if (scoutWords > 70) warnings.push(`${id}: scouting report is ${scoutWords} words, panel is sized for ~65`); + if (noteWords > 35) warnings.push(`${id}: note is ${noteWords} words, sized for ~35`); + if (h.status !== 'locked') warnings.push(`${id}: status is "${h.status}", copy is not final`); + if (!portraitFor(h)) warnings.push(`${id}: no portrait art yet`); + } + + for (const w of warnings) console.log(`warn ${w}`); + if (errors.length) { + console.error('\nValidation failed:'); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); + } + console.log( + `\nValidation passed: ${all.length} on the roster, ${roster.length} selected, ` + + `${warnings.length} warning${warnings.length === 1 ? '' : 's'}.` + ); + return { warnings }; +} + +// ------------------------------------------------------------------ cli ---- + +const HELP = `Security Professionals (Series Three) card pipeline + + node scripts/security-pros.mjs [card numbers...] + +Commands + validate Check the roster and report what art is still missing + portraits Generate missing portrait art + render Render card fronts and backs from the HTML template (no API calls) + enhance Premium finish pass. OPT-IN ONLY — it rewrites text. See the header. + publish Copy the best available faces into public/cards/security/ + all portraits -> render -> publish + +With no card numbers every card on the roster is processed. Every stage skips +work that already exists, so re-running only fills gaps.`; + +async function main() { + const cmd = process.argv[2] || 'help'; + const only = process.argv.slice(3).map(Number).filter((n) => Number.isFinite(n) && n > 0); + + if (cmd === 'help' || cmd === '--help' || cmd === '-h') { + console.log(HELP); + return; + } + + const roster = await loadRoster(only); + if (only.length) console.log(`Selected ${roster.length} card(s): ${roster.map((h) => h.number).join(', ')}\n`); + + if (cmd === 'validate') await validate(roster); + else if (cmd === 'portraits') await portraits(roster); + else if (cmd === 'render') await render(roster); + else if (cmd === 'enhance') await enhance(roster); + else if (cmd === 'publish') await publish(roster); + else if (cmd === 'all') { + // No enhance. See the header comment — it is opt-in for this set. + await portraits(roster); + await render(roster); + await publish(roster); + } else { + console.error(`Unknown command: ${cmd}\n`); + console.log(HELP); + process.exit(1); + } +} + +main().catch((err) => { + console.error('ERROR:', err.message); + process.exit(1); +}); diff --git a/scripts/security-template.mjs b/scripts/security-template.mjs new file mode 100644 index 0000000..1ebc489 --- /dev/null +++ b/scripts/security-template.mjs @@ -0,0 +1,194 @@ +// HTML/CSS card template for "Security Professionals" (Series Three). +// Same bones as card-template.mjs (Series One) and hacking-template.mjs (Series +// Two) so all three decks read as one product line: thin accent border, +// portrait-top front, cream panels and a dark skill stack on the back. What +// differs is the axes — this set rates defensive craft and research where Series +// Two rates social engineering and notoriety. +// Only raster image = the AI portrait. Everything else is HTML/CSS/SVG. +// +// Two deliberate departures from the Series Two template, both from defects found +// on shipped Series Two art: +// 1. The curator's note is rendered as a labelled panel, NOT wrapped in quote +// marks. On Series Two backs the quoted note reads as a quotation from the +// subject, which it is not — the field is editorial voice. +// 2. Nothing on either face is left as undecorated empty space, because the +// image-to-image finish pass treats blank regions as an invitation to invent +// text. (Series Three does not run that pass at all — see security-pros.mjs.) + +export const CARD_W = 700; +export const CARD_H = 1043; + +// Steadier than the Series Two palette: steel, cipher blue and signal amber +// rather than phosphor and alarm. These are the people holding the line. +const PALETTE = ['4f8ef7', '2bb3c0', '5ad1a5', 'c9a227', '8e9bb8', '7b6bd6', '3f9f7f', 'd07a3c']; +export function accentHexOf(pro) { + let h = 0; + for (const ch of String(pro.name)) h = (h * 31 + ch.charCodeAt(0)) >>> 0; + return PALETTE[h % PALETTE.length]; +} +const accentOf = (pro) => '#' + accentHexOf(pro); + +// Like Series Two, every entry carries an explicit `rarity`, so unlike Series One +// it is never derived from the impact score. +export const rarityOf = (pro) => pro.rarity; +const STARS = { iconic: '★★★★★', legendary: '★★★★☆', epic: '★★★☆☆', rare: '★★☆☆☆' }; + +const pad2 = (n) => String(n).padStart(2, '0'); +const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + +function traits(h) { + const m = [ + ['DEEP TECH', h.technical], + ['DEFENDER', h.defense], + ['RESEARCHER', h.research], + ['INFLUENTIAL', h.influence], + ] + .sort((a, b) => b[1] - a[1]) + .slice(0, 2) + .map((x) => x[0]); + return [...m, String(h.rarity).toUpperCase()]; +} + +function bars(h, accent) { + const rows = [ + ['TECHNICAL DEPTH', h.technical], + ['DEFENSIVE CRAFT', h.defense], + ['RESEARCH', h.research], + ['INFLUENCE', h.influence], + ]; + // No IMPACT row: unlike Series One's "OPEN SOURCE IMPACT", it would repeat the + // score already shown in the box beside the stack. + return rows + .map( + ([label, v]) => + `
${esc(label)}${v}
` + ) + .join(''); +} + +const FONTS = `@import url('https://fonts.googleapis.com/css2?family=Oswald:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap');`; + +// Series Three mark: the same shield as Series One and Two, holding a keyhole — +// the defensive counterpart to Series Two's terminal prompt. +const emblem = (a, s = 24) => + ``; +const lockGlyph = (a, s = 22) => + ``; + +const shell = (accent) => `${FONTS} + * { margin:0; padding:0; box-sizing:border-box; } + html,body { width:${CARD_W}px; height:${CARD_H}px; } + body { font-family:'Inter',Arial,sans-serif; background:#05060a; } + .card { position:relative; width:${CARD_W}px; height:${CARD_H}px; overflow:hidden; border-radius:26px; + background: radial-gradient(120% 80% at 50% 0%, #141a18 0%, #0b0f11 60%, #08090c 100%); + box-shadow: inset 0 0 0 2px ${accent}, inset 0 0 0 5px #07070d; } + .crest { display:flex; align-items:center; gap:7px; } + .crest .t { font-family:'Oswald'; color:#fff; font-weight:700; font-size:11px; letter-spacing:.6px; line-height:1.05; }`; + +export function buildFront(pro, portraitDataUri) { + const accent = accentOf(pro); + const kw = traits(pro).join('  ·  '); + const alias = pro.handle + ? `
“${esc(pro.handle)}”
` + : ''; + return ` +
+
+
${pad2(pro.number)}
+
${emblem(accent, 24)}SECURITY
PROFESSIONALS
+
${esc(pro.name)}
${alias}
${esc(pro.title)}
+
${emblem(accent, 30)}${kw}${lockGlyph(accent, 28)}
+
`; +} + +export function buildBack(pro) { + const accent = accentOf(pro); + const rarity = String(pro.rarity).toUpperCase(); + const domains = (pro.domains || []).slice(0, 5).map((d) => `
  • ${esc(d)}
  • `).join(''); + const alias = pro.handle ? `“${esc(pro.handle)}”` : ''; + return ` +
    +
    +
    ${pad2(pro.number)}${esc(pro.name)}${alias}
    +
    ${esc(pro.title)}
    +
    +
    +
    +

    SCOUTING REPORT

    ${esc(pro.scouting)}

    +

    DOMAINS

      ${domains}
    +
    +
    +

    SKILL STACK

    ${bars(pro, accent)}
    +
    IMPACT${pro.impact}${rarity}${STARS[pro.rarity] || '★★★★★'}
    +
    +
    ${emblem(accent, 190)}${ + pro.handle ? `${esc(pro.handle)}` : '' + }
    +
    CURATOR’S NOTE

    ${esc(pro.note)}

    +
    +
    ERA ${esc(pro.era)}NATIONALITY ${esc(pro.nationality)}
    +
    KNOWN FOR ${esc(pro.knownFor)}${lockGlyph(accent, 18)}
    +
    +
    `; +} diff --git a/src/app/security-professionals/[slug]/opengraph-image.tsx b/src/app/security-professionals/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..84e9e3f --- /dev/null +++ b/src/app/security-professionals/[slug]/opengraph-image.tsx @@ -0,0 +1,28 @@ +import { pros, getPro, rarityLabel } from '@/data/security'; +import { OG_CONTENT_TYPE, OG_SIZE, renderCardOg } from '@/lib/og'; + +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; +export const alt = 'Security Professionals trading card'; + +export function generateStaticParams() { + return pros.filter((p) => p.front).map((p) => ({ slug: p.slug })); +} + +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const p = getPro(slug); + if (!p?.front) throw new Error(`No illustrated card for slug ${slug}`); + + return renderCardOg({ + number: p.number, + name: p.name, + handle: p.handle, + title: p.title, + rarity: p.rarity, + rarityLabel: rarityLabel[p.rarity], + impact: p.impact, + front: p.front, + setLabel: 'Security Professionals', + }); +} diff --git a/src/app/security-professionals/[slug]/page.tsx b/src/app/security-professionals/[slug]/page.tsx new file mode 100644 index 0000000..f6ef4dd --- /dev/null +++ b/src/app/security-professionals/[slug]/page.tsx @@ -0,0 +1,102 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import CardDetail from '@/components/CardDetail'; +import { pros, getPro, rarityLabel, statusLabel } from '@/data/security'; +import { site } from '@/data/site'; + +type Params = { params: Promise<{ slug: string }> }; + +export function generateStaticParams() { + return pros.map((p) => ({ slug: p.slug })); +} + +export async function generateMetadata({ params }: Params): Promise { + const { slug } = await params; + const p = getPro(slug); + if (!p) return { title: 'Card not found' }; + + const title = `${p.name} — ${p.title}`; + const description = `${p.knownFor}. Card #${String(p.number).padStart(3, '0')} in ${site.name} Series Three: Security Professionals, ${rarityLabel[p.rarity]} rarity, impact ${p.impact}.`; + const url = `${site.url}/security-professionals/${p.slug}`; + + return { + title, + description, + alternates: { canonical: url }, + openGraph: { title, description, url, type: 'profile' }, + twitter: { card: 'summary_large_image', title, description }, + }; +} + +export default async function ProPage({ params }: Params) { + const { slug } = await params; + const p = getPro(slug); + if (!p) notFound(); + + const i = pros.findIndex((x) => x.slug === p.slug); + const prev = i > 0 ? pros[i - 1] : undefined; + const next = i < pros.length - 1 ? pros[i + 1] : undefined; + + // Art is published for the whole set, but keep the page honest if a face is + // ever missing rather than rendering a broken image. + if (!p.front || !p.back) notFound(); + + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'Person', + name: p.name, + alternateName: p.handle, + jobTitle: p.title, + description: p.scouting, + nationality: p.nationality, + url: `${site.url}/security-professionals/${p.slug}`, + image: `${site.url}${p.front}`, + ...(p.sources?.length ? { sameAs: p.sources.map((s) => s.url) } : {}), + subjectOf: { + '@type': 'CreativeWork', + name: `${p.name} — ${site.name} Security Professionals card #${String(p.number).padStart(3, '0')}`, + license: 'https://creativecommons.org/licenses/by-sa/4.0/', + }, + }; + + return ( + <> +