scenri 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/{chunk-Y5L3KLX5.js → chunk-WORM6GB3.js} +139 -7
  3. package/dist/{cli-SX46UQO6.js → cli-EXASA5BK.js} +3 -3
  4. package/dist/index.js +2 -2
  5. package/dist/serve.js +2289 -337
  6. package/dist/{src-F3KJZNZT.js → src-4N4TS3HO.js} +3 -3
  7. package/package.json +1 -1
  8. package/studio-dist/assets/facial-2-BmVnDzjJ.webp +0 -0
  9. package/studio-dist/assets/facial-3-DZtKCJBc.webp +0 -0
  10. package/studio-dist/assets/facial-4-D3bBk4Y-.webp +0 -0
  11. package/studio-dist/assets/facial-5-BZUOhBlt.webp +0 -0
  12. package/studio-dist/assets/facial-6-BjNlybGd.webp +0 -0
  13. package/studio-dist/assets/freckles-1-EZ0x3-vK.webp +0 -0
  14. package/studio-dist/assets/freckles-2-DuHEq26I.webp +0 -0
  15. package/studio-dist/assets/freckles-3-WGP6r0Fh.webp +0 -0
  16. package/studio-dist/assets/glasses-1-DPlo67j6.webp +0 -0
  17. package/studio-dist/assets/glasses-2-mRgaHYWV.webp +0 -0
  18. package/studio-dist/assets/glasses-3-DuTs0m8y.webp +0 -0
  19. package/studio-dist/assets/glasses-4-DGjoOjFS.webp +0 -0
  20. package/studio-dist/assets/glasses-5-DoEVdOaY.webp +0 -0
  21. package/studio-dist/assets/glasses-6-CcUVUrfz.webp +0 -0
  22. package/studio-dist/assets/{index-BGts89lc.css → index-CVbs0aDq.css} +1 -1
  23. package/studio-dist/assets/index-CyQ6cSKd.js +107 -0
  24. package/studio-dist/assets/length-1-androgynous-DiFL-HbJ.webp +0 -0
  25. package/studio-dist/assets/length-1-man-OI1u_7yA.webp +0 -0
  26. package/studio-dist/assets/length-1-woman-BbYkzjnG.webp +0 -0
  27. package/studio-dist/assets/length-2-androgynous-zBgi1XzH.webp +0 -0
  28. package/studio-dist/assets/length-2-man-Df3fVDUO.webp +0 -0
  29. package/studio-dist/assets/length-2-woman-BP1NSRcI.webp +0 -0
  30. package/studio-dist/assets/length-3-androgynous-BN_SLD6d.webp +0 -0
  31. package/studio-dist/assets/length-3-man-Dk_3OkKz.webp +0 -0
  32. package/studio-dist/assets/length-3-woman-BFV6b4If.webp +0 -0
  33. package/studio-dist/assets/length-4-androgynous-BblLWRi-.webp +0 -0
  34. package/studio-dist/assets/length-4-man-Cszq08t7.webp +0 -0
  35. package/studio-dist/assets/length-4-woman-B8gzWrbX.webp +0 -0
  36. package/studio-dist/assets/length-5-androgynous-DwxP_mgy.webp +0 -0
  37. package/studio-dist/assets/length-5-man-Yt6YRiWE.webp +0 -0
  38. package/studio-dist/assets/length-5-woman-BTz0YJMm.webp +0 -0
  39. package/studio-dist/assets/length-6-androgynous-BD7OoH6P.webp +0 -0
  40. package/studio-dist/assets/length-6-man-oceHSSPF.webp +0 -0
  41. package/studio-dist/assets/length-6-woman-BvX37wCz.webp +0 -0
  42. package/studio-dist/assets/makeup-1-CvGOSJO_.webp +0 -0
  43. package/studio-dist/assets/makeup-3-yoncgA8G.webp +0 -0
  44. package/studio-dist/assets/makeup-4-Buulx0wj.webp +0 -0
  45. package/studio-dist/assets/makeup-5-5Y_Qpn53.webp +0 -0
  46. package/studio-dist/assets/makeup-6-CSQm_m8e.webp +0 -0
  47. package/studio-dist/assets/prosthetic-1-C-bbKoKE.webp +0 -0
  48. package/studio-dist/assets/prosthetic-2-DCGXnKzb.webp +0 -0
  49. package/studio-dist/assets/prosthetic-3-BW_gdjlG.webp +0 -0
  50. package/studio-dist/assets/prosthetic-4-ec_5-7Tb.webp +0 -0
  51. package/studio-dist/assets/prosthetic-5-res5u7gj.webp +0 -0
  52. package/studio-dist/assets/prosthetic-6-0AvaEGMf.webp +0 -0
  53. package/studio-dist/assets/tattoo-3-33-3gd4C.webp +0 -0
  54. package/studio-dist/assets/tattoo-4-BW22hxdb.webp +0 -0
  55. package/studio-dist/assets/tattoo-5-BWJwjBfm.webp +0 -0
  56. package/studio-dist/index.html +2 -2
  57. package/studio-dist/assets/index-eKmAemHO.js +0 -104
package/dist/serve.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createUpdateChecker, classify, isReleaseTriplet, findNpm, stageVersion } from './chunk-FYQ5BAFA.js';
2
2
  import { shouldAdoptRunning, anotherScenriLines, portBusyLines } from './chunk-CMLJGPYC.js';
3
- import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, budgetSize, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, termMatches, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-Y5L3KLX5.js';
3
+ import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, budgetSize, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, termMatches, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-WORM6GB3.js';
4
4
  import { detectInstallKind } from './chunk-PAIAXRAC.js';
5
5
  export { detectInstallKind } from './chunk-PAIAXRAC.js';
6
6
  import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
@@ -1238,6 +1238,15 @@ function createRunner(opts = {}) {
1238
1238
  }
1239
1239
  return { run: run2, withWorkDir, probe, invalidateProbe, connect, invalidateConnection, noteConnection };
1240
1240
  }
1241
+ var PHOTO_VIEWS = [
1242
+ "portrait",
1243
+ "front",
1244
+ "three-quarter",
1245
+ "back",
1246
+ "left",
1247
+ "right",
1248
+ "other"
1249
+ ];
1241
1250
  var OUT_FILE = "analysis.json";
1242
1251
  function createCodexAnalyzer(opts = {}) {
1243
1252
  const runner = opts.runner ?? createRunner(opts);
@@ -1289,7 +1298,10 @@ function presenterBody(req, refCount) {
1289
1298
  const same = refCount > 1 ? " Every attached photograph is the same person." : "";
1290
1299
  const ask = req.instruction ? ` The person who supplied these adds: ${req.instruction}.` : "";
1291
1300
  const categories = req.vocabulary?.categories?.length ? ` Choose "suitableCategories" only from this list: ${req.vocabulary.categories.join(", ")}.` : "";
1292
- return `These are photographs of one person, supplied by the person casting them.${same}${ask} Write a casting sheet: what a photographer would need in order to recognise this person again in a different shot. Do not name, identify, or guess who this person is, and do not use any proper name anywhere in your answer. Describe the person, not the photograph: their build, face and hair belong to them, but the background, crop, pose, clothing and lighting of these pictures belong to a shoot and must be left out. ${OUT_FILE} must be a JSON object with exactly these keys: "promptName": a short noun phrase a generator can be handed, such as "a woman in her early thirties with dark shoulder-length waves"; "presentation": either "woman" or "man"; "descriptor": a three-beat casting caption joined by " \xB7 ", such as "Warm editorial \xB7 dark waves \xB7 composed"; "ageRange": an approximate range such as "early 30s"; "hair": colour, length, texture and how it is worn; "identityNotes": one paragraph naming the two or three features that must survive every generation, drawn from face shape, eyes, nose, mouth, jaw, skin, distinctive marks, and build where it is visible; "negativeConstraints": an array of short refusals for the drift these photographs invite, such as "no youth-smoothing that erases the natural lines"; "suitableCategories": the industries this person would be cast for.${categories} "coverage": an array of at most two short sentences naming a view that is missing and would make this person more consistent, such as "A three-quarter photo would pin the cheekbones down." Use an empty array when the coverage is already good.`;
1301
+ return `These are photographs of one person, supplied by the person casting them.${same}${ask} Write a casting sheet: what a photographer would need in order to recognise this person again in a different shot. Do not name, identify, or guess who this person is, and do not use any proper name anywhere in your answer. Describe the person, not the photograph: their build, face and hair belong to them, but the background, crop, pose, clothing and lighting of these pictures belong to a shoot and must be left out. ${OUT_FILE} must be a JSON object with exactly these keys: "promptName": a short noun phrase a generator can be handed, such as "a woman in her early thirties with dark shoulder-length waves"; "presentation": either "woman" or "man"; "descriptor": a three-beat casting caption joined by " \xB7 ", such as "Warm editorial \xB7 dark waves \xB7 composed"; "ageRange": an approximate range such as "early 30s"; "hair": colour, length, texture and how it is worn; "identityNotes": one paragraph naming the two or three features that must survive every generation, drawn from face shape, eyes, nose, mouth, jaw, skin, distinctive marks, and build where it is visible; "negativeConstraints": an array of short refusals for the drift these photographs invite, such as "no youth-smoothing that erases the natural lines"; "suitableCategories": the industries this person would be cast for.${categories} "coverage": an array of at most two short sentences naming a view that is missing and would make this person more consistent, such as "A three-quarter photo would pin the cheekbones down." Use an empty array when the coverage is already good; "facial": bone structure in words, the face shape, jaw, cheekbones, eyes and brows a photographer would need to recognise them again; "skin": their skin tone and texture exactly as the photographs show it, never smoothed; "build": their body type and proportions where the photographs show them.` + (req.classifyPhotos ? photosClause(refCount) : "");
1302
+ }
1303
+ function photosClause(refCount) {
1304
+ return ` Also write "photos": an array with one entry per attached image in attachment order (ref-1.png is index 0, up to index ${Math.max(0, refCount - 1)}), each an object {"index", "view", "usable", "note"}, where "view" is exactly one of "portrait" (head and shoulders, the face large and facing the camera), "front" (full length, standing, facing the camera), "three-quarter" (turned about forty-five degrees, both eyes visible), "back" (full length, facing away from the camera), "left" (full length, the left side of the body to the camera), "right" (full length, the right side of the body to the camera), or "other"; "usable" is true only when the image is sharp, evenly lit, unobstructed, and shows this person clearly enough to stand in as that view; "note" is a few words on why. If the photographs appear to show more than one person, also write "conflict": one sentence saying which images disagree; otherwise leave "conflict" out.`;
1293
1305
  }
1294
1306
  function sceneBody(req) {
1295
1307
  const ask = req.instruction ? ` The person who chose these references says what matters in them: ${req.instruction}. Treat that as the deciding word: whatever it calls essential IS essential even if only one reference shows it, and whatever it tells you to ignore stays out even if every reference contains it.` : "";
@@ -1332,10 +1344,34 @@ function parsePresenter(req, o) {
1332
1344
  identityNotes: cap(identityNotes, 900),
1333
1345
  negativeConstraints: list(o.negativeConstraints, 6, 160),
1334
1346
  suitableCategories: pick(o.suitableCategories, req.vocabulary?.categories, 6),
1335
- coverage: list(o.coverage, 2, 160)
1347
+ coverage: list(o.coverage, 2, 160),
1348
+ // Non-blocking, like scene's `camera`: a model that omits or fumbles
1349
+ // these must not burn the single retry that exists for a broken contract.
1350
+ ...optional("facial", cap(str(o.facial), 300)),
1351
+ ...optional("skin", cap(str(o.skin), 200)),
1352
+ ...optional("build", cap(str(o.build), 200)),
1353
+ ...req.classifyPhotos ? optional("photos", photoFilings(o.photos, req.imagePaths.length)) : {},
1354
+ ...req.classifyPhotos ? optional("conflict", cap(str(o.conflict), 200)) : {}
1336
1355
  }
1337
1356
  };
1338
1357
  }
1358
+ function optional(key, value) {
1359
+ return value === void 0 || value === "" || Array.isArray(value) && value.length === 0 ? {} : { [key]: value };
1360
+ }
1361
+ function photoFilings(raw, count) {
1362
+ if (!Array.isArray(raw)) return void 0;
1363
+ const out = [];
1364
+ for (const row of raw) {
1365
+ if (!row || typeof row !== "object") continue;
1366
+ const r = row;
1367
+ const index = Number(r.index);
1368
+ if (!Number.isInteger(index) || index < 0 || index >= count) continue;
1369
+ const view = PHOTO_VIEWS.includes(str(r.view).toLowerCase()) ? str(r.view).toLowerCase() : "other";
1370
+ const usable2 = r.usable === true || /^(true|yes)$/i.test(str(r.usable));
1371
+ out.push({ index, view, usable: usable2, note: cap(str(r.note), 160) });
1372
+ }
1373
+ return out.length ? out : void 0;
1374
+ }
1339
1375
  function parseScene(req, o) {
1340
1376
  const problems = [];
1341
1377
  const prompt = str(o.prompt);
@@ -1839,9 +1875,13 @@ function demoOptionsFromEnv(env) {
1839
1875
  const out = {};
1840
1876
  const stagger = Number(env.SCENRI_DEMO_STAGGER_MS);
1841
1877
  if (env.SCENRI_DEMO_STAGGER_MS && Number.isFinite(stagger) && stagger > 0) out.staggerMs = stagger;
1878
+ const delay = Number(env.SCENRI_DEMO_DELAY_MS);
1879
+ if (env.SCENRI_DEMO_DELAY_MS && Number.isFinite(delay) && delay > 0) out.delayMs = delay;
1842
1880
  if (env.SCENRI_DEMO_ORDER === "reverse") out.order = "reverse";
1843
- const fail = Number(env.SCENRI_DEMO_FAIL_SLOT);
1844
- if (env.SCENRI_DEMO_FAIL_SLOT && Number.isInteger(fail) && fail >= 0) out.failSlot = fail;
1881
+ const fail2 = Number(env.SCENRI_DEMO_FAIL_SLOT);
1882
+ if (env.SCENRI_DEMO_FAIL_SLOT && Number.isInteger(fail2) && fail2 >= 0) out.failSlot = fail2;
1883
+ const refs = Number(env.SCENRI_DEMO_REFS);
1884
+ if (env.SCENRI_DEMO_REFS && Number.isInteger(refs) && refs > 0) out.maxReferenceImages = refs;
1845
1885
  return out;
1846
1886
  }
1847
1887
  function sleep2(ms, signal) {
@@ -1894,7 +1934,7 @@ function createDemoEngine(saveImage, opts = {}) {
1894
1934
  // development and in the end-to-end suite. `placeholder` below is what
1895
1935
  // says none of this is a real picture.
1896
1936
  supportsOutpaint: true,
1897
- maxReferenceImages: 0,
1937
+ maxReferenceImages: opts.maxReferenceImages ?? 0,
1898
1938
  placeholder: true
1899
1939
  };
1900
1940
  },
@@ -1912,6 +1952,7 @@ function createDemoEngine(saveImage, opts = {}) {
1912
1952
  const landed = /* @__PURE__ */ new Map();
1913
1953
  const failures = [];
1914
1954
  for (const [position, slot] of slots.entries()) {
1955
+ if (opts.delayMs) await sleep2(opts.delayMs, signal);
1915
1956
  if (position > 0 && opts.staggerMs) await sleep2(opts.staggerMs, signal);
1916
1957
  if (signal?.aborted) {
1917
1958
  if (signal.reason === BUDGET_EXHAUSTED) break;
@@ -1921,7 +1962,9 @@ function createDemoEngine(saveImage, opts = {}) {
1921
1962
  failures.push(`demo: slot ${slot + 1} refused`);
1922
1963
  continue;
1923
1964
  }
1924
- const hash = saveImage(await render(colors, req.prompt, req.width, req.height, slot + req.prompt.length));
1965
+ const refs = req.referenceImages?.length ?? 0;
1966
+ const label = refs ? `refs=${refs} ${req.prompt}` : req.prompt;
1967
+ const hash = saveImage(await render(colors, label, req.width, req.height, slot + req.prompt.length));
1925
1968
  landed.set(slot, hash);
1926
1969
  onImage?.(slot, hash);
1927
1970
  }
@@ -1943,6 +1986,30 @@ function createDemoEngine(saveImage, opts = {}) {
1943
1986
  }
1944
1987
  };
1945
1988
  }
1989
+ function createDemoAnalyzer(opts = {}) {
1990
+ const rejects = opts.photos === "unusable";
1991
+ const filing = (i) => ({
1992
+ index: i,
1993
+ view: rejects || i > 0 ? "other" : "portrait",
1994
+ usable: !rejects,
1995
+ note: rejects ? "too unclear to read a face from" : "sharp enough"
1996
+ });
1997
+ return {
1998
+ isAvailable: async () => ({ ok: true }),
1999
+ analyze: async (req) => ({
2000
+ promptName: "a person in their thirties",
2001
+ presentation: "woman",
2002
+ descriptor: "Demo read",
2003
+ ageRange: "30s",
2004
+ hair: "dark hair",
2005
+ identityNotes: "read by the demo analyzer, which never looked at anything",
2006
+ negativeConstraints: [],
2007
+ suitableCategories: [],
2008
+ coverage: [],
2009
+ photos: req.imagePaths.map((_, i) => filing(i))
2010
+ })
2011
+ };
2012
+ }
1946
2013
  var SUBJECTS = /* @__PURE__ */ new Set(["product", "person", "either"]);
1947
2014
  var ID = /^[a-z0-9-]+$/;
1948
2015
  function isScene(x) {
@@ -2246,6 +2313,10 @@ var SEAT_TIER = {
2246
2313
  product: 0,
2247
2314
  character: 0,
2248
2315
  scene: 0,
2316
+ // One detail of a person, behind every chip they placed: a picture of the
2317
+ // exact glasses is worth having, and never worth the product or the face it
2318
+ // would push out of a four-slot engine.
2319
+ detail: 1,
2249
2320
  composition: 1,
2250
2321
  style: 2
2251
2322
  };
@@ -2255,8 +2326,9 @@ var ROLE_PRIORITY = {
2255
2326
  brand: 2,
2256
2327
  reference: 3,
2257
2328
  scene: 4,
2258
- composition: 5,
2259
- style: 6
2329
+ detail: 5,
2330
+ composition: 6,
2331
+ style: 7
2260
2332
  };
2261
2333
  function allocateAttachments(attachments, cap2) {
2262
2334
  const max = Math.max(0, cap2);
@@ -2392,6 +2464,13 @@ function createThumbStore(core, opts = {}) {
2392
2464
  async settle() {
2393
2465
  await Promise.allSettled([...inflight.values()]);
2394
2466
  },
2467
+ evict(hash) {
2468
+ if (!/^[a-f0-9]{32}$/.test(hash)) return;
2469
+ for (const w of THUMB_WIDTHS) {
2470
+ failed.delete(`${hash}-w${w}`);
2471
+ rmSync(pathFor(hash, w), { force: true });
2472
+ }
2473
+ },
2395
2474
  clear() {
2396
2475
  failed.clear();
2397
2476
  rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
@@ -2513,6 +2592,19 @@ var serveJpegSized = async (req, reply, path, thumbs, key) => {
2513
2592
  reply.header("content-type", "image/webp").header("cache-control", immutable).header("etag", etag).header("content-length", String(size));
2514
2593
  return reply.send(thumbs.stream(made));
2515
2594
  };
2595
+ async function pickBuildEngine(engines, opts = {}) {
2596
+ const ordered = [...engines.all()].sort((a, b) => {
2597
+ const rank = (e) => e.capabilities().id === "codex-cli" ? 0 : 1;
2598
+ return rank(a) - rank(b);
2599
+ });
2600
+ for (const engine of ordered) {
2601
+ const caps = engine.capabilities();
2602
+ if (!caps.maxReferenceImages) continue;
2603
+ if (caps.placeholder && !opts.allowPlaceholder) continue;
2604
+ if ((await engine.isAvailable()).ok) return engine;
2605
+ }
2606
+ return null;
2607
+ }
2516
2608
 
2517
2609
  // src/briefDirectives.ts
2518
2610
  function productFidelityDirective(attached) {
@@ -2599,6 +2691,41 @@ function shotAsksForAPerson(text) {
2599
2691
  text
2600
2692
  );
2601
2693
  }
2694
+ function askedView(text) {
2695
+ if (/\b(from behind|from the back|back view|walking away|rear view|seen from behind)\b/i.test(text)) return "back";
2696
+ if (/\b(in profile|profile view|side view|from the side)\b/i.test(text)) return "profile";
2697
+ if (/\b(three[- ]quarter|turned|over the shoulder|over-the-shoulder)\b/i.test(text)) return "three-quarter";
2698
+ return null;
2699
+ }
2700
+ var ANSWERING_ANGLES = {
2701
+ back: ["back"],
2702
+ profile: ["left", "right", "left-profile", "right-profile"],
2703
+ "three-quarter": ["three-quarter"]
2704
+ };
2705
+ function characterRefs(shots, userWords, max) {
2706
+ const cap2 = Math.max(0, max);
2707
+ const out = [];
2708
+ const seen = /* @__PURE__ */ new Set();
2709
+ const board = (s) => {
2710
+ if (!s || out.length >= cap2) return;
2711
+ const file = s.file == null ? null : String(s.file);
2712
+ if (file !== null) {
2713
+ if (seen.has(file)) return;
2714
+ seen.add(file);
2715
+ }
2716
+ out.push(s);
2717
+ };
2718
+ const byAngle = (angle) => shots.find((s) => s.angle === angle);
2719
+ board(byAngle("portrait") ?? shots[0]);
2720
+ const asked = askedView(userWords);
2721
+ if (asked) {
2722
+ const answer = ANSWERING_ANGLES[asked].map(byAngle).find(Boolean);
2723
+ board(answer);
2724
+ }
2725
+ board(byAngle("front"));
2726
+ for (const s of shots) board(s);
2727
+ return out;
2728
+ }
2602
2729
  function sceneFigureDirectives(opts) {
2603
2730
  const figure = opts.figure.trim().replace(/[.\s]+$/, "");
2604
2731
  if (!figure) return [];
@@ -2716,7 +2843,7 @@ function validateBrief(brief) {
2716
2843
  const b = brief;
2717
2844
  if (!b || typeof b !== "object") return ["brief must be an object"];
2718
2845
  if (!Array.isArray(b.tokens)) return ["brief.tokens must be an array"];
2719
- const str4 = (v) => typeof v === "string" && v.length > 0;
2846
+ const str5 = (v) => typeof v === "string" && v.length > 0;
2720
2847
  b.tokens.forEach((raw, i) => {
2721
2848
  const at = `tokens[${i}]`;
2722
2849
  if (!raw || typeof raw !== "object") {
@@ -2729,19 +2856,19 @@ function validateBrief(brief) {
2729
2856
  if (typeof t.v !== "string") errors.push(`${at}.v must be a string`);
2730
2857
  break;
2731
2858
  case "product":
2732
- if (!str4(t.id)) errors.push(`${at}.id must be a non-empty string`);
2733
- if (t.angle !== void 0 && !str4(t.angle)) errors.push(`${at}.angle must be a non-empty string`);
2859
+ if (!str5(t.id)) errors.push(`${at}.id must be a non-empty string`);
2860
+ if (t.angle !== void 0 && !str5(t.angle)) errors.push(`${at}.angle must be a non-empty string`);
2734
2861
  break;
2735
2862
  case "character":
2736
2863
  case "template":
2737
- if (!str4(t.id)) errors.push(`${at}.id must be a non-empty string`);
2864
+ if (!str5(t.id)) errors.push(`${at}.id must be a non-empty string`);
2738
2865
  break;
2739
2866
  case "color":
2740
- if (!str4(t.hex) || !/^#[0-9a-fA-F]{6}$/.test(String(t.hex))) errors.push(`${at}.hex must be a #RRGGBB color`);
2867
+ if (!str5(t.hex) || !/^#[0-9a-fA-F]{6}$/.test(String(t.hex))) errors.push(`${at}.hex must be a #RRGGBB color`);
2741
2868
  break;
2742
2869
  case "ref":
2743
2870
  case "mark":
2744
- if (!str4(t.imageHash)) errors.push(`${at}.imageHash must be a non-empty string`);
2871
+ if (!str5(t.imageHash)) errors.push(`${at}.imageHash must be a non-empty string`);
2745
2872
  if (t.t === "ref" && t.label !== void 0 && typeof t.label !== "string")
2746
2873
  errors.push(`${at}.label must be a string when present`);
2747
2874
  break;
@@ -2771,7 +2898,7 @@ function compileBrief(brief, ctx) {
2771
2898
  let hasPerson = false;
2772
2899
  let people = 0;
2773
2900
  let sentence = "";
2774
- let userWords = "";
2901
+ const userWords = brief.tokens.map((t) => t.t === "text" ? ` ${t.v}` : "").join("");
2775
2902
  const append = (s) => {
2776
2903
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
2777
2904
  };
@@ -2782,7 +2909,6 @@ function compileBrief(brief, ctx) {
2782
2909
  switch (tok.t) {
2783
2910
  case "text":
2784
2911
  append(tok.v);
2785
- userWords += ` ${tok.v}`;
2786
2912
  break;
2787
2913
  case "product": {
2788
2914
  const p = products.find((x) => x.id === tok.id);
@@ -2833,7 +2959,11 @@ function compileBrief(brief, ctx) {
2833
2959
  hasPerson = true;
2834
2960
  people += 1;
2835
2961
  append(c.promptName ?? c.name);
2836
- const cshots = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => ({ h: assetHash2(s?.file), angle: s?.angle ? String(s.angle) : void 0 })).filter((x) => !!x.h && ctx.images.has(x.h));
2962
+ const cshots = [];
2963
+ for (const s of characterRefs(c.shots ?? [], userWords, CHARACTER_REF_MAX)) {
2964
+ const h = assetHash2(s?.file);
2965
+ if (h && ctx.images.has(h)) cshots.push({ h, ...s?.angle ? { angle: String(s.angle) } : {} });
2966
+ }
2837
2967
  if (cshots.length) {
2838
2968
  cshots.forEach(({ h, angle }, i) => {
2839
2969
  attachments.push({
@@ -3571,6 +3701,50 @@ var brand_schema_default = {
3571
3701
  avatar: {
3572
3702
  $ref: "#/$defs/assetRef",
3573
3703
  description: "Square head-and-shoulders portrait, for surfaces that render a person small or round. Also a crop of the first shot."
3704
+ },
3705
+ source: {
3706
+ enum: ["synthetic", "photos"],
3707
+ description: "Where this person came from: made in Scenri from a description (synthetic) or built from photographs of a real person (photos). Absent on a record written before the distinction existed, which is read as photos. There is no third value: a public figure is not a source Scenri offers."
3708
+ },
3709
+ likeness: {
3710
+ type: "object",
3711
+ additionalProperties: false,
3712
+ required: ["attestedAt", "version"],
3713
+ properties: {
3714
+ attestedAt: {
3715
+ type: "string",
3716
+ description: "When the person casting them confirmed they have permission to use this real person's likeness commercially (ISO 8601)."
3717
+ },
3718
+ version: { const: "v1", description: "Which wording of the confirmation was shown." }
3719
+ },
3720
+ description: "The likeness confirmation recorded for a person built from photographs of a real person. Never set on a synthetic person."
3721
+ },
3722
+ facial: {
3723
+ type: "string",
3724
+ description: "Bone structure in words, because full-length pictures cannot carry it: face shape, jaw, cheekbones, eyes, brows. Injected verbatim, name-prefixed."
3725
+ },
3726
+ skin: {
3727
+ type: "string",
3728
+ description: "Skin tone and texture exactly as the references show it. Injected verbatim, name-prefixed."
3729
+ },
3730
+ build: {
3731
+ type: "string",
3732
+ description: "Body type and proportions. Injected verbatim, name-prefixed."
3733
+ },
3734
+ revisionOf: {
3735
+ type: "string",
3736
+ description: "The record this one replaced: an edit that changes a picture or the identity prose is a new record, so shots made against the old one keep refining against what they were made from."
3737
+ },
3738
+ supersededBy: {
3739
+ type: "string",
3740
+ description: "The record that replaced this one. Absent on the current record; a tool lists only records without it and maps an old id to the current record through it."
3741
+ },
3742
+ identityEdits: {
3743
+ type: "array",
3744
+ items: {
3745
+ type: "string"
3746
+ },
3747
+ description: `The identity-wide instructions accepted when this record's views were drawn, e.g. "shorter hair". Read after identityNotes, so later views follow the change rather than the originals.`
3574
3748
  }
3575
3749
  }
3576
3750
  },
@@ -4274,19 +4448,70 @@ function svgAsMark(markup, longEdge = 1024) {
4274
4448
 
4275
4449
  // ../brand-spec/src/scrapeError.ts
4276
4450
  var ScrapeError = class extends Error {
4277
- constructor(code, message, statusCode = 502) {
4451
+ constructor(code, message, statusCode = 502, httpStatus) {
4278
4452
  super(message);
4279
4453
  this.code = code;
4280
4454
  this.statusCode = statusCode;
4455
+ this.httpStatus = httpStatus;
4281
4456
  }
4282
4457
  code;
4283
4458
  statusCode;
4459
+ httpStatus;
4284
4460
  name = "ScrapeError";
4285
4461
  };
4462
+ function isRefusal(err) {
4463
+ if (!(err instanceof ScrapeError)) return false;
4464
+ if (err.code === "timeout") return true;
4465
+ if (err.code !== "http_status" || err.httpStatus === void 0) return false;
4466
+ return err.httpStatus === 401 || err.httpStatus === 403 || err.httpStatus === 429 || err.httpStatus >= 500;
4467
+ }
4286
4468
  function urlRefusal(reason, message) {
4287
4469
  return new ScrapeError(`url_${reason}`, message, 400);
4288
4470
  }
4289
4471
 
4472
+ // ../brand-spec/src/hostManners.ts
4473
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
4474
+ var cooledUntil = /* @__PURE__ */ new Map();
4475
+ var hostOf = (url) => {
4476
+ try {
4477
+ return new URL(url).host;
4478
+ } catch {
4479
+ return "";
4480
+ }
4481
+ };
4482
+ async function waitForHost(host, signal) {
4483
+ if (!host) return;
4484
+ for (; ; ) {
4485
+ const left = (cooledUntil.get(host) ?? 0) - Date.now();
4486
+ if (left <= 0 || signal?.aborted) return;
4487
+ await sleep3(Math.min(left, 250));
4488
+ }
4489
+ }
4490
+ function coolHost(host, ms) {
4491
+ if (!host || ms <= 0) return;
4492
+ const until = Date.now() + ms;
4493
+ if (until > (cooledUntil.get(host) ?? 0)) cooledUntil.set(host, until);
4494
+ }
4495
+ var RETRY_AFTER_CAP_MS = 6e4;
4496
+ function retryAfterMs(header, now = Date.now()) {
4497
+ if (!header) return null;
4498
+ const secs = Number(header.trim());
4499
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
4500
+ const at = Date.parse(header);
4501
+ if (Number.isNaN(at)) return null;
4502
+ return Math.min(Math.max(0, at - now), RETRY_AFTER_CAP_MS);
4503
+ }
4504
+ function isChallenge(res) {
4505
+ if (res.status !== 429 && res.status !== 503) return false;
4506
+ if (res.headers.get("cf-mitigated")) return true;
4507
+ if (res.headers.get("retry-after")) return false;
4508
+ return /text\/html/i.test(res.headers.get("content-type") ?? "");
4509
+ }
4510
+ function throttleBackoff(attempt) {
4511
+ const base = Math.min(1e3 * 2 ** attempt, 16e3);
4512
+ return base + Math.floor(Math.random() * 400);
4513
+ }
4514
+
4290
4515
  // ../brand-spec/src/safeFetch.ts
4291
4516
  var DEFAULTS = {
4292
4517
  budgetMs: 2e4,
@@ -4380,6 +4605,7 @@ function createGuardedFetch(opts = {}) {
4380
4605
  }
4381
4606
  }
4382
4607
  assertPublicHost(u.hostname, addresses, Boolean(o.allowPrivateHosts));
4608
+ await waitForHost(u.host);
4383
4609
  const left = remaining();
4384
4610
  if (left <= 0) throw new ScrapeError("timeout", "Reading that site took too long.");
4385
4611
  const signal = AbortSignal.any([AbortSignal.timeout(Math.min(o.requestMs, left))]);
@@ -4409,14 +4635,24 @@ function createGuardedFetch(opts = {}) {
4409
4635
  continue;
4410
4636
  }
4411
4637
  if (!res.ok) {
4412
- if (RETRYABLE.has(res.status) && attempts < 1 && remaining() > 2e3) {
4638
+ const refused = () => new ScrapeError("http_status", statusSentence(u.hostname, res.status, isChallenge(res)), 502, res.status);
4639
+ if (isChallenge(res)) {
4413
4640
  await res.body?.cancel().catch(() => {
4414
4641
  });
4415
- await sleep3(700);
4416
- attempts++;
4417
- continue;
4642
+ throw refused();
4643
+ }
4644
+ if (RETRYABLE.has(res.status)) {
4645
+ const wait = retryAfterMs(res.headers.get("retry-after")) ?? throttleBackoff(attempts);
4646
+ coolHost(u.host, wait);
4647
+ if (attempts < 1 && remaining() > wait + 2e3) {
4648
+ await res.body?.cancel().catch(() => {
4649
+ });
4650
+ await sleep4(wait);
4651
+ attempts++;
4652
+ continue;
4653
+ }
4418
4654
  }
4419
- throw new ScrapeError("http_status", statusSentence(u.hostname, res.status));
4655
+ throw refused();
4420
4656
  }
4421
4657
  const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
4422
4658
  const cap2 = o[LIMIT[as]];
@@ -4437,10 +4673,10 @@ function createGuardedFetch(opts = {}) {
4437
4673
  return guarded;
4438
4674
  }
4439
4675
  var USER_AGENT = "scenri/0.1 (+https://scenri.co)";
4440
- var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
4676
+ var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
4441
4677
  var RETRYABLE = /* @__PURE__ */ new Set([403, 408, 425, 429, 500, 502, 503, 504]);
4442
- function statusSentence(host, status) {
4443
- if (status === 401 || status === 403)
4678
+ function statusSentence(host, status, challenged = false) {
4679
+ if (challenged || status === 401 || status === 403)
4444
4680
  return `${host} would not let Scenri read it. Some sites block anything that is not a person in a browser; you can still add the logo and colours by hand.`;
4445
4681
  if (status === 404) return `There is no page at that address on ${host}.`;
4446
4682
  if (status === 429) return `${host} asked Scenri to slow down. Try again in a minute.`;
@@ -4538,7 +4774,10 @@ function unwrap(value) {
4538
4774
  const pairs = [
4539
4775
  ["<", ">"],
4540
4776
  ['"', '"'],
4541
- ["'", "'"]
4777
+ ["'", "'"],
4778
+ ["\u201C", "\u201D"],
4779
+ ["\u2018", "\u2019"],
4780
+ ["\xAB", "\xBB"]
4542
4781
  ];
4543
4782
  for (const [open, close] of pairs) {
4544
4783
  if (value.length >= 2 && value.startsWith(open) && value.endsWith(close)) return value.slice(1, -1).trim();
@@ -4589,7 +4828,25 @@ async function buildFromUrl(url, opts = {}) {
4589
4828
  ...opts.guard,
4590
4829
  ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl, allowPrivateHosts: true } : {}
4591
4830
  });
4592
- const page = await get(normalized.url, "html");
4831
+ let page = null;
4832
+ let refused = null;
4833
+ let missed = null;
4834
+ for (const candidate of readOrder(normalized.url)) {
4835
+ try {
4836
+ page = await get(candidate, "html");
4837
+ break;
4838
+ } catch (err) {
4839
+ if (isRefusal(err)) {
4840
+ refused = err;
4841
+ break;
4842
+ }
4843
+ missed = err;
4844
+ }
4845
+ }
4846
+ if (!page) {
4847
+ if (refused) return addressOnlyBrand(normalized.url, refused.message, opts);
4848
+ throw missed;
4849
+ }
4593
4850
  const origin = new URL(page.finalUrl);
4594
4851
  const $ = cheerio.load(page.text);
4595
4852
  const base = baseOf($, origin);
@@ -4622,11 +4879,12 @@ async function buildFromUrl(url, opts = {}) {
4622
4879
  const manifest = await readManifest($, base, get);
4623
4880
  const candidates = logoCandidates($, base, manifest);
4624
4881
  const picked = await downloadMark(candidates, get, opts, warnings);
4882
+ const iconRef = await downloadIcon(candidates, get, opts, picked);
4625
4883
  const brand = {
4626
4884
  specVersion: "0.1",
4627
4885
  meta: {
4628
4886
  name: named.value,
4629
- slug: named.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || origin.hostname,
4887
+ slug: slugOf(named.value, origin.hostname),
4630
4888
  ...tagline ? { tagline } : {},
4631
4889
  website: origin.origin,
4632
4890
  createdWith: opts.createdWith ?? "scenri",
@@ -4640,13 +4898,16 @@ async function buildFromUrl(url, opts = {}) {
4640
4898
  ...palette.neutrals.length ? { neutrals: palette.neutrals.map((hex) => ({ hex })) } : {}
4641
4899
  }
4642
4900
  } : {},
4643
- ...picked.ref ? {
4901
+ ...picked.ref || iconRef ? {
4644
4902
  logos: [
4645
- {
4646
- role: picked.role,
4647
- file: picked.ref,
4648
- ...picked.background ? { background: picked.background } : {}
4649
- }
4903
+ ...picked.ref ? [
4904
+ {
4905
+ role: picked.role,
4906
+ file: picked.ref,
4907
+ ...picked.background ? { background: picked.background } : {}
4908
+ }
4909
+ ] : [],
4910
+ ...iconRef ? [{ role: "mark", file: iconRef }] : []
4650
4911
  ]
4651
4912
  } : {}
4652
4913
  };
@@ -4656,6 +4917,7 @@ async function buildFromUrl(url, opts = {}) {
4656
4917
  report: {
4657
4918
  url: page.finalUrl,
4658
4919
  host: origin.hostname,
4920
+ read: true,
4659
4921
  name: named,
4660
4922
  tagline: tagline ?? null,
4661
4923
  logo: {
@@ -4668,6 +4930,45 @@ async function buildFromUrl(url, opts = {}) {
4668
4930
  }
4669
4931
  };
4670
4932
  }
4933
+ function readOrder(url) {
4934
+ try {
4935
+ const u = new URL(url);
4936
+ return u.pathname === "/" && !u.search ? [url] : [url, u.origin];
4937
+ } catch {
4938
+ return [url];
4939
+ }
4940
+ }
4941
+ function slugOf(name, fallback) {
4942
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || fallback;
4943
+ }
4944
+ function addressOnlyBrand(url, reason, opts) {
4945
+ const origin = new URL(url);
4946
+ const name = origin.hostname.replace(/^www\./, "");
4947
+ return {
4948
+ brand: {
4949
+ specVersion: "0.1",
4950
+ meta: {
4951
+ name,
4952
+ slug: slugOf(name, origin.hostname),
4953
+ website: origin.origin,
4954
+ createdWith: opts.createdWith ?? "scenri",
4955
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4956
+ }
4957
+ },
4958
+ // The first sentence is the site's own refusal, already written for a
4959
+ // person by `statusSentence`; the second is what they can do about it.
4960
+ warnings: [reason, "The brand was made from the address. Add the logo and colours in Settings."],
4961
+ report: {
4962
+ url: origin.origin,
4963
+ host: origin.hostname,
4964
+ read: false,
4965
+ name: { value: name, source: "hostname" },
4966
+ tagline: null,
4967
+ logo: { status: "none", source: null },
4968
+ colors: { count: 0 }
4969
+ }
4970
+ };
4971
+ }
4671
4972
  function baseOf($, origin) {
4672
4973
  const declared = $("base[href]").attr("href");
4673
4974
  if (!declared) return origin;
@@ -4765,14 +5066,9 @@ async function downloadMark(candidates, get, opts, warnings) {
4765
5066
  for (const candidate of candidates) {
4766
5067
  if (tried >= LOGO_TRIES || get.remaining() <= 0) break;
4767
5068
  let buf = null;
4768
- if (candidate.svg) {
4769
- const sized = svgAsMark(candidate.svg);
4770
- if (!sized) continue;
4771
- buf = Buffer.from(sized, "utf8");
4772
- } else if (candidate.url?.startsWith("data:")) {
4773
- const comma = candidate.url.indexOf(",");
4774
- const body = candidate.url.slice(comma + 1);
4775
- buf = candidate.url.slice(0, comma).includes(";base64") ? Buffer.from(body, "base64") : Buffer.from(decodeURIComponent(body), "utf8");
5069
+ if (carriesBytes(candidate)) {
5070
+ buf = inlineBytes(candidate);
5071
+ if (!buf) continue;
4776
5072
  } else if (candidate.url) {
4777
5073
  tried++;
4778
5074
  try {
@@ -4826,6 +5122,45 @@ async function downloadMark(candidates, get, opts, warnings) {
4826
5122
  warnings.push("No logo captured. Add one manually.");
4827
5123
  return { role: "primary", source: null };
4828
5124
  }
5125
+ function carriesBytes(candidate) {
5126
+ return Boolean(candidate.svg) || Boolean(candidate.url?.startsWith("data:"));
5127
+ }
5128
+ function inlineBytes(candidate) {
5129
+ if (candidate.svg) {
5130
+ const sized = svgAsMark(candidate.svg);
5131
+ return sized ? Buffer.from(sized, "utf8") : null;
5132
+ }
5133
+ if (!candidate.url) return null;
5134
+ const comma = candidate.url.indexOf(",");
5135
+ const body = candidate.url.slice(comma + 1);
5136
+ return candidate.url.slice(0, comma).includes(";base64") ? Buffer.from(body, "base64") : Buffer.from(decodeURIComponent(body), "utf8");
5137
+ }
5138
+ async function downloadIcon(candidates, get, opts, picked) {
5139
+ if (!opts.saveAsset) return null;
5140
+ if (picked.source && ICON_SOURCES.has(picked.source)) return null;
5141
+ const best = candidates.find((c) => ICON_SOURCES.has(c.source) && (c.svg || c.url));
5142
+ if (!best) return null;
5143
+ let buf = null;
5144
+ if (carriesBytes(best)) {
5145
+ buf = inlineBytes(best);
5146
+ } else if (best.url) {
5147
+ if (get.remaining() <= 0) return null;
5148
+ try {
5149
+ buf = (await get(best.url, "asset")).bytes;
5150
+ } catch {
5151
+ return null;
5152
+ }
5153
+ }
5154
+ if (!buf || buf.length === 0) return null;
5155
+ try {
5156
+ const ref = await opts.saveAsset(buf, "logo");
5157
+ if (ref === picked.ref) return null;
5158
+ const shape = opts.inspectMark ? await opts.inspectMark(buf).catch(() => null) : null;
5159
+ return shape?.blank ? null : ref;
5160
+ } catch {
5161
+ return null;
5162
+ }
5163
+ }
4829
5164
 
4830
5165
  // ../brand-spec/src/mergeScrape.ts
4831
5166
  var str2 = (v) => typeof v === "string" ? v.trim() : "";
@@ -4887,6 +5222,67 @@ async function inspectMark(buf, toPng2) {
4887
5222
  };
4888
5223
  }
4889
5224
 
5225
+ // ../catalog/src/failures.ts
5226
+ function pageFailure(status, challenged = false) {
5227
+ if (challenged) return "CHALLENGED";
5228
+ if (status === 429 || status === 503) return "RATE_LIMITED";
5229
+ if (status === 401 || status === 403 || status === 405) return "PAGE_BLOCKED";
5230
+ if (status === 404 || status === 410) return "PAGE_NOT_FOUND";
5231
+ return "PAGE_UNREADABLE";
5232
+ }
5233
+ function imageFailure(status) {
5234
+ if (status === 429 || status === 503) return "RATE_LIMITED";
5235
+ if (status === 401 || status === 403) return "IMAGE_FORBIDDEN";
5236
+ if (status === 404 || status === 410) return "IMAGE_NOT_FOUND";
5237
+ return "DOWNLOAD_FAILED";
5238
+ }
5239
+ function thrownFailure(err, aborted = false) {
5240
+ if (aborted) return "ABORTED";
5241
+ const m = String(err?.message ?? err).toLowerCase();
5242
+ if (m.includes("abort") || m.includes("timed out") || m.includes("timeout")) return "IMAGE_TIMEOUT";
5243
+ return "DOWNLOAD_FAILED";
5244
+ }
5245
+ function tally(into, reason, n = 1) {
5246
+ into[reason] = (into[reason] ?? 0) + n;
5247
+ }
5248
+ function leadingReason(t) {
5249
+ const rows = Object.entries(t).filter(([, n]) => n > 0);
5250
+ if (!rows.length) return null;
5251
+ return rows.sort((a, b) => b[1] - a[1])[0][0];
5252
+ }
5253
+ function shortfall(asked, worked) {
5254
+ return Math.max(0, asked - Math.max(0, worked));
5255
+ }
5256
+ function summarise(t, saved, asked, failedCount) {
5257
+ const reason = leadingReason(t);
5258
+ if (!reason) return null;
5259
+ const none = saved === 0;
5260
+ const failed = Math.min(asked, failedCount ?? Math.max(0, asked - saved));
5261
+ switch (reason) {
5262
+ case "RATE_LIMITED":
5263
+ return none ? "The store asked us to slow down, so nothing could be read. Waiting a few minutes and trying again usually works." : `The store asked us to slow down partway, so ${failed} of ${asked} products were skipped. Trying again picks up the rest.`;
5264
+ // Never "try again in a few minutes": this one does not clear with time,
5265
+ // and saying so would be advice that cannot work.
5266
+ case "CHALLENGED":
5267
+ return none ? "This store checks that visitors are a web browser, so its catalogue could not be read automatically. You can still add products by hand." : `This store started checking that visitors are a web browser, so ${failed} of ${asked} products were skipped.`;
5268
+ case "PAGE_BLOCKED":
5269
+ return none ? "This store would not let us read its product pages." : `${failed} of ${asked} product pages would not open.`;
5270
+ case "PAGE_NOT_FOUND":
5271
+ return `${failed} of ${asked} products are no longer on the site.`;
5272
+ case "IMAGE_FORBIDDEN":
5273
+ case "IMAGE_NOT_FOUND":
5274
+ case "IMAGE_TIMEOUT":
5275
+ case "IMAGE_EMPTY":
5276
+ case "UNSUPPORTED_MEDIA":
5277
+ case "DOWNLOAD_FAILED":
5278
+ return none ? "We found the products, but none of their pictures could be downloaded." : "Some pictures could not be downloaded. The products they belong to are still here.";
5279
+ case "ABORTED":
5280
+ return null;
5281
+ default:
5282
+ return none ? "The products on this site could not be read." : null;
5283
+ }
5284
+ }
5285
+
4890
5286
  // ../catalog/src/url.ts
4891
5287
  function normalizeStoreUrl(input) {
4892
5288
  let raw = input.trim();
@@ -4919,6 +5315,19 @@ function absolutize(base, href) {
4919
5315
  return null;
4920
5316
  }
4921
5317
  }
5318
+ var CARD_WIDTH = 400;
5319
+ function cardImageUrl(url, width = CARD_WIDTH) {
5320
+ try {
5321
+ const u = new URL(url);
5322
+ for (const key of u.searchParams.keys()) {
5323
+ if (/^(x-amz-|signature|sig|token|expires|policy)/i.test(key)) return url;
5324
+ }
5325
+ u.searchParams.set("width", String(width));
5326
+ return u.toString();
5327
+ } catch {
5328
+ return url;
5329
+ }
5330
+ }
4922
5331
  function upgradeImageUrl(url) {
4923
5332
  try {
4924
5333
  const u = new URL(url);
@@ -5102,7 +5511,7 @@ async function readBounded2(res, maxBytes) {
5102
5511
  }
5103
5512
  return out + decoder.decode();
5104
5513
  }
5105
- function sleep4(ms) {
5514
+ function sleep5(ms) {
5106
5515
  return new Promise((r) => setTimeout(r, ms));
5107
5516
  }
5108
5517
  async function httpGet(url, opts = {}) {
@@ -5110,7 +5519,10 @@ async function httpGet(url, opts = {}) {
5110
5519
  const retries = opts.retries ?? 3;
5111
5520
  const timeoutMs = opts.timeoutMs ?? 25e3;
5112
5521
  let lastErr;
5522
+ const host = hostOf(url);
5113
5523
  for (let attempt = 0; attempt <= retries; attempt++) {
5524
+ await waitForHost(host, opts.signal);
5525
+ if (opts.signal?.aborted) throw new Error("aborted");
5114
5526
  const ctrl = new AbortController();
5115
5527
  const onAbort = () => ctrl.abort();
5116
5528
  opts.signal?.addEventListener("abort", onAbort, { once: true });
@@ -5142,8 +5554,20 @@ async function httpGet(url, opts = {}) {
5142
5554
  } catch {
5143
5555
  }
5144
5556
  }
5145
- if ((res.status === 429 || res.status >= 500) && attempt < retries) {
5146
- await sleep4(400 * 2 ** attempt);
5557
+ if (isChallenge(res)) return res;
5558
+ if (res.status === 429 || res.status === 503) {
5559
+ const asked = retryAfterMs(res.headers.get("retry-after"));
5560
+ const wait = asked ?? throttleBackoff(attempt);
5561
+ coolHost(host, wait);
5562
+ if (attempt < retries) {
5563
+ await res.body?.cancel().catch(() => {
5564
+ });
5565
+ continue;
5566
+ }
5567
+ return res;
5568
+ }
5569
+ if (res.status >= 500 && attempt < retries) {
5570
+ await sleep5(throttleBackoff(attempt));
5147
5571
  continue;
5148
5572
  }
5149
5573
  return res;
@@ -5151,7 +5575,7 @@ async function httpGet(url, opts = {}) {
5151
5575
  lastErr = err;
5152
5576
  if (opts.signal?.aborted) throw err;
5153
5577
  if (attempt < retries) {
5154
- await sleep4(400 * 2 ** attempt);
5578
+ await sleep5(400 * 2 ** attempt);
5155
5579
  continue;
5156
5580
  }
5157
5581
  throw err;
@@ -5164,12 +5588,14 @@ async function httpGet(url, opts = {}) {
5164
5588
  }
5165
5589
  async function httpText(url, opts = {}) {
5166
5590
  const res = await httpGet(url, opts);
5591
+ const challenged = isChallenge(res);
5167
5592
  const text = await readBounded2(res, opts.maxBytes);
5168
- return { ok: res.ok, status: res.status, text, url: res.url || url };
5593
+ return { ok: res.ok, status: res.status, text, url: res.url || url, challenged };
5169
5594
  }
5170
5595
  async function httpJson(url, opts = {}) {
5171
5596
  const res = await httpGet(url, { ...opts, accept: opts.accept ?? "application/json" });
5172
- const text = await res.text();
5597
+ const challenged = isChallenge(res);
5598
+ const text = await readBounded2(res, opts.maxBytes ?? 16e6);
5173
5599
  let json = null;
5174
5600
  if (res.ok) {
5175
5601
  try {
@@ -5178,7 +5604,10 @@ async function httpJson(url, opts = {}) {
5178
5604
  json = null;
5179
5605
  }
5180
5606
  }
5181
- return { ok: res.ok && json !== null, status: res.status, json, url: res.url || url, text };
5607
+ return { ok: res.ok && json !== null, status: res.status, json, url: res.url || url, text, challenged };
5608
+ }
5609
+ function outOfTime(deadline) {
5610
+ return deadline != null && Date.now() > deadline;
5182
5611
  }
5183
5612
  async function mapPool(items, concurrency, fn, signal) {
5184
5613
  const results = new Array(items.length);
@@ -5335,6 +5764,16 @@ var PRODUCT_MARKERS = [
5335
5764
  var BUY_WORDS = /add to (cart|bag|basket)|buy now|add to my bag/i;
5336
5765
  var BUILD_ASSET = /\/_next\/static\/|\/static\/media\/|\/assets\/(icons|flags|ui)\//i;
5337
5766
  var NOT_A_PACKSHOT = /logo|icon|sprite|pixel|avatar|\bflags?\b|\/flags?\/|locale|country|currency|badge|payment|social/i;
5767
+ var SITE_CHROME = /login|sign-?in|account|\bcart\b|\bsearch\b|\bmenu\b|burger|newsletter|placeholder|spacer|spinner/i;
5768
+ var VECTOR = /\.svgz?(\?|#|$)/i;
5769
+ var MIN_PACKSHOT_PX = 100;
5770
+ function imageKey(url) {
5771
+ return url.replace(/^https?:/i, "").toLowerCase();
5772
+ }
5773
+ function declaredPx(value) {
5774
+ const n = Number(String(value ?? "").trim());
5775
+ return Number.isFinite(n) && n > 0 ? n : null;
5776
+ }
5338
5777
  function looksLikeProduct(html, doc) {
5339
5778
  const $ = doc ?? loadHtml(html);
5340
5779
  for (const sel of PRODUCT_MARKERS) {
@@ -5351,17 +5790,27 @@ function looksLikeProduct(html, doc) {
5351
5790
  }
5352
5791
  function galleryImages($, pageUrl, cap2 = 12) {
5353
5792
  const images = [];
5793
+ const taken = /* @__PURE__ */ new Set();
5354
5794
  const og = attr($.querySelector('meta[property="og:image"]'), "content");
5355
5795
  if (og) {
5356
5796
  const abs = absolutize(pageUrl, og);
5357
- if (abs) images.push({ url: abs, position: 0, width: null, height: null, alt: null });
5797
+ if (abs && !VECTOR.test(abs)) {
5798
+ images.push({ url: abs, position: 0, width: null, height: null, alt: null });
5799
+ taken.add(imageKey(abs));
5800
+ }
5358
5801
  }
5359
5802
  for (const el of $.querySelectorAll("img[src]")) {
5360
5803
  if (images.length >= cap2) break;
5361
5804
  const src = attr(el, "src") || attr(el, "data-src");
5362
5805
  const abs = src ? absolutize(pageUrl, src) : null;
5363
5806
  if (!abs || NOT_A_PACKSHOT.test(abs) || BUILD_ASSET.test(abs)) continue;
5364
- if (images.some((x) => x.url === abs)) continue;
5807
+ if (SITE_CHROME.test(abs) || VECTOR.test(abs)) continue;
5808
+ const w = declaredPx(attr(el, "width"));
5809
+ const h = declaredPx(attr(el, "height"));
5810
+ if (w !== null && w < MIN_PACKSHOT_PX || h !== null && h < MIN_PACKSHOT_PX) continue;
5811
+ const key = imageKey(abs);
5812
+ if (taken.has(key)) continue;
5813
+ taken.add(key);
5365
5814
  images.push({ url: abs, position: images.length, width: null, height: null, alt: attr(el, "alt") ?? null });
5366
5815
  }
5367
5816
  return images;
@@ -5412,11 +5861,14 @@ function productsFromPage(html, url) {
5412
5861
  const one = parseProductHtml(html, url, doc);
5413
5862
  return one ? [one] : [];
5414
5863
  }
5415
- var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
5864
+ var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
5416
5865
  async function fetchProductPages(ctx, urls, opts = {}) {
5417
5866
  const out = [];
5418
5867
  const seen = /* @__PURE__ */ new Set();
5419
5868
  let kept = 0;
5869
+ let refusedRun = 0;
5870
+ const giveUpAt = opts.giveUpAfterRefusals ?? Number.POSITIVE_INFINITY;
5871
+ let givenUp = false;
5420
5872
  const want = opts.want ?? Number.POSITIVE_INFINITY;
5421
5873
  const ceiling = opts.maxPages ?? (Number.isFinite(want) ? want * 3 : urls.length);
5422
5874
  const take = urls.slice(0, ceiling);
@@ -5425,12 +5877,12 @@ async function fetchProductPages(ctx, urls, opts = {}) {
5425
5877
  take,
5426
5878
  opts.delayMs ? 1 : opts.concurrency ?? 5,
5427
5879
  async (u) => {
5428
- if (kept >= want) return;
5880
+ if (kept >= want || givenUp) return;
5429
5881
  if (opts.deadline != null && Date.now() > opts.deadline) return;
5430
5882
  if (opts.maxTotalBytes != null && bytes >= opts.maxTotalBytes) return;
5431
5883
  try {
5432
- if (opts.delayMs) await sleep5(opts.delayMs);
5433
- const { ok, text, url } = await httpText(u, {
5884
+ if (opts.delayMs) await sleep6(opts.delayMs);
5885
+ const { ok, status, text, url, challenged } = await httpText(u, {
5434
5886
  fetchImpl: ctx.fetchImpl,
5435
5887
  signal: ctx.signal,
5436
5888
  accept: "text/html",
@@ -5442,9 +5894,14 @@ async function fetchProductPages(ctx, urls, opts = {}) {
5442
5894
  opts.stats.bytes = bytes;
5443
5895
  }
5444
5896
  if (!ok) {
5445
- if (opts.stats) opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5897
+ if (opts.stats) {
5898
+ opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5899
+ if (opts.stats.reasons) tally(opts.stats.reasons, pageFailure(status, challenged));
5900
+ }
5901
+ if (++refusedRun >= giveUpAt) givenUp = true;
5446
5902
  return;
5447
5903
  }
5904
+ refusedRun = 0;
5448
5905
  for (const p of productsFromPage(text, url)) {
5449
5906
  if (seen.has(p.externalKey)) continue;
5450
5907
  seen.add(p.externalKey);
@@ -5453,8 +5910,12 @@ async function fetchProductPages(ctx, urls, opts = {}) {
5453
5910
  else out.push(p);
5454
5911
  }
5455
5912
  opts.onProduct?.(kept);
5456
- } catch {
5457
- if (opts.stats) opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5913
+ } catch (err) {
5914
+ if (opts.stats) {
5915
+ opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5916
+ if (opts.stats.reasons) tally(opts.stats.reasons, thrownFailure(err, ctx.signal?.aborted));
5917
+ }
5918
+ if (!ctx.signal?.aborted && ++refusedRun >= giveUpAt) givenUp = true;
5458
5919
  }
5459
5920
  },
5460
5921
  ctx.signal
@@ -5521,14 +5982,28 @@ async function fetchProductsJsonPage(ctx, page, limit = 250) {
5521
5982
  if (ok && json?.products) return { products: json.products, blocked: false };
5522
5983
  return { products: [], blocked: status === 401 || status === 403 || status === 404 || status >= 500 };
5523
5984
  }
5985
+ function cardOf(origin, p) {
5986
+ const handle = String(p.handle ?? "");
5987
+ const first = (p.images ?? [])[0] ?? p.image ?? null;
5988
+ const src = first ? String(first.src ?? first) : null;
5989
+ return {
5990
+ externalKey: String(p.id),
5991
+ title: String(p.title ?? handle),
5992
+ url: absolutize(origin, `/products/${handle}`) ?? `${originOf(origin)}/products/${handle}`,
5993
+ handle: handle || null,
5994
+ image: src ? cardImageUrl(upgradeImageUrl(src)) : null
5995
+ };
5996
+ }
5524
5997
  var JSON_BLOCKED = "json-blocked";
5998
+ var JSON_GIVE_UP = 3;
5999
+ var MAX_SITEMAPS = 40;
5525
6000
  async function collectSitemapProductUrls(ctx) {
5526
6001
  const origin = originOf(ctx.baseUrl);
5527
6002
  const urls = /* @__PURE__ */ new Set();
5528
6003
  const candidates = [`${origin}/sitemap.xml`, `${origin}/sitemap_products_1.xml`];
5529
6004
  const queue = [...candidates];
5530
6005
  const seen = /* @__PURE__ */ new Set();
5531
- while (queue.length) {
6006
+ while (queue.length && seen.size < MAX_SITEMAPS && !outOfTime(ctx.deadline)) {
5532
6007
  const next = queue.shift();
5533
6008
  if (seen.has(next)) continue;
5534
6009
  seen.add(next);
@@ -5582,11 +6057,16 @@ var shopifyAdapter = {
5582
6057
  const hints = [];
5583
6058
  const keys = /* @__PURE__ */ new Set();
5584
6059
  const productUrls = /* @__PURE__ */ new Set();
6060
+ const cards = [];
5585
6061
  const origin = originOf(ctx.baseUrl);
5586
6062
  let page = 1;
5587
6063
  let emptyStreak = 0;
5588
6064
  while (emptyStreak < 1) {
5589
6065
  if (ctx.signal?.aborted) throw new Error("aborted");
6066
+ if (outOfTime(ctx.deadline)) {
6067
+ warnings.push("This store was slow to list its catalogue, so only part of it was read");
6068
+ break;
6069
+ }
5590
6070
  const { products, blocked: blocked2 } = await fetchProductsJsonPage(ctx, page);
5591
6071
  ctx.onProgress?.({ stage: "discovering", discovered: keys.size, message: `Shopify page ${page}` });
5592
6072
  if (blocked2 && page === 1) {
@@ -5600,6 +6080,7 @@ var shopifyAdapter = {
5600
6080
  for (const p of products) {
5601
6081
  keys.add(String(p.id));
5602
6082
  if (p.handle) productUrls.add(`${origin}/products/${p.handle}`);
6083
+ cards.push(cardOf(origin, p));
5603
6084
  }
5604
6085
  if (products.length < 250) break;
5605
6086
  page++;
@@ -5620,6 +6101,7 @@ var shopifyAdapter = {
5620
6101
  return {
5621
6102
  productKeys: [...keys],
5622
6103
  productUrls: [...productUrls],
6104
+ cards,
5623
6105
  estimatedTotal: keys.size || productUrls.size || null,
5624
6106
  // A store whose own product API answered but refused us leaves nothing
5625
6107
  // to read but the pages themselves, one request each. That is what
@@ -5629,6 +6111,43 @@ var shopifyAdapter = {
5629
6111
  hints
5630
6112
  };
5631
6113
  },
6114
+ /**
6115
+ * The chosen products, read from the listing instead of a page each.
6116
+ *
6117
+ * Walks `/products.json` only until every handle asked for has been found,
6118
+ * so a small pick is usually one request and the whole catalogue is a
6119
+ * handful. Returns null the moment the listing refuses us, because then the
6120
+ * pages are the only way in and the caller already knows how to read them.
6121
+ */
6122
+ async fetchSome(ctx, urls) {
6123
+ const origin = originOf(ctx.baseUrl);
6124
+ const handleOf = (u) => {
6125
+ const raw = /\/products\/([^/?#]+)/i.exec(u)?.[1];
6126
+ return raw ? decodeURIComponent(raw) : null;
6127
+ };
6128
+ const want = new Set(urls.map(handleOf).filter((h) => Boolean(h)));
6129
+ if (!want.size) return null;
6130
+ const out = [];
6131
+ let page = 1;
6132
+ while (want.size) {
6133
+ if (ctx.signal?.aborted) throw new Error("aborted");
6134
+ if (outOfTime(ctx.deadline)) break;
6135
+ const { products, blocked: blocked2 } = await fetchProductsJsonPage(ctx, page);
6136
+ if (blocked2 && page === 1) return null;
6137
+ if (!products.length) break;
6138
+ for (const p of products) {
6139
+ const h = String(p.handle ?? "");
6140
+ if (want.delete(h)) {
6141
+ out.push(mapShopifyProduct(origin, p));
6142
+ ctx.onProgress?.({ stage: "fetching_products", fetched: out.length });
6143
+ }
6144
+ }
6145
+ if (products.length < 250) break;
6146
+ page++;
6147
+ if (page > 1e4) break;
6148
+ }
6149
+ return out.length ? out : null;
6150
+ },
5632
6151
  async fetchAll(ctx, discovered) {
5633
6152
  const origin = originOf(ctx.baseUrl);
5634
6153
  const out = [];
@@ -5662,20 +6181,29 @@ var shopifyAdapter = {
5662
6181
  const handle = handleOf(u);
5663
6182
  return handle && !out.some((p) => p.handle === handle);
5664
6183
  });
5665
- if (!jsonBlocked) {
6184
+ const bulkCoveredAll = out.length > 0 && out.length >= discovered.productKeys.length;
6185
+ if (!jsonBlocked && !bulkCoveredAll) {
5666
6186
  const missingUrls = stillMissing();
5667
6187
  if (missingUrls.length) {
6188
+ let refusedRun = 0;
6189
+ let abandoned = false;
5668
6190
  await mapPool(
5669
6191
  missingUrls,
5670
- 6,
6192
+ 4,
5671
6193
  async (u) => {
6194
+ if (abandoned) return;
5672
6195
  const handle = handleOf(u);
5673
6196
  if (!handle) return;
5674
- const { ok, json } = await httpJson(`${origin}/products/${handle}.json`, {
6197
+ const { ok, json, challenged } = await httpJson(`${origin}/products/${handle}.json`, {
5675
6198
  fetchImpl: ctx.fetchImpl,
5676
6199
  signal: ctx.signal
5677
6200
  });
5678
- if (ok && json?.product) {
6201
+ if (!ok) {
6202
+ if (challenged || ++refusedRun >= JSON_GIVE_UP) abandoned = true;
6203
+ return;
6204
+ }
6205
+ refusedRun = 0;
6206
+ if (json?.product) {
5679
6207
  const mapped = mapShopifyProduct(origin, json.product);
5680
6208
  if (!seen.has(mapped.externalKey)) {
5681
6209
  seen.add(mapped.externalKey);
@@ -5733,7 +6261,7 @@ async function extractSitemapUrls(ctx, filter = () => true) {
5733
6261
  }
5734
6262
  if (filter(loc)) out.add(loc.split("?")[0]);
5735
6263
  }
5736
- if (seen.size > 200) break;
6264
+ if (seen.size > 200 || outOfTime(ctx.deadline)) break;
5737
6265
  }
5738
6266
  return preferCanonicalLocale([...out]);
5739
6267
  }
@@ -5772,7 +6300,7 @@ async function crawlListingPages(ctx) {
5772
6300
  const found = /* @__PURE__ */ new Set();
5773
6301
  const visited = /* @__PURE__ */ new Set();
5774
6302
  const queue = [origin, `${origin}/collections/all`, `${origin}/shop`, `${origin}/products`, `${origin}/catalog`];
5775
- while (queue.length && visited.size < 40) {
6303
+ while (queue.length && visited.size < 40 && !outOfTime(ctx.deadline)) {
5776
6304
  const page = queue.shift();
5777
6305
  if (visited.has(page)) continue;
5778
6306
  visited.add(page);
@@ -5928,6 +6456,11 @@ var woocommerceAdapter = {
5928
6456
  let page = 1;
5929
6457
  let usedApi = false;
5930
6458
  while (true) {
6459
+ if (ctx.signal?.aborted) throw new Error("aborted");
6460
+ if (outOfTime(ctx.deadline)) {
6461
+ warnings.push("This store was slow to list its catalogue, so only part of it was read");
6462
+ break;
6463
+ }
5931
6464
  const batch = await tryStoreApi(ctx, page, 100);
5932
6465
  if (batch === null) break;
5933
6466
  usedApi = true;
@@ -6287,6 +6820,7 @@ var DEFAULT_SCAN_BUDGET = {
6287
6820
  */
6288
6821
  previewFloorMs: 12e3
6289
6822
  };
6823
+ var DISCOVERY_FLOOR_MS = 8e3;
6290
6824
  var COMMERCE_SIGNALS = /* @__PURE__ */ new Set([
6291
6825
  "products.json",
6292
6826
  "shopify-html",
@@ -6317,12 +6851,14 @@ async function scanOnce(opts, baseUrl) {
6317
6851
  const started = Date.now();
6318
6852
  const budget = { ...DEFAULT_SCAN_BUDGET, ...opts.budget };
6319
6853
  const deadline = started + budget.budgetMs;
6854
+ const discoveryDeadline = Math.max(deadline, started + DISCOVERY_FLOOR_MS);
6320
6855
  const fetchImpl = opts.fetchImpl ?? fetch;
6321
6856
  const ctx = {
6322
6857
  fetchImpl,
6323
6858
  baseUrl,
6324
6859
  signal: opts.signal,
6325
- onProgress: opts.onProgress
6860
+ onProgress: opts.onProgress,
6861
+ deadline: discoveryDeadline
6326
6862
  };
6327
6863
  const warnings = [];
6328
6864
  const stats = { pages: 0, bytes: 0 };
@@ -6343,10 +6879,18 @@ async function scanOnce(opts, baseUrl) {
6343
6879
  if (refused > 0) warnings.push(`${refused} pages are disallowed by this site's robots.txt`);
6344
6880
  const fromApi = detection.platform !== "generic" && !discovered.hints?.includes("json-blocked");
6345
6881
  const countSource = urls.length === 0 ? "none" : fromApi ? "api" : "sitemap";
6882
+ const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
6346
6883
  if (urls.length === 0) {
6347
- const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
6348
6884
  return done(commerce ? "likely" : "none", [], [], 0, countSource, warnings);
6349
6885
  }
6886
+ const listed = discovered.cards ?? [];
6887
+ if (listed.length) {
6888
+ const byUrl = new Set(urls);
6889
+ const usable2 = listed.filter((c) => byUrl.has(c.url));
6890
+ const cards = usable2.length ? usable2 : listed;
6891
+ opts.onProgress?.({ stage: "discovering", discovered: cards.length, message: `Found ${cards.length} products` });
6892
+ return done("found", [], urls, urls.length || cards.length, countSource, warnings, cards);
6893
+ }
6350
6894
  opts.onProgress?.({ stage: "discovering", discovered: urls.length, message: "Reading a few products" });
6351
6895
  const previewDeadline = Math.max(deadline, Date.now() + budget.previewFloorMs);
6352
6896
  const read = dedupeProducts(
@@ -6362,15 +6906,16 @@ async function scanOnce(opts, baseUrl) {
6362
6906
  })
6363
6907
  );
6364
6908
  const preview = read.slice(0, budget.maxPreviewPages);
6365
- const verdict = preview.length ? "found" : "blocked";
6909
+ const verdict = preview.length ? "found" : commerce ? "blocked" : "none";
6366
6910
  return done(verdict, preview, urls, urls.length, countSource, warnings);
6367
- function done(verdict2, candidates, candidateUrls, count, source, notes) {
6911
+ function done(verdict2, candidates, candidateUrls, count, source, notes, cards = []) {
6368
6912
  return {
6369
6913
  baseUrl,
6370
6914
  platform: detection?.platform ?? "unknown",
6371
6915
  signals: detection?.signals ?? [],
6372
6916
  verdict: verdict2,
6373
6917
  count,
6918
+ cards,
6374
6919
  countSource: source,
6375
6920
  candidates,
6376
6921
  candidateUrls,
@@ -6438,11 +6983,14 @@ function progressWriter(patch2) {
6438
6983
  });
6439
6984
  };
6440
6985
  }
6986
+ function stoppedMessage(saved) {
6987
+ return saved ? `Stopped after saving ${saved.toLocaleString()} products` : "Stopped before anything was saved";
6988
+ }
6441
6989
  async function runJob(deps, jobId, brandId, url, signal, only) {
6442
6990
  const { core, fetchImpl } = deps;
6443
6991
  const patch2 = (p) => core.catalog.updateJob(jobId, p);
6992
+ const tally2 = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
6444
6993
  try {
6445
- const tally = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
6446
6994
  const ctx = { fetchImpl: fetchImpl ?? fetch, baseUrl: url, signal };
6447
6995
  let urls = [];
6448
6996
  let bulk = null;
@@ -6458,6 +7006,16 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6458
7006
  baseUrl = url;
6459
7007
  platform = detection.platform;
6460
7008
  discovered = only.length;
7009
+ const adapter = adapterFor(detection.platform);
7010
+ const listed = adapter.fetchSome ? await adapter.fetchSome({ ...ctx, baseUrl }, only) : null;
7011
+ if (listed?.length) {
7012
+ bulk = listed;
7013
+ const got = new Set(listed.map((p) => p.handle ?? "").filter(Boolean));
7014
+ urls = only.filter((u) => {
7015
+ const h = /\/products\/([^/?#]+)/i.exec(u)?.[1];
7016
+ return !h || !got.has(decodeURIComponent(h));
7017
+ });
7018
+ }
6461
7019
  } else {
6462
7020
  patch2({ stage: "discovering", message: "Detecting store platform" });
6463
7021
  const found = await discoverCatalog({ url, fetchImpl, signal, onProgress: progressWriter(patch2) });
@@ -6481,7 +7039,7 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6481
7039
  if (!found.byPage) {
6482
7040
  const products = await found.fetchAll();
6483
7041
  if (signal.aborted) {
6484
- patch2({ stage: "cancelled", errors: [], message: "Stopped before anything was saved", finished: true });
7042
+ patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
6485
7043
  return;
6486
7044
  }
6487
7045
  if (!products.length) {
@@ -6506,64 +7064,74 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6506
7064
  patch2({ stage: "fetching_products", discovered, message: `Reading ${discovered.toLocaleString()} products` });
6507
7065
  }
6508
7066
  }
6509
- const run2 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally, discovered);
6510
- const pictures = drainPictures(deps, jobId, brandId, tally, signal);
7067
+ const run2 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered);
7068
+ const pictures = drainPictures(deps, jobId, brandId, tally2, signal, hostOf(baseUrl));
6511
7069
  let pictureErrors;
6512
- const stats = { pages: 0, bytes: 0, refused: 0 };
7070
+ let pictureReasons = {};
7071
+ const stats = { pages: 0, bytes: 0, refused: 0, reasons: {} };
6513
7072
  try {
6514
7073
  if (bulk) for (const p of bulk) run2.write(p);
6515
- else
7074
+ if (urls.length)
6516
7075
  await fetchProductPages(ctx, urls, {
6517
7076
  concurrency: IMPORT_CONCURRENCY,
6518
7077
  maxBytes: 15e5,
6519
7078
  onEach: run2.write,
6520
- stats
7079
+ stats,
7080
+ // A run that cannot finish still has to end. Generous enough for a
7081
+ // 2,200 page catalogue, which measures around sixteen minutes, and
7082
+ // short of the forever this had before. Stopping here leaves the run
7083
+ // `partial` with what it saved, never `completed`.
7084
+ deadline: Date.now() + IMPORT_DEADLINE_MS,
7085
+ // Past this many refusals in a row the store is not going to change
7086
+ // its mind inside this import, and waiting out its cooldown for the
7087
+ // rest of the catalogue is time nobody gets a product for.
7088
+ giveUpAfterRefusals: 24
6521
7089
  });
6522
- if (!signal.aborted && tally.upserted) {
7090
+ if (!signal.aborted && tally2.upserted) {
6523
7091
  patch2({
6524
7092
  stage: "processing_assets",
6525
- message: `Downloading ${tally.imagesTotal.toLocaleString()} pictures`,
6526
- upserted: tally.upserted,
6527
- fetched: tally.fetched
7093
+ message: `Downloading ${tally2.imagesTotal.toLocaleString()} pictures`,
7094
+ upserted: tally2.upserted,
7095
+ fetched: tally2.fetched
6528
7096
  });
6529
7097
  }
6530
7098
  } finally {
6531
7099
  pictures.stop();
6532
- pictureErrors = await pictures.done;
7100
+ const settled = await pictures.done;
7101
+ pictureErrors = settled.errors;
7102
+ pictureReasons = settled.reasons;
6533
7103
  }
6534
7104
  if (signal.aborted) {
6535
7105
  patch2({
6536
7106
  stage: "cancelled",
6537
7107
  errors: [],
6538
- message: tally.upserted ? `Stopped after saving ${tally.upserted.toLocaleString()} products` : "Stopped before anything was saved",
7108
+ message: stoppedMessage(tally2.upserted),
6539
7109
  finished: true
6540
7110
  });
6541
7111
  core.catalog.setSourceStatus(run2.sourceId, "partial", true);
6542
7112
  return;
6543
7113
  }
6544
- if (!tally.upserted) {
6545
- const message = only?.length ? "None of the chosen products could be read" : `Found pages on this ${platform} store but could not read a product from any of them. The store may be blocking automated readers.`;
6546
- patch2({
6547
- stage: "failed",
6548
- message,
6549
- errors: [{ code: "no_products_fetched", message }],
6550
- finished: true
6551
- });
6552
- core.catalog.setSourceStatus(run2.sourceId, "failed", true);
6553
- return;
6554
- }
6555
7114
  run2.finish({
6556
7115
  sweep,
6557
7116
  warnings,
6558
7117
  errors: pictureErrors,
6559
7118
  refused: stats.refused,
6560
- // Every address was read. A bulk API hands the catalogue over whole, so
6561
- // there is nothing to cover.
6562
- covered: bulk ? true : stats.pages >= urls.length
7119
+ reasons: { ...stats.reasons, ...pictureReasons },
7120
+ // A chosen set can now come partly from the listing and partly from the
7121
+ // pages it did not carry, so both halves count.
7122
+ asked: (bulk?.length ?? 0) + urls.length,
7123
+ // Addresses that actually yielded a page, which is the unit the person's
7124
+ // question was asked in. Products are the wrong unit: one address can
7125
+ // carry several, and counting them made a run that lost three addresses
7126
+ // report that it had lost one. A listing answers for itself.
7127
+ worked: (bulk?.length ?? 0) + Math.max(0, stats.pages - stats.refused),
7128
+ // Every address was read. A bulk API hands its share over whole, so only
7129
+ // the pages left over have to be covered.
7130
+ covered: stats.pages >= urls.length
6563
7131
  });
6564
7132
  } catch (err) {
6565
7133
  if (signal.aborted) {
6566
- patch2({ stage: "cancelled", errors: [], message: "Stopped before anything was saved", finished: true });
7134
+ patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
6567
7135
  return;
6568
7136
  }
6569
7137
  patch2({
@@ -6577,8 +7145,16 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6577
7145
  var IMAGES_PER_PRODUCT = 3;
6578
7146
  var TERMINAL = /* @__PURE__ */ new Set(["completed", "partial", "no_catalog", "cancelled", "failed"]);
6579
7147
  var IMAGE_CONCURRENCY = 4;
6580
- var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
6581
- function beginWrite(deps, jobId, brandId, baseUrl, platform, tally, discovered) {
7148
+ var IMAGE_CONCURRENCY_OFF_HOST = 12;
7149
+ function allOffHost(round, storeHost) {
7150
+ if (!storeHost || !round.length) return false;
7151
+ return round.every((img) => {
7152
+ const h = hostOf(img.sourceUrl);
7153
+ return h !== "" && h !== storeHost;
7154
+ });
7155
+ }
7156
+ var sleep7 = (ms) => new Promise((r) => setTimeout(r, ms));
7157
+ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered) {
6582
7158
  const { core } = deps;
6583
7159
  const patch2 = (p) => core.catalog.updateJob(jobId, p);
6584
7160
  const source = core.catalog.upsertSource(brandId, baseUrl, platform);
@@ -6616,71 +7192,93 @@ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally, discovered)
6616
7192
  })),
6617
7193
  collections: (p.collections ?? []).map((c) => ({ externalKey: c, title: c }))
6618
7194
  });
6619
- tally.seenKeys.push(p.externalKey);
6620
- tally.upserted++;
6621
- tally.fetched++;
6622
- patch2({ upserted: tally.upserted, fetched: tally.fetched });
7195
+ tally2.seenKeys.push(p.externalKey);
7196
+ tally2.upserted++;
7197
+ tally2.fetched++;
7198
+ patch2({ upserted: tally2.upserted, fetched: tally2.fetched });
6623
7199
  },
6624
7200
  finish({
6625
7201
  sweep,
6626
7202
  warnings,
6627
7203
  errors,
6628
7204
  refused = 0,
6629
- covered = true
7205
+ covered = true,
7206
+ reasons = {},
7207
+ asked = 0,
7208
+ worked
6630
7209
  }) {
6631
- if (sweep) core.catalog.markMissingUnavailable(source.id, tally.seenKeys);
6632
- tally.errors = errors;
7210
+ const mayRetire = sweep && covered && refused === 0 && tally2.upserted > 0;
7211
+ if (mayRetire) core.catalog.markMissingUnavailable(source.id, tally2.seenKeys);
7212
+ if (tally2.upserted > 0) core.catalog.excludeSharedImages(source.id);
7213
+ tally2.errors = errors;
6633
7214
  const partial = !covered || refused > 0;
6634
- const shut = refused > 0 && refused >= Math.max(20, tally.upserted * 0.25);
6635
- const message = shut ? `The store stopped answering after ${tally.upserted.toLocaleString()} of ${discovered.toLocaleString()} products. Try again later.` : partial ? `Imported ${tally.upserted.toLocaleString()} products with ${(errors.length + refused).toLocaleString()} issue${errors.length + refused === 1 ? "" : "s"}` : `Imported ${tally.upserted.toLocaleString()} products`;
7215
+ const savedNothing = tally2.upserted === 0;
7216
+ const total = asked || discovered || tally2.fetched;
7217
+ const said = summarise(
7218
+ reasons,
7219
+ tally2.upserted,
7220
+ total,
7221
+ worked === void 0 ? void 0 : shortfall(total, worked)
7222
+ );
7223
+ const stage = savedNothing ? "failed" : partial ? "partial" : "completed";
7224
+ const message = savedNothing ? said ?? "We found the products on this site, but none of them could be imported." : said ?? (partial ? `Imported ${tally2.upserted.toLocaleString()} products with ${(errors.length + refused).toLocaleString()} issue${errors.length + refused === 1 ? "" : "s"}` : `Imported ${tally2.upserted.toLocaleString()} products`);
6636
7225
  patch2({
6637
- stage: partial ? "partial" : "completed",
6638
- upserted: tally.upserted,
6639
- fetched: tally.fetched,
6640
- imagesDone: tally.imagesDone,
6641
- imagesTotal: tally.imagesTotal,
6642
- errors: shut ? [...errors, { code: "store_refused", message: `${refused.toLocaleString()} pages were refused` }] : errors,
7226
+ stage,
7227
+ upserted: tally2.upserted,
7228
+ fetched: tally2.fetched,
7229
+ imagesDone: tally2.imagesDone,
7230
+ imagesTotal: tally2.imagesTotal,
7231
+ errors,
6643
7232
  warnings,
6644
7233
  message,
6645
7234
  finished: true
6646
7235
  });
6647
- core.catalog.setSourceStatus(source.id, partial ? "partial" : "ready", true);
7236
+ core.catalog.setSourceStatus(source.id, savedNothing ? "failed" : partial ? "partial" : "ready", true);
6648
7237
  }
6649
7238
  };
6650
7239
  }
6651
7240
  var IMPORT_CONCURRENCY = 4;
7241
+ var IMPORT_DEADLINE_MS = 40 * 6e4;
6652
7242
  var PICTURE_ROUND = 60;
6653
- function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = IMAGES_PER_PRODUCT) {
7243
+ function drainPictures(deps, jobId, brandId, tally2, signal, storeHost = "", imagesPerProduct = IMAGES_PER_PRODUCT) {
6654
7244
  const { core, fetchImpl } = deps;
6655
7245
  const patch2 = (p) => core.catalog.updateJob(jobId, p);
6656
7246
  const errors = [...core.catalog.getJob(jobId)?.errors ?? []];
7247
+ const reasons = {};
6657
7248
  let stopped = false;
6658
7249
  const done = (async () => {
6659
7250
  while (!signal.aborted) {
6660
7251
  const round = core.catalog.listImagesNeedingAssets(brandId, PICTURE_ROUND).filter((img) => img.position < imagesPerProduct);
6661
7252
  if (!round.length) {
6662
7253
  if (stopped) break;
6663
- await sleep6(150);
7254
+ await sleep7(150);
6664
7255
  continue;
6665
7256
  }
6666
- tally.imagesTotal += round.length;
7257
+ tally2.imagesTotal = tally2.imagesDone + core.catalog.countImagesNeedingAssets(brandId, imagesPerProduct);
6667
7258
  await mapPool(
6668
7259
  round,
6669
- IMAGE_CONCURRENCY,
7260
+ allOffHost(round, storeHost) ? IMAGE_CONCURRENCY_OFF_HOST : IMAGE_CONCURRENCY,
6670
7261
  async (img) => {
6671
7262
  if (signal.aborted) return;
6672
7263
  try {
6673
7264
  const res = await httpGet(img.sourceUrl, { fetchImpl, signal, timeoutMs: 4e4, retries: 2 });
6674
7265
  if (!res.ok) {
6675
7266
  errors.push({ code: "image_http", message: `HTTP ${res.status}`, url: img.sourceUrl });
7267
+ tally(reasons, imageFailure(res.status));
6676
7268
  return;
6677
7269
  }
6678
7270
  const buf = Buffer.from(await res.arrayBuffer());
6679
7271
  if (!buf.length) {
6680
7272
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
7273
+ tally(reasons, "IMAGE_EMPTY");
6681
7274
  return;
6682
7275
  }
6683
7276
  const probe = await sharp21(buf).metadata();
7277
+ if (probe.format === "svg") {
7278
+ errors.push({ code: "image_unsupported", message: "Not a photograph", url: img.sourceUrl });
7279
+ tally(reasons, "UNSUPPORTED_MEDIA");
7280
+ return;
7281
+ }
6684
7282
  const turned = (probe.orientation ?? 1) > 1;
6685
7283
  const keep = turned ? await sharp21(buf).rotate().toBuffer() : buf;
6686
7284
  const meta = turned ? await sharp21(keep).metadata() : probe;
@@ -6691,6 +7289,7 @@ function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = I
6691
7289
  });
6692
7290
  } catch (err) {
6693
7291
  if (signal.aborted) return;
7292
+ tally(reasons, thrownFailure(err, signal.aborted));
6694
7293
  errors.push({
6695
7294
  code: "image_failed",
6696
7295
  message: String(err?.message ?? err),
@@ -6698,8 +7297,8 @@ function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = I
6698
7297
  retryable: true
6699
7298
  });
6700
7299
  } finally {
6701
- tally.imagesDone++;
6702
- patch2({ imagesDone: tally.imagesDone, imagesTotal: tally.imagesTotal, errors });
7300
+ tally2.imagesDone++;
7301
+ patch2({ imagesDone: tally2.imagesDone, imagesTotal: tally2.imagesTotal, errors });
6703
7302
  }
6704
7303
  },
6705
7304
  signal
@@ -6710,8 +7309,8 @@ function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = I
6710
7309
  break;
6711
7310
  }
6712
7311
  }
6713
- patch2({ imagesDone: tally.imagesDone, imagesTotal: tally.imagesTotal, errors });
6714
- return errors;
7312
+ patch2({ imagesDone: tally2.imagesDone, imagesTotal: tally2.imagesTotal, errors });
7313
+ return { errors, reasons };
6715
7314
  })();
6716
7315
  return {
6717
7316
  stop() {
@@ -6779,12 +7378,26 @@ function brandJsonWithCatalogProducts(core, brandId) {
6779
7378
  function runningImportCount() {
6780
7379
  return running.size;
6781
7380
  }
7381
+ var IDENTITY_EDITS_MAX = 8;
7382
+ var IDENTITY_EDIT_CHARS = 120;
7383
+ var PRESENTER_SOURCES = ["synthetic", "photos"];
7384
+ var LIKENESS_VERSION = "v1";
6782
7385
  var PRESENTER_ID_PREFIX = "up";
6783
7386
  var SCENE_ID_PREFIX = "us";
6784
7387
  var ASSET_WIDTH = 1024;
6785
7388
  var ASSET_HEIGHT = 1280;
6786
7389
  var mintId = (prefix) => `${prefix}-${randomUUID().slice(0, 8)}`;
6787
7390
  var str3 = (v, max) => String(v ?? "").trim().slice(0, max);
7391
+ var phrase = (v, max) => {
7392
+ const whole = String(v ?? "").trim();
7393
+ if (whole.length <= max) return whole;
7394
+ const cut = whole.slice(0, max);
7395
+ const stop = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
7396
+ if (stop >= max * 0.6) return cut.slice(0, stop + 1);
7397
+ const space = cut.lastIndexOf(" ");
7398
+ if (space < max * 0.6) return cut;
7399
+ return cut.slice(0, space).replace(/[\s,;:]+$/, "");
7400
+ };
6788
7401
  var strList = (v, max, each) => Array.isArray(v) ? v.map((x) => str3(x, each)).filter(Boolean).slice(0, max) : [];
6789
7402
  var assetRef = (hash) => {
6790
7403
  const h = String(hash ?? "");
@@ -6802,6 +7415,30 @@ function brandSceneById(brandJson, id) {
6802
7415
  function isCustomPresenter(c) {
6803
7416
  return c?.origin === "custom";
6804
7417
  }
7418
+ function headOf(brandJson, id) {
7419
+ const rows = brandCharacters(brandJson);
7420
+ const seen = /* @__PURE__ */ new Set([id]);
7421
+ let cur = id;
7422
+ for (; ; ) {
7423
+ const next = rows.find((c) => c?.id === cur)?.supersededBy;
7424
+ if (typeof next !== "string" || !next || seen.has(next) || !rows.some((c) => c?.id === next)) return cur;
7425
+ seen.add(next);
7426
+ cur = next;
7427
+ }
7428
+ }
7429
+ function identityEditsOf(v) {
7430
+ const out = [];
7431
+ const seen = /* @__PURE__ */ new Set();
7432
+ for (const raw of Array.isArray(v) ? v : []) {
7433
+ const s = str3(raw, IDENTITY_EDIT_CHARS).replace(/\s+/g, " ");
7434
+ const key = s.toLowerCase();
7435
+ if (!s || seen.has(key)) continue;
7436
+ seen.add(key);
7437
+ out.push(s);
7438
+ if (out.length >= IDENTITY_EDITS_MAX) break;
7439
+ }
7440
+ return out;
7441
+ }
6805
7442
  var SUBJECTS2 = /* @__PURE__ */ new Set(["product", "person", "either"]);
6806
7443
  function sceneRecordFrom(input, base) {
6807
7444
  const has = (k) => input[k] !== void 0;
@@ -6852,7 +7489,11 @@ function presenterRecordFrom(input, base) {
6852
7489
  const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
6853
7490
  if (!name) return { ok: false, error: "a presenter needs a name" };
6854
7491
  const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
6855
- const shots = has("shotHashes") ? strList(input.shotHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file, i) => angles[i] ? { file, angle: angles[i], locked: true } : { file, locked: true }) : base?.shots ?? [];
7492
+ const knownAngle = (file) => base?.shots?.find((s) => s.file === file)?.angle;
7493
+ const shots = has("shotHashes") ? strList(input.shotHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file, i) => {
7494
+ const angle = angles[i] ?? knownAngle(file);
7495
+ return angle ? { file, angle, locked: true } : { file, locked: true };
7496
+ }) : base?.shots ?? [];
6856
7497
  if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
6857
7498
  const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
6858
7499
  const presenter = {
@@ -6865,13 +7506,13 @@ function presenterRecordFrom(input, base) {
6865
7506
  if (promptName) presenter.promptName = promptName;
6866
7507
  const presentation = has("presentation") ? str3(input.presentation, 10).toLowerCase() : base?.presentation;
6867
7508
  if (presentation === "woman" || presentation === "man") presenter.presentation = presentation;
6868
- const descriptor = has("descriptor") ? str3(input.descriptor, 120) : base?.descriptor;
7509
+ const descriptor = has("descriptor") ? phrase(input.descriptor, 120) : base?.descriptor;
6869
7510
  if (descriptor) presenter.descriptor = descriptor;
6870
7511
  const ageRange = has("ageRange") ? str3(input.ageRange, 40) : base?.ageRange;
6871
7512
  if (ageRange) presenter.ageRange = ageRange;
6872
- const hair = has("hair") ? str3(input.hair, 120) : base?.hair;
7513
+ const hair = has("hair") ? phrase(input.hair, 120) : base?.hair;
6873
7514
  if (hair) presenter.hair = hair;
6874
- const identityNotes = has("identityNotes") ? str3(input.identityNotes, 900) : base?.identityNotes;
7515
+ const identityNotes = has("identityNotes") ? phrase(input.identityNotes, 900) : base?.identityNotes;
6875
7516
  if (identityNotes) presenter.identityNotes = identityNotes;
6876
7517
  const negatives = has("negativeConstraints") ? strList(input.negativeConstraints, 8, 160) : base?.negativeConstraints;
6877
7518
  if (negatives?.length) presenter.negativeConstraints = negatives;
@@ -6882,8 +7523,36 @@ function presenterRecordFrom(input, base) {
6882
7523
  if (previewRef) presenter.preview = previewRef;
6883
7524
  const avatarRef = has("avatarHash") ? assetRef(input.avatarHash) : base?.avatar ?? null;
6884
7525
  if (avatarRef) presenter.avatar = avatarRef;
7526
+ const source = has("source") ? str3(input.source, 16) : base?.source;
7527
+ if (source && PRESENTER_SOURCES.includes(source)) presenter.source = source;
7528
+ const likeness = has("likeness") ? likenessOf(input.likeness) : base?.likeness;
7529
+ if (likeness) presenter.likeness = likeness;
7530
+ const facial = has("facial") ? str3(input.facial, 300) : base?.facial;
7531
+ if (facial) presenter.facial = facial;
7532
+ const skin = has("skin") ? str3(input.skin, 200) : base?.skin;
7533
+ if (skin) presenter.skin = skin;
7534
+ const build = has("build") ? str3(input.build, 200) : base?.build;
7535
+ if (build) presenter.build = build;
7536
+ const edits = has("identityEdits") ? identityEditsOf(input.identityEdits) : base?.identityEdits;
7537
+ if (edits?.length) presenter.identityEdits = edits;
7538
+ const revisionOf = has("revisionOf") ? str3(input.revisionOf, 64) : base?.revisionOf;
7539
+ if (revisionOf) presenter.revisionOf = revisionOf;
7540
+ if (base?.supersededBy) presenter.supersededBy = base.supersededBy;
6885
7541
  return { ok: true, presenter };
6886
7542
  }
7543
+ function mintRevision(base, input) {
7544
+ const built = presenterRecordFrom(input, base);
7545
+ if (!built.ok) return built;
7546
+ const { supersededBy: _head, ...rest } = built.presenter;
7547
+ return { ok: true, presenter: { ...rest, id: mintId(PRESENTER_ID_PREFIX), revisionOf: base.id } };
7548
+ }
7549
+ function likenessOf(raw) {
7550
+ if (!raw || typeof raw !== "object") return void 0;
7551
+ const at = str3(raw.attestedAt, 40);
7552
+ const version = str3(raw.version, 8);
7553
+ if (!at || version !== LIKENESS_VERSION) return void 0;
7554
+ return { attestedAt: at, version: LIKENESS_VERSION };
7555
+ }
6887
7556
  function lintSceneProse(brandJson, scene) {
6888
7557
  const haystack = `${scene.prompt} ${scene.description} ${scene.figure ?? ""} ${scene.figureTreatment ?? ""}`.toLowerCase();
6889
7558
  const named = /* @__PURE__ */ new Set();
@@ -6899,14 +7568,14 @@ function lintSceneProse(brandJson, scene) {
6899
7568
  `This scene names ${[...named].join(", ")}. A scene is a place, so whatever you stage in it later has to argue with that.`
6900
7569
  ] : [];
6901
7570
  }
6902
- function commit(core, brandId, mutate) {
7571
+ function commit(core, brandId, mutate2, dropDraftId) {
6903
7572
  const brand = core.store.getBrand(brandId);
6904
7573
  if (!brand) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
6905
7574
  const json = { ...brand.json };
6906
- mutate(json);
7575
+ mutate2(json);
6907
7576
  const v = validateBrand(json);
6908
7577
  if (!v.valid) throw Object.assign(new Error(`brand became invalid: ${v.errors.join("; ")}`), { statusCode: 400 });
6909
- return core.store.updateBrand(brand.id, json);
7578
+ return dropDraftId ? core.store.updateBrandAndDropPresenterDraft(brand.id, json, dropDraftId) : core.store.updateBrand(brand.id, json);
6910
7579
  }
6911
7580
 
6912
7581
  // src/customAssets.ts
@@ -6938,20 +7607,17 @@ function startAssetBuild(deps, input) {
6938
7607
  if (input.sceneId && !prior) throw Object.assign(new Error("scene not found"), { statusCode: 404 });
6939
7608
  const supplied = input.imageHashes.length ? input.imageHashes : (prior?.refs ?? []).map((r) => String(r?.file ?? "").replace(/^asset:/, ""));
6940
7609
  const hashes = supplied.filter((h) => /^[a-f0-9]{32}$/.test(h) && core.images.has(h));
6941
- if (input.kind === "presenter" && !hashes.length) {
6942
- throw Object.assign(new Error("add at least one photo of this person"), { statusCode: 400 });
6943
- }
6944
- if (input.kind === "scene" && !hashes.length && !input.instruction?.trim()) {
7610
+ if (!hashes.length && !input.instruction?.trim()) {
6945
7611
  throw Object.assign(new Error("add a reference image, or describe the place in a sentence"), { statusCode: 400 });
6946
7612
  }
6947
7613
  const job = {
6948
7614
  id: `ab-${randomUUID().slice(0, 8)}`,
6949
7615
  brandId: brand.id,
6950
7616
  kind: input.kind,
6951
- name: str3(input.name, 60) || (input.kind === "presenter" ? "New presenter" : "New scene"),
7617
+ name: str3(input.name, 60) || "New scene",
6952
7618
  stage: "queued",
6953
7619
  step: 0,
6954
- steps: input.kind === "presenter" ? STUDIO_FRAMES.length : 1,
7620
+ steps: 1,
6955
7621
  message: null,
6956
7622
  assetId: null,
6957
7623
  previewHash: null,
@@ -6979,8 +7645,7 @@ function patch(job, next) {
6979
7645
  }
6980
7646
  async function runBuild(deps, job, hashes, instruction, signal) {
6981
7647
  try {
6982
- if (job.kind === "presenter") await runPresenterBuild(deps, job, hashes, instruction, signal);
6983
- else await runSceneBuild(deps, job, hashes, instruction, signal);
7648
+ await runSceneBuild(deps, job, hashes, instruction, signal);
6984
7649
  } catch (err) {
6985
7650
  if (signal.aborted) {
6986
7651
  patch(job, { stage: "cancelled", message: null, finished: true });
@@ -6989,162 +7654,6 @@ async function runBuild(deps, job, hashes, instruction, signal) {
6989
7654
  patch(job, { stage: "failed", error: err?.message ?? "build failed", message: null, finished: true });
6990
7655
  }
6991
7656
  }
6992
- var STUDIO_FRAMES = [
6993
- /*
6994
- * The identity frame, and it comes first because that is the order a brief
6995
- * attaches: `shots[0]` is the essential character reference.
6996
- *
6997
- * Every other frame here is full-length head-to-toe, which is right for
6998
- * build, proportion and wardrobe and useless for a face — in a 1024x1280
6999
- * full-length frame the face is about 105px brow to chin, while a portrait
7000
- * output renders it at four times that. Measured 2026-08-30 against the
7001
- * reported failure: four outputs of one brief, four different jaws, and
7002
- * drift that tracked nothing but how big the face was in the output.
7003
- *
7004
- * Drawn `from: 'sources'` rather than chained off the front view, because
7005
- * the user's own photographs are the only real face evidence in the system
7006
- * and a chain would just enlarge the same 105px.
7007
- */
7008
- {
7009
- angle: "portrait",
7010
- from: "sources",
7011
- subject: (who) => `${who}, head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens, their own hair exactly as the references show it, the same plain studio backdrop and even frontal light`
7012
- },
7013
- {
7014
- angle: "front",
7015
- from: "sources",
7016
- subject: (who) => `${who}, wearing a fitted off-white ribbed tank top and matching fitted off-white leggings, barefoot, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, facing the camera straight-on`
7017
- },
7018
- {
7019
- angle: "left-profile",
7020
- from: "front",
7021
- subject: () => "the same person in the identical standing pose, full-length head-to-toe framing, rotated a full 90 degrees to show their left side in full profile, facing screen-left, same wardrobe"
7022
- },
7023
- {
7024
- angle: "right-profile",
7025
- from: "left-profile",
7026
- subject: () => "the attached image shows this exact same person in full left profile, standing: generate the precise mirror-flipped view of that same pose, the same person now in full right profile, facing the exact opposite horizontal direction, same standing pose, same wardrobe, same lighting and background"
7027
- },
7028
- {
7029
- angle: "back",
7030
- from: "front",
7031
- subject: () => "the same person in the identical standing pose, full-length head-to-toe framing, rotated to face fully away from the camera, back view, same wardrobe"
7032
- }
7033
- ];
7034
- var STUDIO_SET = "against a solid seamless white studio background, eye-level camera with gentle 85mm-equivalent portrait compression and a soft shallow depth of field, one large soft key light with gentle fill producing even, flattering, true-to-life beauty light, while keeping fine natural skin texture at pore scale and true-to-life proportions, the complexion even and uniform in tone across face, neck and shoulders, never airbrushed, plastic, or synthetic-looking, a calm quietly confident expression, true-to-life color grade with minimal retouch";
7035
- function studioPrompt(subject) {
7036
- return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind, the seamless studio backdrop runs past all four edges and is the only thing behind the subject at every edge of the frame. ${subject}, ${STUDIO_SET}. No text, no logos, no watermarks anywhere in the frame.`;
7037
- }
7038
- function whoIs(name, draft) {
7039
- if (!draft) return `the exact person in the attached photographs`;
7040
- const bits = [draft.promptName];
7041
- if (draft.hair && !draft.promptName.toLowerCase().includes(draft.hair.toLowerCase())) bits.push(draft.hair);
7042
- if (draft.identityNotes) bits.push(draft.identityNotes);
7043
- return bits.filter(Boolean).join(", ") || name;
7044
- }
7045
- async function runPresenterBuild(deps, job, hashes, instruction, signal) {
7046
- const { core } = deps;
7047
- const sourcePaths = hashes.map((h) => core.images.pathFor(h));
7048
- let draft = null;
7049
- if (deps.analyzer) {
7050
- patch(job, { stage: "analyzing", message: "Reading the photos" });
7051
- draft = await deps.analyzer.analyze(
7052
- {
7053
- kind: "presenter",
7054
- imagePaths: sourcePaths,
7055
- name: job.name,
7056
- instruction: instruction || void 0,
7057
- vocabulary: deps.vocabulary
7058
- },
7059
- signal
7060
- );
7061
- patch(job, { coverage: draft.coverage ?? [] });
7062
- }
7063
- if (signal.aborted) throw new Error("cancelled");
7064
- let shotHashes = hashes;
7065
- let shotAngles = [];
7066
- const warnings = [];
7067
- if (deps.engine) {
7068
- patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
7069
- const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
7070
- if (built2.hashes.length) {
7071
- shotHashes = built2.hashes;
7072
- shotAngles = built2.angles;
7073
- } else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
7074
- } else {
7075
- warnings.push("No engine could draw the studio views, so the photos are being used directly.");
7076
- }
7077
- if (signal.aborted) throw new Error("cancelled");
7078
- patch(job, { stage: "saving", message: null });
7079
- const generated = shotHashes !== hashes;
7080
- const frontIndex = shotAngles.indexOf("front");
7081
- const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
7082
- const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
7083
- const built = presenterRecordFrom({
7084
- name: job.name,
7085
- shotHashes,
7086
- shotAngles,
7087
- sourceHashes: hashes,
7088
- previewHash,
7089
- avatarHash,
7090
- promptName: draft?.promptName,
7091
- presentation: draft?.presentation,
7092
- descriptor: draft?.descriptor,
7093
- ageRange: draft?.ageRange,
7094
- hair: draft?.hair,
7095
- identityNotes: draft?.identityNotes,
7096
- negativeConstraints: draft?.negativeConstraints,
7097
- // What the caller asked for wins over what the analyzer guessed: the
7098
- // person choosing where this belongs knows their own library.
7099
- suitableCategories: job.facets.length ? job.facets : draft?.suitableCategories
7100
- });
7101
- if (!built.ok) throw new Error(built.error);
7102
- commit(core, job.brandId, (json) => {
7103
- json.characters = [...brandCharacters(json), built.presenter];
7104
- });
7105
- patch(job, {
7106
- stage: "done",
7107
- step: job.steps,
7108
- assetId: built.presenter.id,
7109
- previewHash: previewHash ?? cardSource ?? null,
7110
- warnings: [...job.warnings, ...warnings],
7111
- finished: true
7112
- });
7113
- }
7114
- async function generateStudioSet(deps, job, who, sourcePaths, signal) {
7115
- const engine = deps.engine;
7116
- if (!engine) return { hashes: [], angles: [] };
7117
- const caps = engine.capabilities();
7118
- if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
7119
- const byAngle = /* @__PURE__ */ new Map();
7120
- for (const frame of STUDIO_FRAMES) {
7121
- if (signal.aborted) throw new Error("cancelled");
7122
- const refs = frame.from === "sources" ? sourcePaths.slice(0, caps.maxReferenceImages) : [byAngle.get(frame.from)].filter((h) => !!h).map((h) => deps.core.images.pathFor(h));
7123
- if (!refs.length) continue;
7124
- try {
7125
- const drawn = await draw(deps, {
7126
- prompt: studioPrompt(frame.subject(who)),
7127
- brandId: job.brandId,
7128
- referenceImages: refs,
7129
- referenceRoles: refs.map(() => "character"),
7130
- signal
7131
- });
7132
- const hash = await trimEdgeBars(deps.core, drawn);
7133
- byAngle.set(frame.angle, hash);
7134
- patch(job, {
7135
- step: byAngle.size,
7136
- previewHash: job.previewHash ?? hash,
7137
- message: `Building the studio views (${byAngle.size} of ${STUDIO_FRAMES.length})`
7138
- });
7139
- } catch (err) {
7140
- if (signal.aborted) throw err;
7141
- if (frame.angle === "front") throw err;
7142
- patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
7143
- }
7144
- }
7145
- const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
7146
- return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
7147
- }
7148
7657
  async function edgeBarGeometry(buf) {
7149
7658
  const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7150
7659
  const W = info.width;
@@ -7215,7 +7724,7 @@ async function cardCrop(core, hash) {
7215
7724
  }
7216
7725
  var AVATAR_FIGURE_FRACTION = 0.22;
7217
7726
  var AVATAR_HEADROOM = 0.1;
7218
- var AVATAR_MAX_PX = 512;
7727
+ var AVATAR_MAX_PX = 1024;
7219
7728
  var FIGURE_TRIM_THRESHOLDS = [12, 25];
7220
7729
  async function avatarCrop(core, hash) {
7221
7730
  if (!hash || !core.images.has(hash)) return void 0;
@@ -7318,10 +7827,25 @@ async function figureBox(buf) {
7318
7827
  return null;
7319
7828
  }
7320
7829
  async function presenterCrops(core, hash, mode) {
7830
+ if (mode === "portrait") {
7831
+ const exists = !!hash && core.images.has(hash);
7832
+ return { previewHash: exists ? hash : void 0, avatarHash: await portraitAvatarCrop(core, hash) };
7833
+ }
7321
7834
  const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
7322
7835
  const avatarHash = mode === "generated" ? await avatarCrop(core, hash) ?? await avatarCropSmart(core, hash) : await avatarCropSmart(core, hash) ?? await avatarCrop(core, hash);
7323
7836
  return { previewHash, avatarHash };
7324
7837
  }
7838
+ async function portraitAvatarCrop(core, hash) {
7839
+ return crop(
7840
+ core,
7841
+ hash,
7842
+ (w, h) => {
7843
+ const size = Math.min(w, h);
7844
+ return { left: Math.max(0, Math.round((w - size) / 2)), top: 0, width: size, height: size };
7845
+ },
7846
+ AVATAR_MAX_PX
7847
+ );
7848
+ }
7325
7849
  async function cardCropSmart(core, hash) {
7326
7850
  return smartCover(core, hash, (w, h) => {
7327
7851
  if (w / h > 0.8) return { width: Math.round(h * 0.8), height: h };
@@ -7460,8 +7984,8 @@ async function draw(deps, req) {
7460
7984
  brand: deps.brandContext(req.brandId),
7461
7985
  referenceImages: req.referenceImages,
7462
7986
  referenceRoles: req.referenceRoles,
7463
- width: ASSET_WIDTH,
7464
- height: ASSET_HEIGHT,
7987
+ width: req.width ?? ASSET_WIDTH,
7988
+ height: req.height ?? ASSET_HEIGHT,
7465
7989
  count: 1
7466
7990
  };
7467
7991
  const estimate = await engine.costEstimate(generateReq).catch(() => 0);
@@ -8616,6 +9140,8 @@ function registerLogoRoutes(app, deps) {
8616
9140
  var scans = /* @__PURE__ */ new Map();
8617
9141
  var controllers = /* @__PURE__ */ new Map();
8618
9142
  var KEEP = 32;
9143
+ var HARD_STOP_MS = 42e3;
9144
+ var KEEP_MS = 10 * 6e4;
8619
9145
  function remember(state) {
8620
9146
  scans.set(state.id, state);
8621
9147
  while (scans.size > KEEP) {
@@ -8626,7 +9152,20 @@ function remember(state) {
8626
9152
  }
8627
9153
  }
8628
9154
  function getScan(scanId) {
8629
- return scans.get(scanId);
9155
+ const state = scans.get(scanId);
9156
+ if (!state) return void 0;
9157
+ if (state.status !== "running" && Date.now() - state.startedAt > KEEP_MS) {
9158
+ scans.delete(scanId);
9159
+ controllers.delete(scanId);
9160
+ return void 0;
9161
+ }
9162
+ return state;
9163
+ }
9164
+ function cancelScan(scanId) {
9165
+ const ctrl = controllers.get(scanId);
9166
+ if (!ctrl) return false;
9167
+ ctrl.abort();
9168
+ return true;
8630
9169
  }
8631
9170
  function startCatalogScan(deps, brandId, url) {
8632
9171
  const { core, fetchImpl } = deps;
@@ -8636,13 +9175,22 @@ function startCatalogScan(deps, brandId, url) {
8636
9175
  const state = { id, brandId, url, status: "running", startedAt: Date.now() };
8637
9176
  remember(state);
8638
9177
  controllers.set(id, ctrl);
9178
+ let hardStopped = false;
9179
+ const stopper = setTimeout(() => {
9180
+ hardStopped = true;
9181
+ ctrl.abort();
9182
+ }, HARD_STOP_MS);
9183
+ stopper.unref?.();
8639
9184
  void scanForCandidates({ url, fetchImpl, signal: ctrl.signal }).then((result) => {
8640
9185
  state.result = result;
8641
9186
  state.status = "done";
8642
9187
  }).catch((err) => {
8643
9188
  state.status = "error";
8644
- state.error = err instanceof Error ? err.message : String(err);
8645
- }).finally(() => controllers.delete(id));
9189
+ state.error = hardStopped ? "This site took too long to look through." : err instanceof Error ? err.message : String(err);
9190
+ }).finally(() => {
9191
+ clearTimeout(stopper);
9192
+ controllers.delete(id);
9193
+ });
8646
9194
  return { scanId: id };
8647
9195
  }
8648
9196
 
@@ -8686,6 +9234,13 @@ function registerCatalogImportRoutes(app, deps) {
8686
9234
  if (!scan || scan.brandId !== brandId) return reply.status(404).send({ error: "scan not found" });
8687
9235
  return scan;
8688
9236
  });
9237
+ app.post("/api/brands/:id/catalog/scans/:scanId/cancel", async (req, reply) => {
9238
+ const brandId = req.params.id;
9239
+ const scanId = req.params.scanId;
9240
+ const scan = getScan(scanId);
9241
+ if (!scan || scan.brandId !== brandId) return reply.status(404).send({ error: "scan not found" });
9242
+ return { ok: cancelScan(scanId) };
9243
+ });
8689
9244
  app.post("/api/brands/:id/catalog/details", async (req, reply) => {
8690
9245
  const brand = core.store.getBrand(req.params.id);
8691
9246
  if (!brand) return reply.status(404).send({ error: "brand not found" });
@@ -8935,8 +9490,11 @@ function registerPresenterRoutes(app, deps) {
8935
9490
  }
8936
9491
 
8937
9492
  // src/presenterRepair.ts
8938
- function presenterCropMode(firstShotFile, firstSourceFile) {
8939
- return firstShotFile && firstShotFile === firstSourceFile ? "upload" : "generated";
9493
+ function presenterCropMode(firstShotFile, sourceFiles, firstShotAngle) {
9494
+ const sources = Array.isArray(sourceFiles) ? sourceFiles : [sourceFiles];
9495
+ if (firstShotFile && sources.includes(firstShotFile)) return "upload";
9496
+ if (firstShotAngle === "portrait") return "portrait";
9497
+ return "generated";
8940
9498
  }
8941
9499
  async function repairPresenterCrops(core, log = () => {
8942
9500
  }) {
@@ -8948,7 +9506,11 @@ async function repairPresenterCrops(core, log = () => {
8948
9506
  const firstShot = c.shots?.[0]?.file;
8949
9507
  const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
8950
9508
  if (!hash) continue;
8951
- const mode = presenterCropMode(firstShot, c.sourceRefs?.[0]?.file);
9509
+ const mode = presenterCropMode(
9510
+ firstShot,
9511
+ (c.sourceRefs ?? []).map((s) => s?.file),
9512
+ c.shots?.[0]?.angle
9513
+ );
8952
9514
  const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
8953
9515
  const preview = previewHash ? `asset:${previewHash}` : void 0;
8954
9516
  const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
@@ -8967,22 +9529,1164 @@ async function repairPresenterCrops(core, log = () => {
8967
9529
  return { repaired };
8968
9530
  }
8969
9531
 
9532
+ // src/presenterPrompts.ts
9533
+ var CAPTURE_UNIFORM = "a fitted off-white ribbed tank top and matching fitted off-white leggings, barefoot";
9534
+ var STUDIO_SET = "against a solid seamless white studio background, eye-level camera with gentle 85mm-equivalent portrait compression and a soft shallow depth of field, one large soft key light with gentle fill producing even, flattering, true-to-life beauty light, while keeping fine natural skin texture at pore scale and true-to-life proportions, the complexion even and uniform in tone across face, neck and shoulders, never airbrushed, plastic, or synthetic-looking, a calm quietly confident expression, true-to-life color grade with minimal retouch";
9535
+ function studioPrompt(subject) {
9536
+ return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind, the seamless studio backdrop runs past all four edges and is the only thing behind the subject at every edge of the frame. ${subject}, ${STUDIO_SET}. No text, no logos, no watermarks anywhere in the frame.`;
9537
+ }
9538
+ var VIEW_ROLES = [
9539
+ { id: "portrait", tier: "core", gate: true, from: [], label: "face" },
9540
+ { id: "front", tier: "core", gate: true, from: ["portrait"], label: "front view" },
9541
+ { id: "three-quarter", tier: "core", from: ["portrait", "front"], label: "three-quarter view" },
9542
+ { id: "back", tier: "supplementary", from: ["portrait", "front"], label: "back view" },
9543
+ { id: "left", tier: "supplementary", from: ["portrait", "front"], label: "left view" },
9544
+ // Never from the left: drawing one profile off the other is the surest way
9545
+ // to put a trait on the wrong side of a face. See refDeps.
9546
+ { id: "right", tier: "supplementary", from: ["portrait", "front", "left"], label: "right view" }
9547
+ ];
9548
+ var idsWhere = (want) => VIEW_ROLES.filter(want).map((r) => r.id);
9549
+ var PRESENTER_VIEWS = idsWhere(() => true);
9550
+ var CORE_VIEWS = idsWhere((r) => r.tier === "core");
9551
+ var EXTRA_VIEWS = idsWhere((r) => r.tier === "supplementary");
9552
+ var DEPENDS = Object.fromEntries(VIEW_ROLES.map((r) => [r.id, r.from]));
9553
+ var VIEW_LABEL = Object.fromEntries(VIEW_ROLES.map((r) => [r.id, r.label]));
9554
+ var HAND_APPROVED = new Set(idsWhere((r) => r.gate === true));
9555
+ function viewSubject(view, who) {
9556
+ switch (view) {
9557
+ case "portrait":
9558
+ return `${who}, head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens, wearing ${CAPTURE_UNIFORM}, their own hair exactly as the references show it, the same plain studio backdrop and even frontal light`;
9559
+ case "front":
9560
+ return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, facing the camera straight-on, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
9561
+ case "three-quarter":
9562
+ return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned about forty-five degrees from the camera so that both eyes stay in frame (a three-quarter view), the head turned with the body, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
9563
+ case "left":
9564
+ return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned so that their left side faces the camera in a full profile, the head in profile too, this being a turn of the same body and never a mirror image of it so anything on one side of them stays on that side, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
9565
+ case "back":
9566
+ return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned to face directly away from the camera so the back of the head, the shoulders and the legs are to the lens, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
9567
+ case "right":
9568
+ return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned so that their right side faces the camera in a full profile, the head in profile too, this being a turn of the same body and never a mirror image of it so anything on one side of them stays on that side, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
9569
+ }
9570
+ }
9571
+ function syntheticIdentitySubject(direction, opts = {}) {
9572
+ const person = `an adult, ${direction.trim()}: an original person who does not resemble any real, famous or public figure, mature adult facial structure`;
9573
+ const framing = "head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens";
9574
+ const skin = "visible natural skin texture with pores and fine lines, no beauty filter, no digital smoothing, the plain studio backdrop and even frontal light";
9575
+ const body = `${person}, ${framing}, wearing ${CAPTURE_UNIFORM}, ${skin}`;
9576
+ const adjustment = opts.adjustment?.trim();
9577
+ if (!adjustment) return body;
9578
+ return `the same person as the attached image, changed only in this: ${adjustment}. That change is the point of this picture and overrides anything below that describes it otherwise. ${keptAspects(adjustment)}; ${body}`;
9579
+ }
9580
+ var ASPECTS = [
9581
+ {
9582
+ name: "face",
9583
+ except: "the rest of the face",
9584
+ words: /\b(face|facial|jaw|chin|cheeks?|cheekbones?|nose|mouth|lips?|teeth|smile|eyes?|eyelids?|eyebrows?|brows?|lashes|freckles?|beard|moustache|mustache|stubble|glasses|expression|skin|complexion|wrinkles?|scars?|birthmarks?|moles?|piercings?|studs?|makeup|make-up|eyeliner|lipstick)\b/i
9585
+ },
9586
+ {
9587
+ name: "hair",
9588
+ words: /\b(hair|haircut|hairline|bob|fringe|bangs|ponytail|bun|braids?|curls?|curly|straighter|bald|shaved|beard)\b/i
9589
+ },
9590
+ { name: "age", words: /\b(age|aged|older|younger|youthful|years old|teenage|[2-7]0s)\b/i },
9591
+ {
9592
+ name: "build",
9593
+ words: /\b(build|body|frame|slim|slimmer|slender|thin|athletic|muscular|heavier|leaner|fuller|broader|broad|shoulders|weight|taller|shorter)\b/i
9594
+ },
9595
+ {
9596
+ // What a body carries rather than what shape it is. Without this row an ask
9597
+ // for a tattoo or a prosthetic arm shipped "otherwise identical in face,
9598
+ // hair, age and build" beside it, which is a contradiction: the picture
9599
+ // being asked for is not identical, that is the point of asking.
9600
+ name: "marks and limbs",
9601
+ except: "their other marks and limbs",
9602
+ words: /\b(tattoos?|tattooed|scars?|birthmarks?|moles?|piercings?|prosthetics?|prosthesis|bionic|limbs?|arms?|hands?|legs?)\b/i
9603
+ }
9604
+ ];
9605
+ function keptAspects(adjustment) {
9606
+ const kept = ASPECTS.map((a) => a.words.test(adjustment) ? a.except : a.name).filter((x) => !!x);
9607
+ if (!kept.length) return "Otherwise the same person as the attached image";
9608
+ const list2 = kept.length === 1 ? kept[0] : `${kept.slice(0, -1).join(", ")} and ${kept.at(-1)}`;
9609
+ return `Otherwise identical to the attached image in ${list2}`;
9610
+ }
9611
+ var keepSentenceOf = (items) => items.map((i) => i.words).join(", ");
9612
+ function itemsFromKeep(keep) {
9613
+ return (keep ?? "").split(",").map((w) => w.trim()).filter(Boolean).map((words, i) => ({ id: `said-${i}`, words }));
9614
+ }
9615
+ function sideNote(keep) {
9616
+ return /\b(left|right)\b/i.test(keep) ? " (their own left and right, not the viewer's)" : "";
9617
+ }
9618
+ var KEEP_FACE = /\b(face|facial|jaw|chin|cheeks?|cheekbones?|nose|nostrils?|septum|mouth|lips?|teeth|smile|eyes?|eyelids?|eyebrows?|brows?|lashes|freckles?|beard|moustache|mustache|stubble|glasses|spectacles|ears?|lobes?|temples?|forehead|skin|complexion|wrinkles?|makeup|make-up|eyeliner|lipstick)\b/i;
9619
+ var KEEP_BODY = /\b(arms?|forearms?|wrists?|hands?|knuckles?|fingers?|shoulders?|back|chest|collarbones?|torso|stomach|waist|hips?|legs?|thighs?|calf|calves|ankles?|feet|foot|toes?|knees?|prosthetics?|prosthesis|bionic|limbs?|sleeve)\b/i;
9620
+ var ROW_LIVES = {
9621
+ glasses: "face",
9622
+ freckles: "face",
9623
+ makeup: "face",
9624
+ scar: "face",
9625
+ piercing: "face"
9626
+ };
9627
+ function shows(view, item) {
9628
+ const lives = ROW_LIVES[item.id];
9629
+ const face = lives ? lives === "face" : KEEP_FACE.test(item.words);
9630
+ const body = lives ? lives === "body" : KEEP_BODY.test(item.words);
9631
+ if (face && !body) return view !== "back";
9632
+ if (body && !face) return view !== "portrait";
9633
+ return true;
9634
+ }
9635
+ function itemsFor(view, items) {
9636
+ return items.filter((i) => shows(view, i));
9637
+ }
9638
+ function keepFor(view, items) {
9639
+ return keepSentenceOf(itemsFor(view, items));
9640
+ }
9641
+ var ATTACHED_PERSON = "the exact person in the attached photographs";
9642
+ function identityOf2(rec) {
9643
+ return {
9644
+ said: (rec.direction ?? "").trim(),
9645
+ read: rec.analysis ?? null,
9646
+ items: rec.keepItems ?? itemsFromKeep(rec.keep),
9647
+ edits: (rec.identityEdits ?? []).filter(Boolean)
9648
+ };
9649
+ }
9650
+ function whoIs(id, view) {
9651
+ if (!id) return ATTACHED_PERSON;
9652
+ const bits = [];
9653
+ const read = id.read;
9654
+ const named = read?.promptName ?? "";
9655
+ if (named && id.said.toLowerCase().includes(named.toLowerCase())) bits.push(id.said);
9656
+ else if (named) bits.push(named);
9657
+ else if (id.said) bits.push(id.said);
9658
+ const already = (v) => bits.some((b) => b.toLowerCase().includes(v.toLowerCase()));
9659
+ if (read?.hair && !already(read.hair)) bits.push(read.hair);
9660
+ if (read?.identityNotes) bits.push(read.identityNotes);
9661
+ if (id.said && !already(id.said)) bits.push(id.said);
9662
+ const said = bits.filter(Boolean).join(", ") || ATTACHED_PERSON;
9663
+ const keep = keepFor(view, id.items);
9664
+ const who = keep ? `${said}, who also has ${keep}${sideNote(keep)}, which is part of who they are and is drawn in this view whether or not the attached images show it` : said;
9665
+ if (!id.edits.length) return who;
9666
+ return `${who}, except as changed here: ${id.edits.join("; ")}; the attached drawn views show the change`;
9667
+ }
9668
+ var RESULTS_MAX = 60;
9669
+ var isView = (v) => PRESENTER_VIEWS.includes(String(v));
9670
+ var isExtra = (v) => EXTRA_VIEWS.includes(v);
9671
+ var HASH = /^[a-f0-9]{32}$/;
9672
+ var fail = (message, statusCode) => Object.assign(new Error(message), { statusCode });
9673
+ var str4 = (v, max) => String(v ?? "").trim().slice(0, max);
9674
+ var emptySlot = () => ({ status: "empty", attempts: 0, rejected: [] });
9675
+ function fromRow(row) {
9676
+ const j = row.json ?? {};
9677
+ const views = {};
9678
+ for (const v of PRESENTER_VIEWS) views[v] = { ...emptySlot(), ...j.views?.[v] ?? {} };
9679
+ const rec = {
9680
+ id: row.id,
9681
+ brandId: row.brandId,
9682
+ source: j.source === "synthetic" ? "synthetic" : "photos",
9683
+ name: String(j.name ?? ""),
9684
+ facets: Array.isArray(j.facets) ? j.facets.map(String) : [],
9685
+ sources: Array.isArray(j.sources) ? j.sources.map(String) : [],
9686
+ views,
9687
+ extras: j.extras === true,
9688
+ generations: Number(j.generations ?? 0),
9689
+ activeView: isView(j.activeView) ? j.activeView : null,
9690
+ stage: j.stage === "analyzing" || j.stage === "drawing" ? j.stage : "idle",
9691
+ identityEdits: identityEditsOf(j.identityEdits),
9692
+ asks: asksOf(j.asks),
9693
+ results: resultsOf(j.results),
9694
+ decisions: decisionsOf(j.decisions),
9695
+ createdAt: row.createdAt,
9696
+ updatedAt: row.updatedAt
9697
+ };
9698
+ if (j.direction) rec.direction = String(j.direction);
9699
+ if (j.keep) rec.keep = String(j.keep);
9700
+ if (j.setup) rec.setup = String(j.setup);
9701
+ const keepItems = keepItemsOf(j.keepItems);
9702
+ if (keepItems) rec.keepItems = keepItems;
9703
+ const detailRefs = detailRefsOf(j.detailRefs);
9704
+ if (detailRefs) rec.detailRefs = detailRefs;
9705
+ if (j.attestation) rec.attestation = j.attestation;
9706
+ if (j.analysis) rec.analysis = j.analysis;
9707
+ if (j.readError) rec.readError = String(j.readError);
9708
+ if (j.presenterId) rec.presenterId = String(j.presenterId);
9709
+ if (j.baseId) rec.baseId = String(j.baseId);
9710
+ if (Array.isArray(j.keptShots) && j.keptShots.length) rec.keptShots = j.keptShots.map(shotOf);
9711
+ backfillRecord(rec);
9712
+ return rec;
9713
+ }
9714
+ function backfillRecord(rec) {
9715
+ if (rec.results.length) return;
9716
+ const at = rec.updatedAt || rec.createdAt;
9717
+ for (const v of PRESENTER_VIEWS) {
9718
+ const s = rec.views[v];
9719
+ if (!s.hash || s.origin !== "generated") continue;
9720
+ if (s.adjustment && !rec.asks.some((a) => a.view === v && a.text === s.adjustment)) {
9721
+ rec.asks = [...rec.asks, { view: v, text: s.adjustment, at }];
9722
+ }
9723
+ rec.results = [
9724
+ ...rec.results,
9725
+ { view: v, hash: s.hash, at, ...s.adjustment ? { ask: s.adjustment } : {}, how: "drawn" }
9726
+ ];
9727
+ }
9728
+ }
9729
+ var asksOf = (v) => (Array.isArray(v) ? v : []).filter((a) => isView(a?.view) && typeof a?.text === "string" && a.text.trim()).map((a) => ({ view: a.view, text: String(a.text), at: String(a.at ?? "") })).slice(-40);
9730
+ var resultsOf = (v) => (Array.isArray(v) ? v : []).filter((r) => isView(r?.view) && HASH.test(String(r?.hash ?? ""))).map((r) => ({
9731
+ view: r.view,
9732
+ hash: String(r.hash),
9733
+ at: String(r.at ?? ""),
9734
+ ...typeof r.ask === "string" && r.ask ? { ask: String(r.ask) } : {},
9735
+ how: r.how === "restored" ? "restored" : "drawn"
9736
+ })).slice(-RESULTS_MAX);
9737
+ var decisionsOf = (v) => (Array.isArray(v) ? v : []).filter((d) => isView(d?.view) && ["use", "again", "keep"].includes(d?.what)).map((d) => ({ view: d.view, what: d.what, at: String(d.at ?? "") })).slice(-RESULTS_MAX);
9738
+ var shotOf = (s) => ({
9739
+ file: String(s?.file ?? ""),
9740
+ ...s?.angle ? { angle: String(s.angle) } : {},
9741
+ ...s?.locked ? { locked: true } : {}
9742
+ });
9743
+ function put(core, rec) {
9744
+ const { id, brandId, createdAt: _c, updatedAt: _u, ...json } = rec;
9745
+ return fromRow(core.store.putPresenterDraft({ id, brandId, json }));
9746
+ }
9747
+ function mutate(core, id, fn) {
9748
+ const rec = getPresenterDraft(core, id);
9749
+ if (!rec) throw fail("draft not found", 404);
9750
+ fn(rec);
9751
+ return put(core, rec);
9752
+ }
9753
+ function getPresenterDraft(core, id) {
9754
+ const row = core.store.getPresenterDraft(id);
9755
+ return row ? fromRow(row) : null;
9756
+ }
9757
+ function bestPicture(rec) {
9758
+ const drawn = PRESENTER_VIEWS.map((v) => rec.views[v]).find((s) => s.hash);
9759
+ return rec.views.portrait.hash ?? drawn?.hash ?? rec.sources[0];
9760
+ }
9761
+ function summarisePresenterDraft(rec) {
9762
+ const wanted = rec.extras ? PRESENTER_VIEWS : CORE_VIEWS;
9763
+ const summary = {
9764
+ id: rec.id,
9765
+ name: rec.name,
9766
+ source: rec.source,
9767
+ updatedAt: rec.updatedAt,
9768
+ createdAt: rec.createdAt,
9769
+ stage: rec.stage,
9770
+ approved: wanted.filter((v) => rec.views[v].status === "approved").length,
9771
+ of: wanted.length,
9772
+ drawing: rec.stage !== "idle" || !!rec.activeView,
9773
+ // Drawn work a discard would throw away, decided or not. A photograph is
9774
+ // still on disk as itself, so it does not count; a candidate face does,
9775
+ // which counting approved views alone would have missed.
9776
+ drawn: PRESENTER_VIEWS.some((v) => {
9777
+ const s = rec.views[v];
9778
+ return !!s.hash && s.origin !== "photo" && s.status !== "empty";
9779
+ })
9780
+ };
9781
+ const hash = bestPicture(rec);
9782
+ if (hash) summary.hash = hash;
9783
+ if (rec.presenterId) summary.presenterId = rec.presenterId;
9784
+ return summary;
9785
+ }
9786
+ function listPresenterDraftSummaries(core, brandId) {
9787
+ return listPresenterDrafts(core, brandId).map(summarisePresenterDraft).sort((a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : 1);
9788
+ }
9789
+ function listPresenterDrafts(core, brandId) {
9790
+ return core.store.listPresenterDrafts(brandId).map(fromRow);
9791
+ }
9792
+ var running3 = /* @__PURE__ */ new Map();
9793
+ function runningDraftJobCount() {
9794
+ return running3.size;
9795
+ }
9796
+ function sweepPresenterDrafts(core) {
9797
+ let swept = 0;
9798
+ for (const brand of core.store.listBrands()) {
9799
+ for (const rec of listPresenterDrafts(core, brand.id)) {
9800
+ const stuck = PRESENTER_VIEWS.filter((v) => rec.views[v].status === "generating");
9801
+ if (!stuck.length && !rec.activeView && rec.stage === "idle") continue;
9802
+ for (const v of stuck) {
9803
+ const slot = rec.views[v];
9804
+ slot.status = slot.hash ? "candidate" : "empty";
9805
+ slot.error = "interrupted: server restarted mid-generation";
9806
+ slot.startedAt = void 0;
9807
+ }
9808
+ rec.activeView = null;
9809
+ rec.stage = "idle";
9810
+ put(core, rec);
9811
+ swept += 1;
9812
+ }
9813
+ }
9814
+ return swept;
9815
+ }
9816
+ var KEEP_ITEM_CHARS = 200;
9817
+ var SETUP_CHARS = 4e4;
9818
+ var KEEP_ITEMS_MAX = 12;
9819
+ function keepItemsOf(raw, has) {
9820
+ if (!Array.isArray(raw)) return void 0;
9821
+ const out = [];
9822
+ const seen = /* @__PURE__ */ new Set();
9823
+ for (const r of raw.slice(0, KEEP_ITEMS_MAX)) {
9824
+ if (!r || typeof r !== "object") continue;
9825
+ const words = str4(r.words, KEEP_ITEM_CHARS);
9826
+ if (!words) continue;
9827
+ let id = str4(r.id, 40) || "said";
9828
+ while (seen.has(id)) id = `${id}+`;
9829
+ seen.add(id);
9830
+ const refs = (Array.isArray(r.refs) ? r.refs : []).map(String).filter((h) => HASH.test(h) && (!has || has(h))).slice(0, 4);
9831
+ out.push(refs.length ? { id, words, refs } : { id, words });
9832
+ }
9833
+ return out;
9834
+ }
9835
+ function writeItems(r, items) {
9836
+ r.keepItems = items.length ? items : void 0;
9837
+ r.keep = keepSentenceOf(items) || void 0;
9838
+ const refs = {};
9839
+ for (const i of items) if (i.refs?.length) refs[i.id] = i.refs;
9840
+ r.detailRefs = Object.keys(refs).length ? refs : void 0;
9841
+ }
9842
+ function detailRefsOf(raw, has) {
9843
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
9844
+ const out = {};
9845
+ for (const [k, v] of Object.entries(raw).slice(0, 8)) {
9846
+ const key = str4(k, 40);
9847
+ if (!key || !Array.isArray(v)) continue;
9848
+ const hashes = v.map(String).filter((h) => HASH.test(h) && (!has || has(h))).slice(0, 4);
9849
+ if (hashes.length) out[key] = hashes;
9850
+ }
9851
+ return Object.keys(out).length ? out : void 0;
9852
+ }
9853
+ async function createPresenterDraft(deps, input) {
9854
+ const { core } = deps;
9855
+ const brand = core.store.getBrand(input.brandId);
9856
+ if (!brand) throw fail("brand not found", 404);
9857
+ const source = input.source === "synthetic" ? "synthetic" : "photos";
9858
+ const direction = str4(input.direction, 400);
9859
+ const keep = str4(input.keep, 240);
9860
+ const keepItems = keepItemsOf(input.keepItems, (h) => core.images.has(h));
9861
+ const sources = (input.imageHashes ?? []).map(String).filter((h) => HASH.test(h) && core.images.has(h));
9862
+ if (source === "synthetic" && !direction) throw fail("describe who they are in a sentence", 400);
9863
+ if (source === "synthetic" && !deps.engine) throw fail("no engine here can draw a person", 400);
9864
+ if (source === "photos" && !sources.length) throw fail("add at least one photo of this person", 400);
9865
+ if (source === "photos" && !input.attestation) {
9866
+ throw fail("confirm you have permission to use this person's likeness", 400);
9867
+ }
9868
+ const views = {};
9869
+ for (const v of PRESENTER_VIEWS) views[v] = emptySlot();
9870
+ const rec = {
9871
+ id: `pd-${randomUUID().slice(0, 8)}`,
9872
+ brandId: brand.id,
9873
+ source,
9874
+ name: str4(input.name, 60),
9875
+ facets: (input.facets ?? []).map((f) => str4(f, 40)).filter(Boolean).slice(0, 8),
9876
+ sources: source === "photos" ? sources : [],
9877
+ views,
9878
+ extras: input.extras === true,
9879
+ generations: 0,
9880
+ activeView: null,
9881
+ stage: "idle",
9882
+ identityEdits: [],
9883
+ asks: [],
9884
+ results: [],
9885
+ decisions: [],
9886
+ createdAt: "",
9887
+ updatedAt: ""
9888
+ };
9889
+ if (keepItems) writeItems(rec, keepItems);
9890
+ else {
9891
+ if (keep) rec.keep = keep;
9892
+ const detailRefs = detailRefsOf(input.detailRefs, (h) => core.images.has(h));
9893
+ if (detailRefs) rec.detailRefs = detailRefs;
9894
+ }
9895
+ if (source === "synthetic") rec.direction = direction;
9896
+ else {
9897
+ rec.attestation = { attestedAt: (/* @__PURE__ */ new Date()).toISOString(), version: LIKENESS_VERSION };
9898
+ if (direction) rec.direction = direction;
9899
+ }
9900
+ const saved = put(core, rec);
9901
+ if (source === "photos") startJob(deps, saved.id, null, (signal) => filePhotos(deps, saved.id, signal));
9902
+ return saved;
9903
+ }
9904
+ var hashOfFile = (file) => {
9905
+ const s = String(file ?? "");
9906
+ return s.startsWith("asset:") && HASH.test(s.slice(6)) ? s.slice(6) : null;
9907
+ };
9908
+ var seedDirection = (p) => {
9909
+ const said = str4(p.identityNotes ?? p.descriptor ?? p.promptName, 400);
9910
+ if (said) return said;
9911
+ return str4([p.presentation, p.ageRange, p.hair && `${p.hair} hair`].filter(Boolean).join(", "), 400);
9912
+ };
9913
+ function slotsFromRecord(presenter) {
9914
+ const sources = (presenter.sourceRefs ?? []).map((s) => hashOfFile(s.file)).filter((h) => !!h);
9915
+ const views = {};
9916
+ for (const v of PRESENTER_VIEWS) views[v] = emptySlot();
9917
+ const kept = [];
9918
+ const shots = presenter.shots ?? [];
9919
+ const hasPortrait = shots.some((s) => s.angle === "portrait" && hashOfFile(s.file));
9920
+ shots.forEach((shot, i) => {
9921
+ const hash = hashOfFile(shot.file);
9922
+ const angle = !hasPortrait && i === 0 ? "portrait" : shot.angle;
9923
+ if (hash && isView(angle) && views[angle].status === "empty") {
9924
+ const origin = sources.includes(hash) ? "photo" : "generated";
9925
+ views[angle] = { ...emptySlot(), status: "approved", hash, origin };
9926
+ } else kept.push({ ...shot });
9927
+ });
9928
+ return { views, kept, sources };
9929
+ }
9930
+ function seedDraftFromPresenter(core, brandId, presenter) {
9931
+ const { views, kept, sources } = slotsFromRecord(presenter);
9932
+ const source = presenter.source ?? (presenter.sourceRefs?.length ? "photos" : "synthetic");
9933
+ const direction = seedDirection(presenter);
9934
+ const rec = {
9935
+ id: `pd-${randomUUID().slice(0, 8)}`,
9936
+ brandId,
9937
+ source,
9938
+ name: str4(presenter.name, 60),
9939
+ facets: (presenter.suitableCategories ?? []).map((f) => str4(f, 40)).filter(Boolean).slice(0, 8),
9940
+ sources,
9941
+ views,
9942
+ // Every one of them, not any: a record carrying a single supplementary
9943
+ // view was never a record whose owner asked for the whole set, and
9944
+ // reading it that way made the save demand two more nobody wanted.
9945
+ extras: EXTRA_VIEWS.every((v) => views[v].status !== "empty"),
9946
+ generations: 0,
9947
+ activeView: null,
9948
+ stage: "idle",
9949
+ presenterId: presenter.id,
9950
+ baseId: presenter.id,
9951
+ identityEdits: identityEditsOf(presenter.identityEdits),
9952
+ asks: [],
9953
+ results: [],
9954
+ decisions: [],
9955
+ createdAt: "",
9956
+ updatedAt: ""
9957
+ };
9958
+ if (direction) rec.direction = direction;
9959
+ if (presenter.likeness) rec.attestation = presenter.likeness;
9960
+ if (kept.length) rec.keptShots = kept;
9961
+ return put(core, rec);
9962
+ }
9963
+ function openPresenterEdit(core, brandId, presenterId) {
9964
+ const brand = core.store.getBrand(brandId);
9965
+ if (!brand) throw fail("brand not found", 404);
9966
+ const rows = brandCharacters(brand.json);
9967
+ const asked = rows.find((c) => c?.id === presenterId);
9968
+ if (!asked) throw fail("presenter not found", 404);
9969
+ if (!isCustomPresenter(asked)) throw fail("this presenter is not editable", 400);
9970
+ const headId = headOf(brand.json, presenterId);
9971
+ const head = rows.find((c) => c?.id === headId);
9972
+ return listPresenterDrafts(core, brandId).find((d) => d.presenterId === headId) ?? seedDraftFromPresenter(core, brandId, head);
9973
+ }
9974
+ var IDENTITY_WORDS = [
9975
+ "hair",
9976
+ "fringe",
9977
+ "bangs",
9978
+ "curls",
9979
+ "beard",
9980
+ "moustache",
9981
+ "mustache",
9982
+ "stubble",
9983
+ "eyebrows",
9984
+ "eyebrow",
9985
+ "brows",
9986
+ "brow",
9987
+ "eyes",
9988
+ "eye",
9989
+ "lashes",
9990
+ "nose",
9991
+ "lips",
9992
+ "mouth",
9993
+ "teeth",
9994
+ "smile",
9995
+ "chin",
9996
+ "jawline",
9997
+ "jaw",
9998
+ "cheekbones",
9999
+ "cheeks",
10000
+ "ears",
10001
+ "neck",
10002
+ "skin",
10003
+ "freckles",
10004
+ "scar",
10005
+ "mole",
10006
+ "tattoo",
10007
+ "wrinkles",
10008
+ "glasses",
10009
+ "earrings",
10010
+ "earring",
10011
+ "piercing",
10012
+ "makeup",
10013
+ "age",
10014
+ "older",
10015
+ "younger",
10016
+ "build",
10017
+ "weight",
10018
+ "height",
10019
+ "shoulders"
10020
+ ];
10021
+ var TRAIT_ALIAS = {
10022
+ fringe: "hair",
10023
+ bangs: "hair",
10024
+ curls: "hair",
10025
+ mustache: "moustache",
10026
+ stubble: "beard",
10027
+ eyebrow: "brow",
10028
+ eyebrows: "brow",
10029
+ brows: "brow",
10030
+ eye: "eyes",
10031
+ lashes: "eyes",
10032
+ jawline: "jaw",
10033
+ cheeks: "cheekbones",
10034
+ earring: "earrings",
10035
+ older: "age",
10036
+ younger: "age",
10037
+ weight: "build",
10038
+ height: "build"
10039
+ };
10040
+ var TRAIT = new RegExp(`\\b(${IDENTITY_WORDS.join("|")})\\b`, "i");
10041
+ function traitOf(sentence) {
10042
+ const m = TRAIT.exec(sentence);
10043
+ if (!m) return null;
10044
+ const word = m[1].toLowerCase();
10045
+ return TRAIT_ALIAS[word] ?? word;
10046
+ }
10047
+ function mergeIdentityEdits(existing, adjustment) {
10048
+ const next = str4(adjustment, IDENTITY_EDIT_CHARS).replace(/\s+/g, " ");
10049
+ if (!next) return existing.slice(-IDENTITY_EDITS_MAX);
10050
+ const trait = traitOf(next);
10051
+ const kept = existing.filter((e) => e.toLowerCase() !== next.toLowerCase() && (!trait || traitOf(e) !== trait));
10052
+ return [...kept, next].slice(-IDENTITY_EDITS_MAX);
10053
+ }
10054
+ async function filePhotos(deps, id, signal) {
10055
+ const { core, analyzer } = deps;
10056
+ const rec = getPresenterDraft(core, id);
10057
+ if (!rec) return;
10058
+ let analysis;
10059
+ let readError;
10060
+ if (analyzer) {
10061
+ mutate(core, id, (r) => {
10062
+ r.stage = "analyzing";
10063
+ });
10064
+ try {
10065
+ analysis = await analyzer.analyze(
10066
+ {
10067
+ kind: "presenter",
10068
+ imagePaths: rec.sources.map((h) => core.images.pathFor(h)),
10069
+ name: rec.name || "New presenter",
10070
+ instruction: rec.direction || void 0,
10071
+ vocabulary: deps.vocabulary,
10072
+ classifyPhotos: true
10073
+ },
10074
+ signal
10075
+ );
10076
+ } catch (err) {
10077
+ if (signal.aborted) return;
10078
+ readError = String(err?.message ?? "the photos could not be read");
10079
+ }
10080
+ }
10081
+ if (signal.aborted) return;
10082
+ mutate(core, id, (r) => {
10083
+ if (analysis) r.analysis = analysis;
10084
+ r.readError = readError;
10085
+ });
10086
+ }
10087
+ function startJob(deps, id, view, work) {
10088
+ const ctrl = new AbortController();
10089
+ running3.set(id, { view, ctrl });
10090
+ void work(ctrl.signal).catch(() => {
10091
+ }).finally(() => {
10092
+ running3.delete(id);
10093
+ try {
10094
+ mutate(deps.core, id, (r) => {
10095
+ r.activeView = null;
10096
+ r.stage = "idle";
10097
+ });
10098
+ } catch {
10099
+ }
10100
+ });
10101
+ }
10102
+ async function generateView(deps, id, view, opts = {}) {
10103
+ const { core, engine } = deps;
10104
+ if (!isView(view)) throw fail("no such view", 400);
10105
+ let rec = getPresenterDraft(core, id);
10106
+ if (!rec) throw fail("draft not found", 404);
10107
+ if (running3.has(id)) throw fail("a view is still being drawn", 409);
10108
+ if (isExtra(view) && !rec.extras) throw fail("extra views are built on request", 400);
10109
+ if (rec.source === "synthetic" && !rec.direction?.trim()) {
10110
+ const said = str4(opts.adjustment, 400);
10111
+ if (!said) throw fail("describe who they are in a sentence", 400);
10112
+ rec = mutate(core, id, (r) => {
10113
+ r.direction = said;
10114
+ });
10115
+ }
10116
+ if (opts.decide === "auto" && HAND_APPROVED.has(view))
10117
+ throw fail(`the ${VIEW_LABEL[view]} is always decided by hand`, 400);
10118
+ for (const dep of DEPENDS[view]) {
10119
+ if (rec.views[dep].status !== "approved") throw fail(`approve the ${VIEW_LABEL[dep]} first`, 400);
10120
+ }
10121
+ if (!engine || !engine.capabilities().maxReferenceImages) throw fail("no engine here can draw a person", 400);
10122
+ const adjustment = str4(opts.adjustment, 240) || void 0;
10123
+ const decide = opts.decide === "auto" ? "auto" : void 0;
10124
+ const before = rec.views[view].status;
10125
+ const saved = mutate(core, id, (r) => {
10126
+ r.views[view].status = "generating";
10127
+ r.views[view].error = void 0;
10128
+ r.views[view].startedAt = (/* @__PURE__ */ new Date()).toISOString();
10129
+ r.activeView = view;
10130
+ r.stage = "drawing";
10131
+ const last = r.asks[r.asks.length - 1];
10132
+ if (adjustment && !(last && last.view === view && last.text === adjustment)) {
10133
+ r.asks = [...r.asks, { view, text: adjustment, at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-40);
10134
+ }
10135
+ });
10136
+ startJob(deps, id, view, (signal) => drawView(deps, id, view, before, adjustment, decide, signal));
10137
+ return { draft: saved };
10138
+ }
10139
+ async function drawView(deps, id, view, before, adjustment, decide, signal) {
10140
+ const { core, engine, analyzer } = deps;
10141
+ const caps = engine.capabilities();
10142
+ let rec = getPresenterDraft(core, id);
10143
+ try {
10144
+ if (!rec.analysis && analyzer && view !== "portrait" && rec.views.portrait.hash) {
10145
+ mutate(core, id, (r) => {
10146
+ r.stage = "analyzing";
10147
+ });
10148
+ const analysis = await analyzer.analyze(
10149
+ {
10150
+ kind: "presenter",
10151
+ imagePaths: [core.images.pathFor(rec.views.portrait.hash)],
10152
+ name: rec.name || "New presenter",
10153
+ instruction: rec.direction || void 0,
10154
+ vocabulary: deps.vocabulary
10155
+ },
10156
+ signal
10157
+ );
10158
+ rec = mutate(core, id, (r) => {
10159
+ r.analysis = analysis;
10160
+ r.stage = "drawing";
10161
+ });
10162
+ }
10163
+ if (signal.aborted) throw new Error("cancelled");
10164
+ const { prompt, refs, roles } = planStep(rec, view, adjustment, caps.maxReferenceImages);
10165
+ const builtOn = new Map(refDeps(rec, view).map((v) => [v, rec.views[v].hash]));
10166
+ const paths = [];
10167
+ for (const h of refs) {
10168
+ const p = core.images.pathFor(h);
10169
+ paths.push(caps.maxReferenceEdge ? await capReferenceEdge(core, p, caps.maxReferenceEdge) : p);
10170
+ }
10171
+ const drawn = await draw(deps, {
10172
+ prompt,
10173
+ brandId: rec.brandId,
10174
+ ...paths.length ? { referenceImages: paths, referenceRoles: roles } : {},
10175
+ signal
10176
+ });
10177
+ const hash = await trimEdgeBars(core, drawn);
10178
+ mutate(core, id, (r) => {
10179
+ const slot = r.views[view];
10180
+ if (slot.hash && slot.hash !== hash) {
10181
+ if (before === "approved") {
10182
+ if (slot.prior && slot.prior !== slot.hash && !r.sources.includes(slot.prior)) {
10183
+ slot.rejected = [...slot.rejected, slot.prior];
10184
+ }
10185
+ slot.prior = slot.hash;
10186
+ } else if (slot.origin === "generated") slot.rejected = [...slot.rejected, slot.hash];
10187
+ }
10188
+ slot.status = "candidate";
10189
+ slot.hash = hash;
10190
+ slot.origin = "generated";
10191
+ slot.attempts += 1;
10192
+ slot.conditionedOn = refs;
10193
+ slot.adjustment = adjustment;
10194
+ slot.error = void 0;
10195
+ slot.startedAt = void 0;
10196
+ r.generations += 1;
10197
+ r.results = [
10198
+ ...r.results,
10199
+ { view, hash, at: (/* @__PURE__ */ new Date()).toISOString(), ...adjustment ? { ask: adjustment } : {}, how: "drawn" }
10200
+ ].slice(-RESULTS_MAX);
10201
+ if (decide === "auto") {
10202
+ slot.status = "approved";
10203
+ if (slot.prior) staleDependents(r, view);
10204
+ }
10205
+ for (const [v, was] of builtOn) {
10206
+ if (r.views[v].hash !== was) {
10207
+ slot.status = "stale";
10208
+ break;
10209
+ }
10210
+ }
10211
+ });
10212
+ } catch (err) {
10213
+ mutate(core, id, (r) => {
10214
+ const slot = r.views[view];
10215
+ if (!slot.hash && slot.prior) {
10216
+ slot.hash = slot.prior;
10217
+ slot.prior = void 0;
10218
+ slot.origin = "generated";
10219
+ slot.status = dependents(view).some((v) => r.views[v].hash) ? "approved" : "candidate";
10220
+ } else slot.status = before === "generating" ? slot.hash ? "candidate" : "empty" : before;
10221
+ slot.attempts += 1;
10222
+ if (adjustment) slot.adjustment = adjustment;
10223
+ slot.error = signal.aborted ? "cancelled" : String(err?.message ?? "the view could not be drawn");
10224
+ slot.startedAt = void 0;
10225
+ r.generations += 1;
10226
+ });
10227
+ }
10228
+ }
10229
+ function rolledFrom(rec) {
10230
+ const id = identityOf2(rec);
10231
+ const said = id.said.replace(/[.\s]+$/, "");
10232
+ const keep = keepFor("portrait", id.items);
10233
+ if (!keep) return said;
10234
+ return said ? `${said}, ${keep}` : keep;
10235
+ }
10236
+ var namesASide = (text) => /\b(left|right)\b/i.test(text ?? "");
10237
+ function refDeps(rec, view) {
10238
+ const deps = DEPENDS[view];
10239
+ if (view !== "right" || !namesASide(rec.keep)) return deps;
10240
+ return deps.filter((d) => d !== "left");
10241
+ }
10242
+ function usableSource(rec, hash) {
10243
+ const filings = rec.analysis?.photos;
10244
+ if (!filings?.length) return true;
10245
+ const i = rec.sources.indexOf(hash);
10246
+ const said = filings.find((p) => p.index === i);
10247
+ return said ? said.usable !== false : true;
10248
+ }
10249
+ function planStep(rec, view, adjustment, cap2) {
10250
+ const slot = rec.views[view];
10251
+ const id = identityOf2(rec);
10252
+ const room = Math.max(1, cap2);
10253
+ const shown = itemsFor(view, id.items);
10254
+ const wanted = rec.keepItems ? [...new Set(shown.flatMap((i) => i.refs ?? []))] : [...new Set(Object.values(rec.detailRefs ?? {}).flat())];
10255
+ const roll = rec.source === "synthetic" && view === "portrait";
10256
+ const identity = [];
10257
+ if (roll) {
10258
+ if (adjustment && slot.hash) identity.push(slot.hash);
10259
+ } else {
10260
+ for (const dep of refDeps(rec, view)) {
10261
+ const h = rec.views[dep].hash;
10262
+ if (h && rec.views[dep].status === "approved" && !identity.includes(h)) identity.push(h);
10263
+ }
10264
+ const stood = rec.sources.filter((h) => usableSource(rec, h));
10265
+ for (const h of stood.length ? stood : rec.sources) if (!identity.includes(h)) identity.push(h);
10266
+ }
10267
+ const hold = wanted.length && identity.length >= room ? 1 : 0;
10268
+ const kept = identity.slice(0, Math.max(1, room - hold));
10269
+ const extra = wanted.filter((h) => !kept.includes(h)).slice(0, Math.max(0, room - kept.length));
10270
+ const dropped = [
10271
+ ...identity.slice(kept.length).map((h) => `identity ${h}: no room`),
10272
+ ...wanted.filter((h) => !kept.includes(h) && !extra.includes(h)).map((h) => `detail ${h}: no room`),
10273
+ ...id.items.filter((i) => i.refs?.length && !shown.includes(i)).map((i) => `detail ${i.id}: not shown in this view`)
10274
+ ];
10275
+ const refs = [...kept, ...extra];
10276
+ const roles = [...kept.map(() => "character"), ...extra.map(() => "detail")];
10277
+ const named = shown.filter((i) => (i.refs ?? []).some((h) => extra.includes(h)));
10278
+ const legend = named.length ? ` The attached detail pictures show, in this order: ${named.map((i) => i.words).join("; ")}.` : "";
10279
+ const who = whoIs(id, view);
10280
+ const subject = roll ? syntheticIdentitySubject(rolledFrom(rec), { adjustment: kept.length ? adjustment : void 0 }) : adjustment ? (
10281
+ // The ask is scoped to this view and has to win where it meets the
10282
+ // view's own fixed clauses, which is why the override sentence rides
10283
+ // with it: every non-face subject already pins "the same person as the
10284
+ // attached images" and "their own hair exactly as the attached images
10285
+ // show it", so a repair that names hair or a feature was asking for one
10286
+ // thing and being told the opposite in the same breath. A second
10287
+ // "otherwise identical" would not have settled that; saying which one
10288
+ // wins does. What the ask does not name is then held still, the same
10289
+ // way it is on a described person's face.
10290
+ `${viewSubject(view, who)}. For this view only: ${adjustment}. That change is the point of this picture and overrides anything above that describes it otherwise. ${keptAspects(adjustment)}`
10291
+ ) : viewSubject(view, who);
10292
+ return { prompt: studioPrompt(subject) + legend, refs, roles, dropped };
10293
+ }
10294
+ async function approveView(deps, id, view) {
10295
+ if (!isView(view)) throw fail("no such view", 400);
10296
+ const rec = getPresenterDraft(deps.core, id);
10297
+ if (!rec) throw fail("draft not found", 404);
10298
+ const slot = rec.views[view];
10299
+ if (slot.status === "approved") {
10300
+ if (!slot.prior) return rec;
10301
+ return mutate(deps.core, id, (r) => {
10302
+ const s = r.views[view];
10303
+ if (s.prior && !r.sources.includes(s.prior)) s.rejected = [...s.rejected, s.prior];
10304
+ s.prior = void 0;
10305
+ });
10306
+ }
10307
+ if (slot.status === "stale") throw fail(`redo the ${VIEW_LABEL[view]}: it was built on a view you changed`, 400);
10308
+ if (slot.status !== "candidate" || !slot.hash) throw fail(`there is no ${VIEW_LABEL[view]} to approve yet`, 400);
10309
+ return mutate(deps.core, id, (r) => {
10310
+ const s = r.views[view];
10311
+ s.status = "approved";
10312
+ r.decisions = [...r.decisions, { view, what: "use", at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-RESULTS_MAX);
10313
+ if (s.prior) {
10314
+ if (!r.sources.includes(s.prior)) s.rejected = [...s.rejected, s.prior];
10315
+ s.prior = void 0;
10316
+ }
10317
+ reconcileDependents(r, view);
10318
+ if (view === "portrait" && r.presenterId && s.adjustment) {
10319
+ r.identityEdits = mergeIdentityEdits(r.identityEdits, s.adjustment);
10320
+ }
10321
+ });
10322
+ }
10323
+ async function revertView(deps, id, view) {
10324
+ if (!isView(view)) throw fail("no such view", 400);
10325
+ if (running3.has(id)) throw fail("a view is still being drawn", 409);
10326
+ const rec = getPresenterDraft(deps.core, id);
10327
+ if (!rec) throw fail("draft not found", 404);
10328
+ if (!rec.views[view].prior) throw fail(`there is no previous ${VIEW_LABEL[view]} to keep`, 400);
10329
+ return mutate(deps.core, id, (r) => {
10330
+ const s = r.views[view];
10331
+ const prior = s.prior;
10332
+ if (s.hash && s.hash !== prior && !r.sources.includes(s.hash)) s.rejected = [...s.rejected, s.hash];
10333
+ s.hash = prior;
10334
+ s.origin = r.sources.includes(prior) ? "photo" : "generated";
10335
+ s.status = "approved";
10336
+ s.prior = void 0;
10337
+ s.adjustment = void 0;
10338
+ s.conditionedOn = void 0;
10339
+ s.error = void 0;
10340
+ r.decisions = [...r.decisions, { view, what: "keep", at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-RESULTS_MAX);
10341
+ });
10342
+ }
10343
+ async function restoreView(deps, id, view, hash) {
10344
+ if (!isView(view)) throw fail("no such view", 400);
10345
+ if (!HASH.test(String(hash))) throw fail("no such picture", 400);
10346
+ if (running3.has(id)) throw fail("a view is still being drawn", 409);
10347
+ const rec = getPresenterDraft(deps.core, id);
10348
+ if (!rec) throw fail("draft not found", 404);
10349
+ const slot = rec.views[view];
10350
+ const known = rec.results.some((r) => r.view === view && r.hash === hash) || slot.prior === hash || slot.rejected.includes(hash);
10351
+ if (!known) throw fail(`that was never the ${VIEW_LABEL[view]}`, 400);
10352
+ if (!existsSync(deps.core.images.pathFor(hash))) throw fail("that picture is gone", 410);
10353
+ if (slot.hash === hash) return rec;
10354
+ const from = [...rec.results].reverse().find((r) => r.view === view && r.hash === hash);
10355
+ return mutate(deps.core, id, (r) => {
10356
+ const s = r.views[view];
10357
+ const was = s.hash;
10358
+ if (s.prior && s.prior !== was && s.prior !== hash && !r.sources.includes(s.prior)) {
10359
+ s.rejected = [...s.rejected, s.prior];
10360
+ }
10361
+ if (was) s.prior = was;
10362
+ s.rejected = s.rejected.filter((h) => h !== hash);
10363
+ s.hash = hash;
10364
+ s.origin = r.sources.includes(hash) ? "photo" : "generated";
10365
+ s.adjustment = from?.ask;
10366
+ s.conditionedOn = void 0;
10367
+ s.error = void 0;
10368
+ reconcileDependents(r, view);
10369
+ });
10370
+ }
10371
+ function dependents(view) {
10372
+ return PRESENTER_VIEWS.filter((v) => v !== view && reaches(v, view));
10373
+ }
10374
+ function reaches(from, to) {
10375
+ return DEPENDS[from].some((d) => d === to || reaches(d, to));
10376
+ }
10377
+ function stillStands(r, view) {
10378
+ const on = r.views[view].conditionedOn;
10379
+ if (!on?.length) return false;
10380
+ for (const h of on) {
10381
+ const owner = r.results.find((x) => x.hash === h)?.view;
10382
+ if (!owner || owner === view) continue;
10383
+ if (r.views[owner].hash !== h) return false;
10384
+ }
10385
+ return true;
10386
+ }
10387
+ function reconcileDependents(r, view) {
10388
+ for (const d of dependents(view)) {
10389
+ const s = r.views[d];
10390
+ if (!s.hash) continue;
10391
+ if (stillStands(r, d)) {
10392
+ if (s.status === "stale") s.status = "approved";
10393
+ } else if (s.status === "approved" || s.status === "candidate") s.status = "stale";
10394
+ }
10395
+ }
10396
+ function staleDependents(r, view) {
10397
+ for (const d of dependents(view)) {
10398
+ const s = r.views[d];
10399
+ if (s.origin === "photo") continue;
10400
+ if (s.status === "approved" || s.status === "candidate") s.status = "stale";
10401
+ }
10402
+ }
10403
+ async function redoView(deps, id, view) {
10404
+ if (!isView(view)) throw fail("no such view", 400);
10405
+ if (running3.has(id)) throw fail("a view is still being drawn", 409);
10406
+ return mutate(deps.core, id, (r) => {
10407
+ const slot = r.views[view];
10408
+ const gone = [slot.prior].filter((h) => !!h && h !== slot.hash && !r.sources.includes(h));
10409
+ r.views[view] = {
10410
+ ...emptySlot(),
10411
+ attempts: slot.attempts,
10412
+ rejected: [...slot.rejected, ...gone],
10413
+ ...slot.hash && !r.sources.includes(slot.hash) ? { prior: slot.hash } : {}
10414
+ };
10415
+ if (view === "portrait" && r.source === "synthetic") r.analysis = void 0;
10416
+ r.decisions = [...r.decisions, { view, what: "again", at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-RESULTS_MAX);
10417
+ });
10418
+ }
10419
+ async function usePhotoForView(deps, id, view, hash) {
10420
+ if (!isView(view)) throw fail("no such view", 400);
10421
+ if (running3.has(id)) throw fail("a view is still being drawn", 409);
10422
+ const rec = getPresenterDraft(deps.core, id);
10423
+ if (!rec) throw fail("draft not found", 404);
10424
+ if (rec.source !== "photos" || !rec.sources.includes(hash)) throw fail("that is not one of their photos", 400);
10425
+ return mutate(deps.core, id, (r) => {
10426
+ const slot = r.views[view];
10427
+ if (slot.hash === hash && slot.origin === "photo") return;
10428
+ const gone = [slot.hash, slot.prior].filter((h) => !!h && !r.sources.includes(h));
10429
+ r.views[view] = {
10430
+ ...emptySlot(),
10431
+ attempts: slot.attempts,
10432
+ rejected: [...slot.rejected, ...gone],
10433
+ status: "approved",
10434
+ hash,
10435
+ origin: "photo"
10436
+ };
10437
+ staleDependents(r, view);
10438
+ });
10439
+ }
10440
+ async function updatePresenterDraft(core, id, patch2) {
10441
+ return mutate(core, id, (r) => {
10442
+ if (patch2.extras !== void 0) r.extras = patch2.extras === true;
10443
+ if (patch2.name !== void 0) r.name = str4(patch2.name, 60);
10444
+ if (Array.isArray(patch2.facets))
10445
+ r.facets = patch2.facets.map((f) => str4(f, 40)).filter(Boolean).slice(0, 8);
10446
+ if (patch2.direction !== void 0) r.direction = str4(patch2.direction, 400) || void 0;
10447
+ if (patch2.setup !== void 0) r.setup = str4(patch2.setup, SETUP_CHARS) || void 0;
10448
+ const items = keepItemsOf(patch2.keepItems, (h) => core.images.has(h));
10449
+ if (items) writeItems(r, items);
10450
+ else {
10451
+ if (patch2.keep !== void 0) r.keep = str4(patch2.keep, 240) || void 0;
10452
+ if (patch2.detailRefs !== void 0) r.detailRefs = detailRefsOf(patch2.detailRefs, (h) => core.images.has(h));
10453
+ }
10454
+ });
10455
+ }
10456
+ async function savePresenterDraft(deps, id, hooks = {}) {
10457
+ const { core } = deps;
10458
+ const rec = getPresenterDraft(core, id);
10459
+ if (!rec) throw fail("draft not found", 404);
10460
+ if (running3.has(id)) throw fail("a view is still being drawn", 409);
10461
+ if (rec.presenterId) return saveEdit(deps, rec, hooks);
10462
+ if (!rec.name.trim()) throw fail("give them a name", 400);
10463
+ const blind = !deps.engine;
10464
+ const required = blind ? ["portrait"] : [...CORE_VIEWS, ...EXTRA_VIEWS.filter((v) => rec.views[v].status !== "empty")];
10465
+ for (const v of required) {
10466
+ const s = rec.views[v];
10467
+ if (s.status === "stale") throw fail(`redo the ${VIEW_LABEL[v]}: it was built on a view you changed`, 400);
10468
+ if (s.status !== "approved" || !s.hash) throw fail(`approve the ${VIEW_LABEL[v]} first`, 400);
10469
+ }
10470
+ const approved = required.map((v) => rec.views[v].hash);
10471
+ const shots = blind ? [...approved, ...rec.sources.filter((h) => !approved.includes(h))] : approved;
10472
+ const angles = blind ? ["portrait"] : [...required];
10473
+ const portraitFile = `asset:${shots[0]}`;
10474
+ const sourceFiles = rec.sources.map((h) => `asset:${h}`);
10475
+ const mode = presenterCropMode(portraitFile, sourceFiles, "portrait");
10476
+ const { previewHash, avatarHash } = await presenterCrops(core, shots[0], mode);
10477
+ const a = rec.analysis;
10478
+ const built = presenterRecordFrom({
10479
+ name: rec.name,
10480
+ shotHashes: shots,
10481
+ shotAngles: angles,
10482
+ ...rec.sources.length ? { sourceHashes: rec.sources } : {},
10483
+ previewHash,
10484
+ avatarHash,
10485
+ promptName: a?.promptName ?? (rec.source === "synthetic" ? rec.direction : void 0),
10486
+ presentation: a?.presentation,
10487
+ descriptor: a?.descriptor,
10488
+ ageRange: a?.ageRange,
10489
+ hair: a?.hair,
10490
+ identityNotes: keptNotes(rec, a),
10491
+ negativeConstraints: a?.negativeConstraints,
10492
+ // What the person filing this chose wins over what the analyzer guessed.
10493
+ suitableCategories: rec.facets.length ? rec.facets : a?.suitableCategories,
10494
+ facial: a?.facial,
10495
+ skin: a?.skin,
10496
+ build: a?.build,
10497
+ source: rec.source,
10498
+ ...rec.source === "photos" && rec.attestation ? { likeness: rec.attestation } : {}
10499
+ });
10500
+ if (!built.ok) throw fail(built.error, 400);
10501
+ commit(
10502
+ core,
10503
+ rec.brandId,
10504
+ (json) => {
10505
+ json.characters = [...brandCharacters(json), built.presenter];
10506
+ },
10507
+ // the row goes with the append, in one transaction: see `commit`
10508
+ id
10509
+ );
10510
+ removeUnreferenced(core, letGoOf(rec), hooks);
10511
+ return { presenter: built.presenter, brand: core.store.getBrand(rec.brandId) };
10512
+ }
10513
+ function keptNotes(rec, a) {
10514
+ const keep = rec.keep?.trim();
10515
+ const said = keep ? /[.!?]$/.test(keep) ? keep : `${keep}.` : "";
10516
+ return [said, a?.identityNotes ?? ""].filter(Boolean).join(" ") || void 0;
10517
+ }
10518
+ function letGoOf(rec) {
10519
+ return PRESENTER_VIEWS.flatMap((v) => {
10520
+ const s = rec.views[v];
10521
+ return [...s.rejected, ...s.prior && s.prior !== s.hash ? [s.prior] : []];
10522
+ });
10523
+ }
10524
+ async function saveEdit(deps, rec, hooks) {
10525
+ const { core } = deps;
10526
+ const brand = core.store.getBrand(rec.brandId);
10527
+ if (!brand) throw fail("brand not found", 404);
10528
+ const base = brandCharacters(brand.json).find((c) => c?.id === rec.presenterId);
10529
+ if (!base) throw fail("this presenter no longer exists", 404);
10530
+ const moved = () => fail("this presenter changed elsewhere; reload to continue", 409);
10531
+ const baseId = rec.baseId ?? "";
10532
+ if (!baseId || headOf(brand.json, baseId) !== baseId) throw moved();
10533
+ if (!rec.name.trim()) throw fail("give them a name", 400);
10534
+ const seeded = slotsFromRecord(base);
10535
+ const required = PRESENTER_VIEWS.filter((v) => rec.views[v].status !== "empty" || seeded.views[v].status !== "empty");
10536
+ for (const v of required) {
10537
+ const s = rec.views[v];
10538
+ if (s.status === "stale") throw fail(`redo the ${VIEW_LABEL[v]}: it was built on a view you changed`, 400);
10539
+ if (s.status !== "approved" || !s.hash) throw fail(`approve the ${VIEW_LABEL[v]} first`, 400);
10540
+ }
10541
+ const views = required.map((v) => ({ hash: rec.views[v].hash, angle: v }));
10542
+ const kept = rec.keptShots ?? [];
10543
+ const nextFiles = [...views.map((v) => `asset:${v.hash}`), ...kept.map((k) => k.file)];
10544
+ const baseFiles = (base.shots ?? []).map((s) => s.file);
10545
+ const samePictures = nextFiles.length === baseFiles.length && nextFiles.every((f) => baseFiles.includes(f));
10546
+ const sameEdits = JSON.stringify(rec.identityEdits) === JSON.stringify(base.identityEdits ?? []);
10547
+ const direction = rec.direction ?? "";
10548
+ const sameDirection = direction === seedDirection(base);
10549
+ let head;
10550
+ if (samePictures && sameEdits && sameDirection) {
10551
+ const built = presenterRecordFrom({ name: rec.name, suitableCategories: rec.facets }, base);
10552
+ if (!built.ok) throw fail(built.error, 400);
10553
+ const patched = built.presenter;
10554
+ commit(core, rec.brandId, (json) => {
10555
+ if (headOf(json, baseId) !== baseId) throw moved();
10556
+ json.characters = brandCharacters(json).map((c) => c.id === base.id ? patched : c);
10557
+ });
10558
+ head = patched;
10559
+ } else {
10560
+ if (!views.length) throw fail("approve the face first", 400);
10561
+ const first = views[0];
10562
+ const mode = presenterCropMode(
10563
+ `asset:${first.hash}`,
10564
+ rec.sources.map((h) => `asset:${h}`),
10565
+ first.angle
10566
+ );
10567
+ const { previewHash, avatarHash } = await presenterCrops(core, first.hash, mode);
10568
+ const built = mintRevision(base, {
10569
+ name: rec.name,
10570
+ shotHashes: views.map((v) => v.hash),
10571
+ shotAngles: views.map((v) => v.angle),
10572
+ ...rec.sources.length ? { sourceHashes: rec.sources } : {},
10573
+ previewHash,
10574
+ avatarHash,
10575
+ suitableCategories: rec.facets,
10576
+ identityEdits: rec.identityEdits,
10577
+ // Recorded, never inferred: a record with no source stays without one.
10578
+ ...base.source ? { source: rec.source } : {},
10579
+ ...sameDirection ? {} : { identityNotes: direction },
10580
+ // A read taken in this session describes the face as it is now; with
10581
+ // none, the record's own words and casting prose carry.
10582
+ ...readWords(rec.analysis)
10583
+ });
10584
+ if (!built.ok) throw fail(built.error, 400);
10585
+ const faceMoved = rec.views.portrait.hash !== seeded.views.portrait.hash;
10586
+ const carried = faceMoved ? [] : kept;
10587
+ const minted = carried.length ? { ...built.presenter, shots: [...built.presenter.shots ?? [], ...carried] } : built.presenter;
10588
+ commit(core, rec.brandId, (json) => {
10589
+ if (headOf(json, baseId) !== baseId) throw moved();
10590
+ json.characters = [
10591
+ ...brandCharacters(json).map((c) => c.id === base.id ? { ...c, supersededBy: minted.id } : c),
10592
+ minted
10593
+ ];
10594
+ });
10595
+ head = minted;
10596
+ }
10597
+ core.store.deletePresenterDraft(rec.id);
10598
+ removeUnreferenced(core, letGoOf(rec), hooks);
10599
+ return { presenter: head, brand: core.store.getBrand(rec.brandId) };
10600
+ }
10601
+ function readWords(a) {
10602
+ if (!a) return {};
10603
+ const out = {};
10604
+ const said = (v) => v != null && v !== "" && !(Array.isArray(v) && !v.length);
10605
+ if (said(a.promptName)) out.promptName = a.promptName;
10606
+ if (said(a.presentation)) out.presentation = a.presentation;
10607
+ if (said(a.descriptor)) out.descriptor = a.descriptor;
10608
+ if (said(a.ageRange)) out.ageRange = a.ageRange;
10609
+ if (said(a.hair)) out.hair = a.hair;
10610
+ if (said(a.negativeConstraints)) out.negativeConstraints = a.negativeConstraints;
10611
+ if (said(a.facial)) out.facial = a.facial;
10612
+ if (said(a.skin)) out.skin = a.skin;
10613
+ if (said(a.build)) out.build = a.build;
10614
+ return out;
10615
+ }
10616
+ async function stopPresenterDraft(deps, id) {
10617
+ const rec = getPresenterDraft(deps.core, id);
10618
+ if (!rec) throw fail("draft not found", 404);
10619
+ const job = running3.get(id);
10620
+ if (!job) return rec;
10621
+ job.ctrl.abort();
10622
+ for (let i = 0; i < 500 && running3.has(id); i++) await new Promise((r) => setTimeout(r, 10));
10623
+ return getPresenterDraft(deps.core, id) ?? rec;
10624
+ }
10625
+ async function discardPresenterDraft(deps, id, hooks = {}) {
10626
+ running3.get(id)?.ctrl.abort();
10627
+ running3.delete(id);
10628
+ const rec = getPresenterDraft(deps.core, id);
10629
+ if (!rec) return;
10630
+ dropDraft(deps.core, rec, hooks);
10631
+ }
10632
+ async function releasePresenter(deps, brandId, presenter, hooks = {}) {
10633
+ for (const d of listPresenterDrafts(deps.core, brandId)) {
10634
+ if (d.presenterId === presenter.id) await discardPresenterDraft(deps, d.id, hooks);
10635
+ }
10636
+ const held = [
10637
+ ...(presenter.shots ?? []).map((s) => s?.file),
10638
+ ...presenter.sourceRefs ?? [],
10639
+ presenter.preview,
10640
+ presenter.avatar
10641
+ ];
10642
+ removeUnreferenced(
10643
+ deps.core,
10644
+ held.map(hashOfFile).filter((h) => !!h),
10645
+ hooks
10646
+ );
10647
+ }
10648
+ var ABANDONED_DRAFT_MS = 14 * 24 * 60 * 60 * 1e3;
10649
+ function sweepAbandonedPresenterDrafts(core, hooks = {}, now = Date.now()) {
10650
+ let swept = 0;
10651
+ for (const brand of core.store.listBrands()) {
10652
+ for (const rec of listPresenterDrafts(core, brand.id)) {
10653
+ if (running3.has(rec.id)) continue;
10654
+ const touched = stampMs(rec.updatedAt);
10655
+ if (Number.isNaN(touched) || now - touched < ABANDONED_DRAFT_MS) continue;
10656
+ dropDraft(core, rec, hooks);
10657
+ swept += 1;
10658
+ }
10659
+ }
10660
+ return swept;
10661
+ }
10662
+ function stampMs(s) {
10663
+ const iso = s.includes("T") ? s : s.replace(" ", "T");
10664
+ return Date.parse(/Z$|[+-]\d\d:\d\d$/.test(iso) ? iso : `${iso}Z`);
10665
+ }
10666
+ function dropDraft(core, rec, hooks) {
10667
+ core.store.deletePresenterDraft(rec.id);
10668
+ const generated = PRESENTER_VIEWS.flatMap((v) => {
10669
+ const s = rec.views[v];
10670
+ return [...s.rejected, ...s.hash && s.origin === "generated" ? [s.hash] : [], ...s.prior ? [s.prior] : []];
10671
+ });
10672
+ removeUnreferenced(core, [...generated, ...rec.sources], hooks);
10673
+ }
10674
+ function removeUnreferenced(core, hashes, hooks) {
10675
+ const brands = core.store.listBrands().map((b) => JSON.stringify(b.json));
10676
+ for (const h of new Set(hashes)) {
10677
+ if (!HASH.test(h)) continue;
10678
+ if (core.store.imageReferenced(h)) continue;
10679
+ if (brands.some((b) => b.includes(h))) continue;
10680
+ if (core.images.remove(h)) hooks.evict?.(h);
10681
+ }
10682
+ }
10683
+
8970
10684
  // src/routes/assetBuilds.ts
8971
- function registerAssetBuildRoutes(app, deps) {
10685
+ function makeBuildDeps(deps) {
8972
10686
  const { core, engines, scenes, presenters } = deps;
8973
10687
  const analyzer = deps.analyzer ?? createCodexAnalyzer({ runner: engines.codexRunner });
8974
- const buildEngine = async () => {
8975
- const ordered = [...engines.all()].sort((a, b) => {
8976
- const rank = (e) => e.capabilities().id === "codex-cli" ? 0 : 1;
8977
- return rank(a) - rank(b);
8978
- });
8979
- for (const engine of ordered) {
8980
- const caps = engine.capabilities();
8981
- if (!caps.maxReferenceImages || caps.placeholder) continue;
8982
- if ((await engine.isAvailable()).ok) return engine;
8983
- }
8984
- return null;
8985
- };
10688
+ const allowPlaceholder = process.env.SCENRI_DEMO_BUILDS === "1";
10689
+ const buildEngine = () => pickBuildEngine(engines, { allowPlaceholder });
8986
10690
  const buildDeps = async () => ({
8987
10691
  core,
8988
10692
  engine: await buildEngine(),
@@ -8992,8 +10696,17 @@ function registerAssetBuildRoutes(app, deps) {
8992
10696
  // person can actually click rather than inventing a category of one.
8993
10697
  vocabulary: { ...facetsOf(scenes), categories: presenterFacetsOf(presenters).categories }
8994
10698
  });
10699
+ return { buildEngine, buildDeps, analyzer };
10700
+ }
10701
+ function registerAssetBuildRoutes(app, deps) {
10702
+ const { core } = deps;
10703
+ const { buildEngine, buildDeps, analyzer } = makeBuildDeps(deps);
10704
+ const hooks = { evict: (hash) => deps.thumbs?.evict(hash) };
8995
10705
  app.get("/api/asset-builds/capabilities", async () => {
8996
- const [engine, probe] = await Promise.all([buildEngine(), analyzer?.isAvailable() ?? { ok: false }]);
10706
+ const [engine, probe] = await Promise.all([
10707
+ buildEngine(),
10708
+ analyzer?.isAvailable() ?? Promise.resolve({ ok: false })
10709
+ ]);
8997
10710
  return {
8998
10711
  canAnalyze: probe.ok,
8999
10712
  analyzeReason: probe.ok ? null : probe.reason ?? null,
@@ -9023,8 +10736,9 @@ function registerAssetBuildRoutes(app, deps) {
9023
10736
  if (!brand) return;
9024
10737
  const body = req.body ?? {};
9025
10738
  const kind = String(body.kind ?? "");
9026
- if (kind !== "presenter" && kind !== "scene")
9027
- return reply.status(400).send({ error: "kind must be presenter|scene" });
10739
+ if (kind === "presenter")
10740
+ return reply.status(400).send({ error: "presenters are cast in the studio now: POST /api/brands/:id/presenter-drafts" });
10741
+ if (kind !== "scene") return reply.status(400).send({ error: "kind must be scene" });
9028
10742
  try {
9029
10743
  return startAssetBuild(await buildDeps(), {
9030
10744
  brandId: brand.id,
@@ -9098,6 +10812,32 @@ function registerAssetBuildRoutes(app, deps) {
9098
10812
  }
9099
10813
  return { presenter: built.presenter, brand: core.store.getBrand(brand.id) };
9100
10814
  });
10815
+ app.post("/api/brands/:id/presenters/:presenterId/revert", async (req, reply) => {
10816
+ const brand = brandOr404(req, reply);
10817
+ if (!brand) return;
10818
+ const id = String(req.params.presenterId);
10819
+ const rows = brandCharacters(brand.json);
10820
+ const current = rows.find((c) => c.id === id);
10821
+ if (!current) return reply.status(404).send({ error: "presenter not found" });
10822
+ if (!isCustomPresenter(current)) return reply.status(400).send({ error: "this presenter is not editable" });
10823
+ const older = current.revisionOf && !current.supersededBy ? rows.find((c) => c.id === current.revisionOf) : void 0;
10824
+ if (!older) return reply.status(400).send({ error: "nothing to revert" });
10825
+ try {
10826
+ commit(core, brand.id, (json) => {
10827
+ json.characters = brandCharacters(json).map((c) => {
10828
+ if (c.id === older.id) {
10829
+ const { supersededBy: _cleared, ...head } = c;
10830
+ return head;
10831
+ }
10832
+ return c.id === current.id ? { ...c, supersededBy: older.id } : c;
10833
+ });
10834
+ });
10835
+ } catch (err) {
10836
+ return reply.status(err.statusCode ?? 500).send({ error: err.message });
10837
+ }
10838
+ const after = core.store.getBrand(brand.id);
10839
+ return { presenter: brandCharacters(after?.json).find((c) => c.id === older.id), brand: after };
10840
+ });
9101
10841
  app.delete("/api/brands/:id/presenters/:presenterId", async (req, reply) => {
9102
10842
  const brand = brandOr404(req, reply);
9103
10843
  if (!brand) return;
@@ -9108,7 +10848,8 @@ function registerAssetBuildRoutes(app, deps) {
9108
10848
  commit(core, brand.id, (json) => {
9109
10849
  json.characters = brandCharacters(json).filter((c) => c.id !== id);
9110
10850
  });
9111
- return { ok: true };
10851
+ await releasePresenter(await buildDeps(), brand.id, base, hooks);
10852
+ return { ok: true, brand: core.store.getBrand(brand.id) };
9112
10853
  });
9113
10854
  app.post("/api/brands/:id/scenes", async (req, reply) => {
9114
10855
  const brand = brandOr404(req, reply);
@@ -9221,14 +10962,191 @@ async function withDerivedCrops(core, body, base) {
9221
10962
  const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
9222
10963
  if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
9223
10964
  const firstShot = `asset:${String(shots[0])}`;
9224
- const firstSource = Array.isArray(body.sourceHashes) ? `asset:${String(body.sourceHashes[0])}` : base?.sourceRefs?.[0]?.file;
9225
- const derived = await presenterCrops(core, String(shots[0]), presenterCropMode(firstShot, firstSource));
10965
+ const sourceFiles = Array.isArray(body.sourceHashes) ? body.sourceHashes.map((h) => `asset:${String(h)}`) : (base?.sourceRefs ?? []).map((s) => s?.file);
10966
+ const angles = Array.isArray(body.shotAngles) ? body.shotAngles : [];
10967
+ const firstAngle = angles[0] ?? base?.shots?.find((s) => s?.file === firstShot)?.angle;
10968
+ const derived = await presenterCrops(core, String(shots[0]), presenterCropMode(firstShot, sourceFiles, firstAngle));
9226
10969
  return {
9227
10970
  ...body,
9228
10971
  ...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
9229
10972
  ...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
9230
10973
  };
9231
10974
  }
10975
+
10976
+ // src/routes/presenterDrafts.ts
10977
+ function registerPresenterDraftRoutes(app, deps) {
10978
+ const { core, thumbs } = deps;
10979
+ const { buildDeps } = makeBuildDeps(deps);
10980
+ const hooks = { evict: (hash) => thumbs.evict(hash) };
10981
+ const brandOr404 = (req, reply) => {
10982
+ const brand = core.store.getBrand(String(req.params.id));
10983
+ if (!brand) {
10984
+ reply.status(404).send({ error: "brand not found" });
10985
+ return null;
10986
+ }
10987
+ return brand;
10988
+ };
10989
+ const draftOr404 = (req, reply) => {
10990
+ const brand = brandOr404(req, reply);
10991
+ if (!brand) return null;
10992
+ const draft = getPresenterDraft(core, String(req.params.draftId));
10993
+ if (!draft || draft.brandId !== brand.id) {
10994
+ reply.status(404).send({ error: "draft not found" });
10995
+ return null;
10996
+ }
10997
+ return draft;
10998
+ };
10999
+ const answer = async (reply, work) => {
11000
+ try {
11001
+ return await work();
11002
+ } catch (err) {
11003
+ return reply.status(err?.statusCode ?? 500).send({ error: err?.message ?? "something went wrong" });
11004
+ }
11005
+ };
11006
+ const viewOf = (req) => String(req.params.view);
11007
+ app.post("/api/brands/:id/presenter-drafts", async (req, reply) => {
11008
+ const brand = brandOr404(req, reply);
11009
+ if (!brand) return;
11010
+ const body = req.body ?? {};
11011
+ return answer(
11012
+ reply,
11013
+ async () => createPresenterDraft(await buildDeps(), {
11014
+ brandId: brand.id,
11015
+ source: body.source === "synthetic" ? "synthetic" : "photos",
11016
+ direction: body.direction == null ? void 0 : String(body.direction),
11017
+ keep: body.keep == null ? void 0 : String(body.keep),
11018
+ keepItems: body.keepItems,
11019
+ detailRefs: body.detailRefs,
11020
+ imageHashes: Array.isArray(body.imageHashes) ? body.imageHashes.map((h) => String(h)) : [],
11021
+ attestation: body.attestation === true,
11022
+ name: body.name == null ? void 0 : String(body.name),
11023
+ facets: Array.isArray(body.facets) ? body.facets.map((f) => String(f)) : [],
11024
+ extras: body.extras === true
11025
+ })
11026
+ );
11027
+ });
11028
+ app.get("/api/brands/:id/presenter-drafts", async (req, reply) => {
11029
+ const brand = brandOr404(req, reply);
11030
+ if (!brand) return;
11031
+ return { drafts: listPresenterDraftSummaries(core, brand.id) };
11032
+ });
11033
+ app.post("/api/brands/:id/presenters/:presenterId/edit", async (req, reply) => {
11034
+ const brand = brandOr404(req, reply);
11035
+ if (!brand) return;
11036
+ return answer(reply, async () => openPresenterEdit(core, brand.id, String(req.params.presenterId)));
11037
+ });
11038
+ app.get("/api/brands/:id/presenter-drafts/:draftId", async (req, reply) => {
11039
+ const draft = draftOr404(req, reply);
11040
+ if (!draft) return;
11041
+ return draft;
11042
+ });
11043
+ app.patch("/api/brands/:id/presenter-drafts/:draftId", async (req, reply) => {
11044
+ const draft = draftOr404(req, reply);
11045
+ if (!draft) return;
11046
+ const body = req.body ?? {};
11047
+ return answer(
11048
+ reply,
11049
+ () => updatePresenterDraft(core, draft.id, {
11050
+ name: body.name,
11051
+ facets: body.facets,
11052
+ direction: body.direction,
11053
+ keep: body.keep,
11054
+ keepItems: body.keepItems,
11055
+ setup: body.setup,
11056
+ detailRefs: body.detailRefs,
11057
+ extras: body.extras
11058
+ })
11059
+ );
11060
+ });
11061
+ app.get("/api/brands/:id/presenter-drafts/:draftId/views/:view/plan", async (req, reply) => {
11062
+ if (!process.env.SCENRI_DEBUG) return reply.status(404).send({ error: "not found" });
11063
+ const draft = draftOr404(req, reply);
11064
+ if (!draft) return;
11065
+ const view = viewOf(req);
11066
+ if (!PRESENTER_VIEWS.includes(view))
11067
+ return reply.status(400).send({ error: "no such view" });
11068
+ return answer(reply, async () => {
11069
+ const { engine } = await buildDeps();
11070
+ const cap2 = engine?.capabilities().maxReferenceImages ?? 0;
11071
+ const adjustment = req.query?.adjustment;
11072
+ const step = planStep(draft, view, adjustment == null ? void 0 : String(adjustment), cap2);
11073
+ return {
11074
+ view,
11075
+ cap: cap2,
11076
+ ...step,
11077
+ held: {
11078
+ source: draft.source,
11079
+ direction: draft.direction ?? "",
11080
+ keep: draft.keep ?? "",
11081
+ detailRefs: draft.detailRefs ?? null,
11082
+ identityEdits: draft.identityEdits ?? [],
11083
+ analysis: draft.analysis ?? null,
11084
+ sources: draft.sources
11085
+ }
11086
+ };
11087
+ });
11088
+ });
11089
+ app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/generate", async (req, reply) => {
11090
+ const draft = draftOr404(req, reply);
11091
+ if (!draft) return;
11092
+ const body = req.body ?? {};
11093
+ return answer(
11094
+ reply,
11095
+ async () => generateView(await buildDeps(), draft.id, viewOf(req), {
11096
+ adjustment: body.adjustment == null ? void 0 : String(body.adjustment),
11097
+ decide: body.decide === "auto" ? "auto" : void 0
11098
+ })
11099
+ );
11100
+ });
11101
+ app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/approve", async (req, reply) => {
11102
+ const draft = draftOr404(req, reply);
11103
+ if (!draft) return;
11104
+ return answer(reply, async () => approveView(await buildDeps(), draft.id, viewOf(req)));
11105
+ });
11106
+ app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/redo", async (req, reply) => {
11107
+ const draft = draftOr404(req, reply);
11108
+ if (!draft) return;
11109
+ return answer(reply, async () => redoView(await buildDeps(), draft.id, viewOf(req)));
11110
+ });
11111
+ app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/revert", async (req, reply) => {
11112
+ const draft = draftOr404(req, reply);
11113
+ if (!draft) return;
11114
+ return answer(reply, async () => revertView(await buildDeps(), draft.id, viewOf(req)));
11115
+ });
11116
+ app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/restore", async (req, reply) => {
11117
+ const draft = draftOr404(req, reply);
11118
+ if (!draft) return;
11119
+ const hash = String(req.body?.hash ?? "");
11120
+ return answer(reply, async () => restoreView(await buildDeps(), draft.id, viewOf(req), hash));
11121
+ });
11122
+ app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/use-photo", async (req, reply) => {
11123
+ const draft = draftOr404(req, reply);
11124
+ if (!draft) return;
11125
+ const body = req.body ?? {};
11126
+ return answer(
11127
+ reply,
11128
+ async () => usePhotoForView(await buildDeps(), draft.id, viewOf(req), String(body.hash ?? ""))
11129
+ );
11130
+ });
11131
+ app.post("/api/brands/:id/presenter-drafts/:draftId/save", async (req, reply) => {
11132
+ const draft = draftOr404(req, reply);
11133
+ if (!draft) return;
11134
+ return answer(reply, async () => savePresenterDraft(await buildDeps(), draft.id, hooks));
11135
+ });
11136
+ app.post("/api/brands/:id/presenter-drafts/:draftId/stop", async (req, reply) => {
11137
+ const draft = draftOr404(req, reply);
11138
+ if (!draft) return;
11139
+ return answer(reply, async () => stopPresenterDraft(await buildDeps(), draft.id));
11140
+ });
11141
+ app.delete("/api/brands/:id/presenter-drafts/:draftId", async (req, reply) => {
11142
+ const draft = draftOr404(req, reply);
11143
+ if (!draft) return;
11144
+ return answer(reply, async () => {
11145
+ await discardPresenterDraft(await buildDeps(), draft.id, hooks);
11146
+ return { ok: true };
11147
+ });
11148
+ });
11149
+ }
9232
11150
  function registerDemoProductRoutes(app, deps) {
9233
11151
  const { templatesRoot, demoProducts, demoProductById, thumbs } = deps;
9234
11152
  const demoProductThumbPath = (id) => {
@@ -9707,13 +11625,12 @@ function registerImageRoutes(app, deps) {
9707
11625
  return reply.send(thumbs.stream(path));
9708
11626
  });
9709
11627
  app.post("/api/images", async (req, reply) => {
9710
- const part = await req.file();
9711
- if (!part) return reply.status(400).send({ error: "multipart file field required" });
9712
- const buf = await part.toBuffer();
9713
- if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
9714
- const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
9715
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
9716
- return { hash: core.images.save(png) };
11628
+ const part = await readImagePart(core, req, async (buf) => {
11629
+ const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
11630
+ return fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
11631
+ });
11632
+ if ("error" in part) return reply.status(400).send({ error: part.error });
11633
+ return { hash: part.hash };
9717
11634
  });
9718
11635
  app.post("/api/diff", async (req, reply) => {
9719
11636
  const { imageA, imageB } = req.body;
@@ -9734,6 +11651,36 @@ function registerImageRoutes(app, deps) {
9734
11651
 
9735
11652
  // src/release/notes.data.ts
9736
11653
  var RELEASES = [
11654
+ {
11655
+ version: "0.10.0",
11656
+ date: "2026-09-16",
11657
+ title: "Presenters are cast by talking to Scenri, one question at a time.",
11658
+ sections: [
11659
+ {
11660
+ heading: "Presenters",
11661
+ body: "Casting a presenter is a conversation: describe someone or add a few photos, and their portrait arrives first, then each further view as you go. Editing a saved presenter opens that same conversation, seeded from what is already there, and every change becomes a new version you can step back through. An unfinished presenter is saved as a draft and offered back the next time you open Presenters, and deleting one clears it everywhere it was used."
11662
+ },
11663
+ {
11664
+ heading: "Products",
11665
+ body: "Importing a store's products shows its progress in the library as it happens, carrying the brand's own mark and a running clock. A site that is rate-limiting requests is reported as such instead of looking like an empty import, and a run that stops partway keeps what it already found rather than losing it."
11666
+ }
11667
+ ]
11668
+ },
11669
+ {
11670
+ version: "0.9.4",
11671
+ date: "2026-09-14",
11672
+ title: "A store that asks Scenri to slow down no longer looks like an empty one.",
11673
+ sections: [
11674
+ {
11675
+ heading: "Products",
11676
+ body: "Some stores limit how fast anything can read them. Scenri now waits the way such a store asks and says what happened: an import that could not read anything tells you to try again in a few minutes, and one that got part way says how much is left. Products from an earlier import are never retired by a run that was refused, and a store that keeps saying no ends the import early rather than grinding on."
11677
+ },
11678
+ {
11679
+ heading: "Fixes",
11680
+ body: "A product picture saved in a newer format such as AVIF is found again instead of going missing."
11681
+ }
11682
+ ]
11683
+ },
9737
11684
  {
9738
11685
  version: "0.9.3",
9739
11686
  date: "2026-09-14",
@@ -10727,7 +12674,7 @@ function registerDesktopRoutes(app, deps) {
10727
12674
  record: null
10728
12675
  };
10729
12676
  }
10730
- const { installDeps } = await import('./cli-SX46UQO6.js');
12677
+ const { installDeps } = await import('./cli-EXASA5BK.js');
10731
12678
  const { desktopStatus } = await import('./install-VXJPLYWM.js');
10732
12679
  return desktopStatus(installDeps(runtime.entry));
10733
12680
  });
@@ -10735,7 +12682,7 @@ function registerDesktopRoutes(app, deps) {
10735
12682
  if (!runtime.entry) {
10736
12683
  return { ok: false, reason: "unsupported", message: "Desktop shortcuts are not available on this system yet." };
10737
12684
  }
10738
- const { addToDesktop } = await import('./cli-SX46UQO6.js');
12685
+ const { addToDesktop } = await import('./cli-EXASA5BK.js');
10739
12686
  return addToDesktop(runtime.entry);
10740
12687
  });
10741
12688
  app.get("/api/desktop", async () => {
@@ -11021,7 +12968,10 @@ function buildServer(opts) {
11021
12968
  const presentersDir = join(templatesRoot, "presenters");
11022
12969
  const { presenters } = loadPresenters(presentersDir);
11023
12970
  registerPresenterRoutes(app, { templatesRoot, presenters, thumbs });
11024
- registerAssetBuildRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters });
12971
+ registerAssetBuildRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters, thumbs });
12972
+ sweepPresenterDrafts(core);
12973
+ sweepAbandonedPresenterDrafts(core, { evict: (hash) => thumbs.evict(hash) });
12974
+ registerPresenterDraftRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters, thumbs });
11025
12975
  const { demoProducts } = loadDemoProducts(join(templatesRoot, "demo-products"));
11026
12976
  const demoProductById = demoProductResolver(demoProducts);
11027
12977
  registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById, thumbs });
@@ -12155,7 +14105,7 @@ function buildServer(opts) {
12155
14105
  // one physical run counts once, however many sibling nodes share its
12156
14106
  // controller — an update gate held open by a 4-shot batch is still held
12157
14107
  // open by exactly one piece of work
12158
- busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
14108
+ busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
12159
14109
  });
12160
14110
  let drained = null;
12161
14111
  app.decorate("drain", () => {
@@ -12177,7 +14127,7 @@ function buildServer(opts) {
12177
14127
  core,
12178
14128
  runtime,
12179
14129
  exitImpl: opts.exitImpl,
12180
- busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
14130
+ busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
12181
14131
  });
12182
14132
  if (opts.studioDist && existsSync(opts.studioDist)) {
12183
14133
  const dist = opts.studioDist;
@@ -12222,6 +14172,7 @@ async function run() {
12222
14172
  const core = createCore();
12223
14173
  const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
12224
14174
  const engines = createEngineRegistry(core, stubs);
14175
+ const analyzer = process.env.SCENRI_DEMO_ANALYSIS === "usable" || process.env.SCENRI_DEMO_ANALYSIS === "unusable" ? createDemoAnalyzer({ photos: process.env.SCENRI_DEMO_ANALYSIS }) : void 0;
12225
14176
  const here = dirname(fileURLToPath(import.meta.url));
12226
14177
  const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];
12227
14178
  const studioDist = candidates.find((p) => existsSync(p));
@@ -12233,6 +14184,7 @@ async function run() {
12233
14184
  const app = buildServer({
12234
14185
  core,
12235
14186
  engines,
14187
+ analyzer,
12236
14188
  studioDist,
12237
14189
  access: { allowedHosts: reachableAt, token },
12238
14190
  runtime: {
@@ -12338,7 +14290,7 @@ async function run() {
12338
14290
  }
12339
14291
  }
12340
14292
  const ownEntry = fileURLToPath(import.meta.url);
12341
- const { addToDesktop, installDeps } = await import('./cli-SX46UQO6.js');
14293
+ const { addToDesktop, installDeps } = await import('./cli-EXASA5BK.js');
12342
14294
  const { refreshLauncher } = await import('./refresh-5HBDLLLB.js');
12343
14295
  const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-W6NJXMWT.js');
12344
14296
  const { launcherInstalled } = await import('./paths-6ZXRZUHY.js');