scenri 0.9.4 → 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 +61 -0
  2. package/dist/{chunk-3ZRMKVBB.js → chunk-WORM6GB3.js} +128 -3
  3. package/dist/{cli-MQO66C5O.js → cli-EXASA5BK.js} +3 -3
  4. package/dist/index.js +2 -2
  5. package/dist/serve.js +2156 -336
  6. package/dist/{src-3ITTV2UE.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-uiX1mW43.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-3ZRMKVBB.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();
4418
4643
  }
4419
- throw new ScrapeError("http_status", statusSentence(u.hostname, res.status));
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
+ }
4654
+ }
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() : "";
@@ -4888,7 +5223,8 @@ async function inspectMark(buf, toPng2) {
4888
5223
  }
4889
5224
 
4890
5225
  // ../catalog/src/failures.ts
4891
- function pageFailure(status) {
5226
+ function pageFailure(status, challenged = false) {
5227
+ if (challenged) return "CHALLENGED";
4892
5228
  if (status === 429 || status === 503) return "RATE_LIMITED";
4893
5229
  if (status === 401 || status === 403 || status === 405) return "PAGE_BLOCKED";
4894
5230
  if (status === 404 || status === 410) return "PAGE_NOT_FOUND";
@@ -4910,25 +5246,34 @@ function tally(into, reason, n = 1) {
4910
5246
  into[reason] = (into[reason] ?? 0) + n;
4911
5247
  }
4912
5248
  function leadingReason(t) {
4913
- const rows = Object.entries(t);
5249
+ const rows = Object.entries(t).filter(([, n]) => n > 0);
4914
5250
  if (!rows.length) return null;
4915
5251
  return rows.sort((a, b) => b[1] - a[1])[0][0];
4916
5252
  }
4917
- function summarise(t, saved, asked) {
5253
+ function shortfall(asked, worked) {
5254
+ return Math.max(0, asked - Math.max(0, worked));
5255
+ }
5256
+ function summarise(t, saved, asked, failedCount) {
4918
5257
  const reason = leadingReason(t);
4919
5258
  if (!reason) return null;
4920
5259
  const none = saved === 0;
5260
+ const failed = Math.min(asked, failedCount ?? Math.max(0, asked - saved));
4921
5261
  switch (reason) {
4922
5262
  case "RATE_LIMITED":
4923
- 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 ${asked - saved} of ${asked} products were skipped. Trying again picks up the rest.`;
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.`;
4924
5268
  case "PAGE_BLOCKED":
4925
- return none ? "This store would not let us read its product pages." : `${asked - saved} of ${asked} product pages would not open.`;
5269
+ return none ? "This store would not let us read its product pages." : `${failed} of ${asked} product pages would not open.`;
4926
5270
  case "PAGE_NOT_FOUND":
4927
- return `${asked - saved} of ${asked} products are no longer on the site.`;
5271
+ return `${failed} of ${asked} products are no longer on the site.`;
4928
5272
  case "IMAGE_FORBIDDEN":
4929
5273
  case "IMAGE_NOT_FOUND":
4930
5274
  case "IMAGE_TIMEOUT":
4931
5275
  case "IMAGE_EMPTY":
5276
+ case "UNSUPPORTED_MEDIA":
4932
5277
  case "DOWNLOAD_FAILED":
4933
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.";
4934
5279
  case "ABORTED":
@@ -4970,6 +5315,19 @@ function absolutize(base, href) {
4970
5315
  return null;
4971
5316
  }
4972
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
+ }
4973
5331
  function upgradeImageUrl(url) {
4974
5332
  try {
4975
5333
  const u = new URL(url);
@@ -5153,43 +5511,9 @@ async function readBounded2(res, maxBytes) {
5153
5511
  }
5154
5512
  return out + decoder.decode();
5155
5513
  }
5156
- function sleep4(ms) {
5514
+ function sleep5(ms) {
5157
5515
  return new Promise((r) => setTimeout(r, ms));
5158
5516
  }
5159
- var cooledUntil = /* @__PURE__ */ new Map();
5160
- var hostOf = (url) => {
5161
- try {
5162
- return new URL(url).host;
5163
- } catch {
5164
- return "";
5165
- }
5166
- };
5167
- async function waitForHost(host, signal) {
5168
- if (!host) return;
5169
- for (; ; ) {
5170
- const left = (cooledUntil.get(host) ?? 0) - Date.now();
5171
- if (left <= 0 || signal?.aborted) return;
5172
- await sleep4(Math.min(left, 250));
5173
- }
5174
- }
5175
- function coolHost(host, ms) {
5176
- if (!host || ms <= 0) return;
5177
- const until = Date.now() + ms;
5178
- if (until > (cooledUntil.get(host) ?? 0)) cooledUntil.set(host, until);
5179
- }
5180
- var RETRY_AFTER_CAP_MS = 6e4;
5181
- function retryAfterMs(header, now = Date.now()) {
5182
- if (!header) return null;
5183
- const secs = Number(header.trim());
5184
- if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
5185
- const at = Date.parse(header);
5186
- if (Number.isNaN(at)) return null;
5187
- return Math.min(Math.max(0, at - now), RETRY_AFTER_CAP_MS);
5188
- }
5189
- function throttleBackoff(attempt) {
5190
- const base = Math.min(1e3 * 2 ** attempt, 16e3);
5191
- return base + Math.floor(Math.random() * 400);
5192
- }
5193
5517
  async function httpGet(url, opts = {}) {
5194
5518
  const fetchImpl = opts.fetchImpl ?? fetch;
5195
5519
  const retries = opts.retries ?? 3;
@@ -5230,6 +5554,7 @@ async function httpGet(url, opts = {}) {
5230
5554
  } catch {
5231
5555
  }
5232
5556
  }
5557
+ if (isChallenge(res)) return res;
5233
5558
  if (res.status === 429 || res.status === 503) {
5234
5559
  const asked = retryAfterMs(res.headers.get("retry-after"));
5235
5560
  const wait = asked ?? throttleBackoff(attempt);
@@ -5242,7 +5567,7 @@ async function httpGet(url, opts = {}) {
5242
5567
  return res;
5243
5568
  }
5244
5569
  if (res.status >= 500 && attempt < retries) {
5245
- await sleep4(throttleBackoff(attempt));
5570
+ await sleep5(throttleBackoff(attempt));
5246
5571
  continue;
5247
5572
  }
5248
5573
  return res;
@@ -5250,7 +5575,7 @@ async function httpGet(url, opts = {}) {
5250
5575
  lastErr = err;
5251
5576
  if (opts.signal?.aborted) throw err;
5252
5577
  if (attempt < retries) {
5253
- await sleep4(400 * 2 ** attempt);
5578
+ await sleep5(400 * 2 ** attempt);
5254
5579
  continue;
5255
5580
  }
5256
5581
  throw err;
@@ -5263,12 +5588,14 @@ async function httpGet(url, opts = {}) {
5263
5588
  }
5264
5589
  async function httpText(url, opts = {}) {
5265
5590
  const res = await httpGet(url, opts);
5591
+ const challenged = isChallenge(res);
5266
5592
  const text = await readBounded2(res, opts.maxBytes);
5267
- 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 };
5268
5594
  }
5269
5595
  async function httpJson(url, opts = {}) {
5270
5596
  const res = await httpGet(url, { ...opts, accept: opts.accept ?? "application/json" });
5271
- const text = await res.text();
5597
+ const challenged = isChallenge(res);
5598
+ const text = await readBounded2(res, opts.maxBytes ?? 16e6);
5272
5599
  let json = null;
5273
5600
  if (res.ok) {
5274
5601
  try {
@@ -5277,7 +5604,10 @@ async function httpJson(url, opts = {}) {
5277
5604
  json = null;
5278
5605
  }
5279
5606
  }
5280
- 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;
5281
5611
  }
5282
5612
  async function mapPool(items, concurrency, fn, signal) {
5283
5613
  const results = new Array(items.length);
@@ -5434,6 +5764,16 @@ var PRODUCT_MARKERS = [
5434
5764
  var BUY_WORDS = /add to (cart|bag|basket)|buy now|add to my bag/i;
5435
5765
  var BUILD_ASSET = /\/_next\/static\/|\/static\/media\/|\/assets\/(icons|flags|ui)\//i;
5436
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
+ }
5437
5777
  function looksLikeProduct(html, doc) {
5438
5778
  const $ = doc ?? loadHtml(html);
5439
5779
  for (const sel of PRODUCT_MARKERS) {
@@ -5450,17 +5790,27 @@ function looksLikeProduct(html, doc) {
5450
5790
  }
5451
5791
  function galleryImages($, pageUrl, cap2 = 12) {
5452
5792
  const images = [];
5793
+ const taken = /* @__PURE__ */ new Set();
5453
5794
  const og = attr($.querySelector('meta[property="og:image"]'), "content");
5454
5795
  if (og) {
5455
5796
  const abs = absolutize(pageUrl, og);
5456
- 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
+ }
5457
5801
  }
5458
5802
  for (const el of $.querySelectorAll("img[src]")) {
5459
5803
  if (images.length >= cap2) break;
5460
5804
  const src = attr(el, "src") || attr(el, "data-src");
5461
5805
  const abs = src ? absolutize(pageUrl, src) : null;
5462
5806
  if (!abs || NOT_A_PACKSHOT.test(abs) || BUILD_ASSET.test(abs)) continue;
5463
- 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);
5464
5814
  images.push({ url: abs, position: images.length, width: null, height: null, alt: attr(el, "alt") ?? null });
5465
5815
  }
5466
5816
  return images;
@@ -5511,7 +5861,7 @@ function productsFromPage(html, url) {
5511
5861
  const one = parseProductHtml(html, url, doc);
5512
5862
  return one ? [one] : [];
5513
5863
  }
5514
- var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
5864
+ var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
5515
5865
  async function fetchProductPages(ctx, urls, opts = {}) {
5516
5866
  const out = [];
5517
5867
  const seen = /* @__PURE__ */ new Set();
@@ -5531,8 +5881,8 @@ async function fetchProductPages(ctx, urls, opts = {}) {
5531
5881
  if (opts.deadline != null && Date.now() > opts.deadline) return;
5532
5882
  if (opts.maxTotalBytes != null && bytes >= opts.maxTotalBytes) return;
5533
5883
  try {
5534
- if (opts.delayMs) await sleep5(opts.delayMs);
5535
- const { ok, status, text, url } = await httpText(u, {
5884
+ if (opts.delayMs) await sleep6(opts.delayMs);
5885
+ const { ok, status, text, url, challenged } = await httpText(u, {
5536
5886
  fetchImpl: ctx.fetchImpl,
5537
5887
  signal: ctx.signal,
5538
5888
  accept: "text/html",
@@ -5546,7 +5896,7 @@ async function fetchProductPages(ctx, urls, opts = {}) {
5546
5896
  if (!ok) {
5547
5897
  if (opts.stats) {
5548
5898
  opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5549
- if (opts.stats.reasons) tally(opts.stats.reasons, pageFailure(status));
5899
+ if (opts.stats.reasons) tally(opts.stats.reasons, pageFailure(status, challenged));
5550
5900
  }
5551
5901
  if (++refusedRun >= giveUpAt) givenUp = true;
5552
5902
  return;
@@ -5565,6 +5915,7 @@ async function fetchProductPages(ctx, urls, opts = {}) {
5565
5915
  opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5566
5916
  if (opts.stats.reasons) tally(opts.stats.reasons, thrownFailure(err, ctx.signal?.aborted));
5567
5917
  }
5918
+ if (!ctx.signal?.aborted && ++refusedRun >= giveUpAt) givenUp = true;
5568
5919
  }
5569
5920
  },
5570
5921
  ctx.signal
@@ -5631,14 +5982,28 @@ async function fetchProductsJsonPage(ctx, page, limit = 250) {
5631
5982
  if (ok && json?.products) return { products: json.products, blocked: false };
5632
5983
  return { products: [], blocked: status === 401 || status === 403 || status === 404 || status >= 500 };
5633
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
+ }
5634
5997
  var JSON_BLOCKED = "json-blocked";
5998
+ var JSON_GIVE_UP = 3;
5999
+ var MAX_SITEMAPS = 40;
5635
6000
  async function collectSitemapProductUrls(ctx) {
5636
6001
  const origin = originOf(ctx.baseUrl);
5637
6002
  const urls = /* @__PURE__ */ new Set();
5638
6003
  const candidates = [`${origin}/sitemap.xml`, `${origin}/sitemap_products_1.xml`];
5639
6004
  const queue = [...candidates];
5640
6005
  const seen = /* @__PURE__ */ new Set();
5641
- while (queue.length) {
6006
+ while (queue.length && seen.size < MAX_SITEMAPS && !outOfTime(ctx.deadline)) {
5642
6007
  const next = queue.shift();
5643
6008
  if (seen.has(next)) continue;
5644
6009
  seen.add(next);
@@ -5692,11 +6057,16 @@ var shopifyAdapter = {
5692
6057
  const hints = [];
5693
6058
  const keys = /* @__PURE__ */ new Set();
5694
6059
  const productUrls = /* @__PURE__ */ new Set();
6060
+ const cards = [];
5695
6061
  const origin = originOf(ctx.baseUrl);
5696
6062
  let page = 1;
5697
6063
  let emptyStreak = 0;
5698
6064
  while (emptyStreak < 1) {
5699
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
+ }
5700
6070
  const { products, blocked: blocked2 } = await fetchProductsJsonPage(ctx, page);
5701
6071
  ctx.onProgress?.({ stage: "discovering", discovered: keys.size, message: `Shopify page ${page}` });
5702
6072
  if (blocked2 && page === 1) {
@@ -5710,6 +6080,7 @@ var shopifyAdapter = {
5710
6080
  for (const p of products) {
5711
6081
  keys.add(String(p.id));
5712
6082
  if (p.handle) productUrls.add(`${origin}/products/${p.handle}`);
6083
+ cards.push(cardOf(origin, p));
5713
6084
  }
5714
6085
  if (products.length < 250) break;
5715
6086
  page++;
@@ -5730,6 +6101,7 @@ var shopifyAdapter = {
5730
6101
  return {
5731
6102
  productKeys: [...keys],
5732
6103
  productUrls: [...productUrls],
6104
+ cards,
5733
6105
  estimatedTotal: keys.size || productUrls.size || null,
5734
6106
  // A store whose own product API answered but refused us leaves nothing
5735
6107
  // to read but the pages themselves, one request each. That is what
@@ -5739,6 +6111,43 @@ var shopifyAdapter = {
5739
6111
  hints
5740
6112
  };
5741
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
+ },
5742
6151
  async fetchAll(ctx, discovered) {
5743
6152
  const origin = originOf(ctx.baseUrl);
5744
6153
  const out = [];
@@ -5772,20 +6181,29 @@ var shopifyAdapter = {
5772
6181
  const handle = handleOf(u);
5773
6182
  return handle && !out.some((p) => p.handle === handle);
5774
6183
  });
5775
- if (!jsonBlocked) {
6184
+ const bulkCoveredAll = out.length > 0 && out.length >= discovered.productKeys.length;
6185
+ if (!jsonBlocked && !bulkCoveredAll) {
5776
6186
  const missingUrls = stillMissing();
5777
6187
  if (missingUrls.length) {
6188
+ let refusedRun = 0;
6189
+ let abandoned = false;
5778
6190
  await mapPool(
5779
6191
  missingUrls,
5780
- 6,
6192
+ 4,
5781
6193
  async (u) => {
6194
+ if (abandoned) return;
5782
6195
  const handle = handleOf(u);
5783
6196
  if (!handle) return;
5784
- const { ok, json } = await httpJson(`${origin}/products/${handle}.json`, {
6197
+ const { ok, json, challenged } = await httpJson(`${origin}/products/${handle}.json`, {
5785
6198
  fetchImpl: ctx.fetchImpl,
5786
6199
  signal: ctx.signal
5787
6200
  });
5788
- 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) {
5789
6207
  const mapped = mapShopifyProduct(origin, json.product);
5790
6208
  if (!seen.has(mapped.externalKey)) {
5791
6209
  seen.add(mapped.externalKey);
@@ -5843,7 +6261,7 @@ async function extractSitemapUrls(ctx, filter = () => true) {
5843
6261
  }
5844
6262
  if (filter(loc)) out.add(loc.split("?")[0]);
5845
6263
  }
5846
- if (seen.size > 200) break;
6264
+ if (seen.size > 200 || outOfTime(ctx.deadline)) break;
5847
6265
  }
5848
6266
  return preferCanonicalLocale([...out]);
5849
6267
  }
@@ -5882,7 +6300,7 @@ async function crawlListingPages(ctx) {
5882
6300
  const found = /* @__PURE__ */ new Set();
5883
6301
  const visited = /* @__PURE__ */ new Set();
5884
6302
  const queue = [origin, `${origin}/collections/all`, `${origin}/shop`, `${origin}/products`, `${origin}/catalog`];
5885
- while (queue.length && visited.size < 40) {
6303
+ while (queue.length && visited.size < 40 && !outOfTime(ctx.deadline)) {
5886
6304
  const page = queue.shift();
5887
6305
  if (visited.has(page)) continue;
5888
6306
  visited.add(page);
@@ -6038,6 +6456,11 @@ var woocommerceAdapter = {
6038
6456
  let page = 1;
6039
6457
  let usedApi = false;
6040
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
+ }
6041
6464
  const batch = await tryStoreApi(ctx, page, 100);
6042
6465
  if (batch === null) break;
6043
6466
  usedApi = true;
@@ -6397,6 +6820,7 @@ var DEFAULT_SCAN_BUDGET = {
6397
6820
  */
6398
6821
  previewFloorMs: 12e3
6399
6822
  };
6823
+ var DISCOVERY_FLOOR_MS = 8e3;
6400
6824
  var COMMERCE_SIGNALS = /* @__PURE__ */ new Set([
6401
6825
  "products.json",
6402
6826
  "shopify-html",
@@ -6427,12 +6851,14 @@ async function scanOnce(opts, baseUrl) {
6427
6851
  const started = Date.now();
6428
6852
  const budget = { ...DEFAULT_SCAN_BUDGET, ...opts.budget };
6429
6853
  const deadline = started + budget.budgetMs;
6854
+ const discoveryDeadline = Math.max(deadline, started + DISCOVERY_FLOOR_MS);
6430
6855
  const fetchImpl = opts.fetchImpl ?? fetch;
6431
6856
  const ctx = {
6432
6857
  fetchImpl,
6433
6858
  baseUrl,
6434
6859
  signal: opts.signal,
6435
- onProgress: opts.onProgress
6860
+ onProgress: opts.onProgress,
6861
+ deadline: discoveryDeadline
6436
6862
  };
6437
6863
  const warnings = [];
6438
6864
  const stats = { pages: 0, bytes: 0 };
@@ -6453,10 +6879,18 @@ async function scanOnce(opts, baseUrl) {
6453
6879
  if (refused > 0) warnings.push(`${refused} pages are disallowed by this site's robots.txt`);
6454
6880
  const fromApi = detection.platform !== "generic" && !discovered.hints?.includes("json-blocked");
6455
6881
  const countSource = urls.length === 0 ? "none" : fromApi ? "api" : "sitemap";
6882
+ const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
6456
6883
  if (urls.length === 0) {
6457
- const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
6458
6884
  return done(commerce ? "likely" : "none", [], [], 0, countSource, warnings);
6459
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
+ }
6460
6894
  opts.onProgress?.({ stage: "discovering", discovered: urls.length, message: "Reading a few products" });
6461
6895
  const previewDeadline = Math.max(deadline, Date.now() + budget.previewFloorMs);
6462
6896
  const read = dedupeProducts(
@@ -6472,15 +6906,16 @@ async function scanOnce(opts, baseUrl) {
6472
6906
  })
6473
6907
  );
6474
6908
  const preview = read.slice(0, budget.maxPreviewPages);
6475
- const verdict = preview.length ? "found" : "blocked";
6909
+ const verdict = preview.length ? "found" : commerce ? "blocked" : "none";
6476
6910
  return done(verdict, preview, urls, urls.length, countSource, warnings);
6477
- function done(verdict2, candidates, candidateUrls, count, source, notes) {
6911
+ function done(verdict2, candidates, candidateUrls, count, source, notes, cards = []) {
6478
6912
  return {
6479
6913
  baseUrl,
6480
6914
  platform: detection?.platform ?? "unknown",
6481
6915
  signals: detection?.signals ?? [],
6482
6916
  verdict: verdict2,
6483
6917
  count,
6918
+ cards,
6484
6919
  countSource: source,
6485
6920
  candidates,
6486
6921
  candidateUrls,
@@ -6548,11 +6983,14 @@ function progressWriter(patch2) {
6548
6983
  });
6549
6984
  };
6550
6985
  }
6986
+ function stoppedMessage(saved) {
6987
+ return saved ? `Stopped after saving ${saved.toLocaleString()} products` : "Stopped before anything was saved";
6988
+ }
6551
6989
  async function runJob(deps, jobId, brandId, url, signal, only) {
6552
6990
  const { core, fetchImpl } = deps;
6553
6991
  const patch2 = (p) => core.catalog.updateJob(jobId, p);
6992
+ const tally2 = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
6554
6993
  try {
6555
- const tally2 = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
6556
6994
  const ctx = { fetchImpl: fetchImpl ?? fetch, baseUrl: url, signal };
6557
6995
  let urls = [];
6558
6996
  let bulk = null;
@@ -6568,6 +7006,16 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6568
7006
  baseUrl = url;
6569
7007
  platform = detection.platform;
6570
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
+ }
6571
7019
  } else {
6572
7020
  patch2({ stage: "discovering", message: "Detecting store platform" });
6573
7021
  const found = await discoverCatalog({ url, fetchImpl, signal, onProgress: progressWriter(patch2) });
@@ -6591,7 +7039,7 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6591
7039
  if (!found.byPage) {
6592
7040
  const products = await found.fetchAll();
6593
7041
  if (signal.aborted) {
6594
- patch2({ stage: "cancelled", errors: [], message: "Stopped before anything was saved", finished: true });
7042
+ patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
6595
7043
  return;
6596
7044
  }
6597
7045
  if (!products.length) {
@@ -6617,18 +7065,23 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6617
7065
  }
6618
7066
  }
6619
7067
  const run2 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered);
6620
- const pictures = drainPictures(deps, jobId, brandId, tally2, signal);
7068
+ const pictures = drainPictures(deps, jobId, brandId, tally2, signal, hostOf(baseUrl));
6621
7069
  let pictureErrors;
6622
7070
  let pictureReasons = {};
6623
7071
  const stats = { pages: 0, bytes: 0, refused: 0, reasons: {} };
6624
7072
  try {
6625
7073
  if (bulk) for (const p of bulk) run2.write(p);
6626
- else
7074
+ if (urls.length)
6627
7075
  await fetchProductPages(ctx, urls, {
6628
7076
  concurrency: IMPORT_CONCURRENCY,
6629
7077
  maxBytes: 15e5,
6630
7078
  onEach: run2.write,
6631
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,
6632
7085
  // Past this many refusals in a row the store is not going to change
6633
7086
  // its mind inside this import, and waiting out its cooldown for the
6634
7087
  // rest of the catalogue is time nobody gets a product for.
@@ -6652,7 +7105,7 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6652
7105
  patch2({
6653
7106
  stage: "cancelled",
6654
7107
  errors: [],
6655
- message: tally2.upserted ? `Stopped after saving ${tally2.upserted.toLocaleString()} products` : "Stopped before anything was saved",
7108
+ message: stoppedMessage(tally2.upserted),
6656
7109
  finished: true
6657
7110
  });
6658
7111
  core.catalog.setSourceStatus(run2.sourceId, "partial", true);
@@ -6664,14 +7117,21 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6664
7117
  errors: pictureErrors,
6665
7118
  refused: stats.refused,
6666
7119
  reasons: { ...stats.reasons, ...pictureReasons },
6667
- asked: bulk ? bulk.length : urls.length,
6668
- // Every address was read. A bulk API hands the catalogue over whole, so
6669
- // there is nothing to cover.
6670
- covered: bulk ? true : stats.pages >= urls.length
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
6671
7131
  });
6672
7132
  } catch (err) {
6673
7133
  if (signal.aborted) {
6674
- patch2({ stage: "cancelled", errors: [], message: "Stopped before anything was saved", finished: true });
7134
+ patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
6675
7135
  return;
6676
7136
  }
6677
7137
  patch2({
@@ -6685,7 +7145,15 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
6685
7145
  var IMAGES_PER_PRODUCT = 3;
6686
7146
  var TERMINAL = /* @__PURE__ */ new Set(["completed", "partial", "no_catalog", "cancelled", "failed"]);
6687
7147
  var IMAGE_CONCURRENCY = 4;
6688
- var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
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));
6689
7157
  function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered) {
6690
7158
  const { core } = deps;
6691
7159
  const patch2 = (p) => core.catalog.updateJob(jobId, p);
@@ -6736,14 +7204,22 @@ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered)
6736
7204
  refused = 0,
6737
7205
  covered = true,
6738
7206
  reasons = {},
6739
- asked = 0
7207
+ asked = 0,
7208
+ worked
6740
7209
  }) {
6741
7210
  const mayRetire = sweep && covered && refused === 0 && tally2.upserted > 0;
6742
7211
  if (mayRetire) core.catalog.markMissingUnavailable(source.id, tally2.seenKeys);
7212
+ if (tally2.upserted > 0) core.catalog.excludeSharedImages(source.id);
6743
7213
  tally2.errors = errors;
6744
7214
  const partial = !covered || refused > 0;
6745
7215
  const savedNothing = tally2.upserted === 0;
6746
- const said = summarise(reasons, tally2.upserted, asked || discovered || tally2.fetched);
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
+ );
6747
7223
  const stage = savedNothing ? "failed" : partial ? "partial" : "completed";
6748
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`);
6749
7225
  patch2({
@@ -6762,8 +7238,9 @@ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered)
6762
7238
  };
6763
7239
  }
6764
7240
  var IMPORT_CONCURRENCY = 4;
7241
+ var IMPORT_DEADLINE_MS = 40 * 6e4;
6765
7242
  var PICTURE_ROUND = 60;
6766
- function drainPictures(deps, jobId, brandId, tally2, signal, imagesPerProduct = IMAGES_PER_PRODUCT) {
7243
+ function drainPictures(deps, jobId, brandId, tally2, signal, storeHost = "", imagesPerProduct = IMAGES_PER_PRODUCT) {
6767
7244
  const { core, fetchImpl } = deps;
6768
7245
  const patch2 = (p) => core.catalog.updateJob(jobId, p);
6769
7246
  const errors = [...core.catalog.getJob(jobId)?.errors ?? []];
@@ -6774,13 +7251,13 @@ function drainPictures(deps, jobId, brandId, tally2, signal, imagesPerProduct =
6774
7251
  const round = core.catalog.listImagesNeedingAssets(brandId, PICTURE_ROUND).filter((img) => img.position < imagesPerProduct);
6775
7252
  if (!round.length) {
6776
7253
  if (stopped) break;
6777
- await sleep6(150);
7254
+ await sleep7(150);
6778
7255
  continue;
6779
7256
  }
6780
- tally2.imagesTotal += round.length;
7257
+ tally2.imagesTotal = tally2.imagesDone + core.catalog.countImagesNeedingAssets(brandId, imagesPerProduct);
6781
7258
  await mapPool(
6782
7259
  round,
6783
- IMAGE_CONCURRENCY,
7260
+ allOffHost(round, storeHost) ? IMAGE_CONCURRENCY_OFF_HOST : IMAGE_CONCURRENCY,
6784
7261
  async (img) => {
6785
7262
  if (signal.aborted) return;
6786
7263
  try {
@@ -6797,6 +7274,11 @@ function drainPictures(deps, jobId, brandId, tally2, signal, imagesPerProduct =
6797
7274
  return;
6798
7275
  }
6799
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
+ }
6800
7282
  const turned = (probe.orientation ?? 1) > 1;
6801
7283
  const keep = turned ? await sharp21(buf).rotate().toBuffer() : buf;
6802
7284
  const meta = turned ? await sharp21(keep).metadata() : probe;
@@ -6896,12 +7378,26 @@ function brandJsonWithCatalogProducts(core, brandId) {
6896
7378
  function runningImportCount() {
6897
7379
  return running.size;
6898
7380
  }
7381
+ var IDENTITY_EDITS_MAX = 8;
7382
+ var IDENTITY_EDIT_CHARS = 120;
7383
+ var PRESENTER_SOURCES = ["synthetic", "photos"];
7384
+ var LIKENESS_VERSION = "v1";
6899
7385
  var PRESENTER_ID_PREFIX = "up";
6900
7386
  var SCENE_ID_PREFIX = "us";
6901
7387
  var ASSET_WIDTH = 1024;
6902
7388
  var ASSET_HEIGHT = 1280;
6903
7389
  var mintId = (prefix) => `${prefix}-${randomUUID().slice(0, 8)}`;
6904
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
+ };
6905
7401
  var strList = (v, max, each) => Array.isArray(v) ? v.map((x) => str3(x, each)).filter(Boolean).slice(0, max) : [];
6906
7402
  var assetRef = (hash) => {
6907
7403
  const h = String(hash ?? "");
@@ -6919,6 +7415,30 @@ function brandSceneById(brandJson, id) {
6919
7415
  function isCustomPresenter(c) {
6920
7416
  return c?.origin === "custom";
6921
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
+ }
6922
7442
  var SUBJECTS2 = /* @__PURE__ */ new Set(["product", "person", "either"]);
6923
7443
  function sceneRecordFrom(input, base) {
6924
7444
  const has = (k) => input[k] !== void 0;
@@ -6969,7 +7489,11 @@ function presenterRecordFrom(input, base) {
6969
7489
  const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
6970
7490
  if (!name) return { ok: false, error: "a presenter needs a name" };
6971
7491
  const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
6972
- 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 ?? [];
6973
7497
  if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
6974
7498
  const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
6975
7499
  const presenter = {
@@ -6982,13 +7506,13 @@ function presenterRecordFrom(input, base) {
6982
7506
  if (promptName) presenter.promptName = promptName;
6983
7507
  const presentation = has("presentation") ? str3(input.presentation, 10).toLowerCase() : base?.presentation;
6984
7508
  if (presentation === "woman" || presentation === "man") presenter.presentation = presentation;
6985
- const descriptor = has("descriptor") ? str3(input.descriptor, 120) : base?.descriptor;
7509
+ const descriptor = has("descriptor") ? phrase(input.descriptor, 120) : base?.descriptor;
6986
7510
  if (descriptor) presenter.descriptor = descriptor;
6987
7511
  const ageRange = has("ageRange") ? str3(input.ageRange, 40) : base?.ageRange;
6988
7512
  if (ageRange) presenter.ageRange = ageRange;
6989
- const hair = has("hair") ? str3(input.hair, 120) : base?.hair;
7513
+ const hair = has("hair") ? phrase(input.hair, 120) : base?.hair;
6990
7514
  if (hair) presenter.hair = hair;
6991
- const identityNotes = has("identityNotes") ? str3(input.identityNotes, 900) : base?.identityNotes;
7515
+ const identityNotes = has("identityNotes") ? phrase(input.identityNotes, 900) : base?.identityNotes;
6992
7516
  if (identityNotes) presenter.identityNotes = identityNotes;
6993
7517
  const negatives = has("negativeConstraints") ? strList(input.negativeConstraints, 8, 160) : base?.negativeConstraints;
6994
7518
  if (negatives?.length) presenter.negativeConstraints = negatives;
@@ -6999,8 +7523,36 @@ function presenterRecordFrom(input, base) {
6999
7523
  if (previewRef) presenter.preview = previewRef;
7000
7524
  const avatarRef = has("avatarHash") ? assetRef(input.avatarHash) : base?.avatar ?? null;
7001
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;
7002
7541
  return { ok: true, presenter };
7003
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
+ }
7004
7556
  function lintSceneProse(brandJson, scene) {
7005
7557
  const haystack = `${scene.prompt} ${scene.description} ${scene.figure ?? ""} ${scene.figureTreatment ?? ""}`.toLowerCase();
7006
7558
  const named = /* @__PURE__ */ new Set();
@@ -7016,14 +7568,14 @@ function lintSceneProse(brandJson, scene) {
7016
7568
  `This scene names ${[...named].join(", ")}. A scene is a place, so whatever you stage in it later has to argue with that.`
7017
7569
  ] : [];
7018
7570
  }
7019
- function commit(core, brandId, mutate) {
7571
+ function commit(core, brandId, mutate2, dropDraftId) {
7020
7572
  const brand = core.store.getBrand(brandId);
7021
7573
  if (!brand) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
7022
7574
  const json = { ...brand.json };
7023
- mutate(json);
7575
+ mutate2(json);
7024
7576
  const v = validateBrand(json);
7025
7577
  if (!v.valid) throw Object.assign(new Error(`brand became invalid: ${v.errors.join("; ")}`), { statusCode: 400 });
7026
- return core.store.updateBrand(brand.id, json);
7578
+ return dropDraftId ? core.store.updateBrandAndDropPresenterDraft(brand.id, json, dropDraftId) : core.store.updateBrand(brand.id, json);
7027
7579
  }
7028
7580
 
7029
7581
  // src/customAssets.ts
@@ -7055,20 +7607,17 @@ function startAssetBuild(deps, input) {
7055
7607
  if (input.sceneId && !prior) throw Object.assign(new Error("scene not found"), { statusCode: 404 });
7056
7608
  const supplied = input.imageHashes.length ? input.imageHashes : (prior?.refs ?? []).map((r) => String(r?.file ?? "").replace(/^asset:/, ""));
7057
7609
  const hashes = supplied.filter((h) => /^[a-f0-9]{32}$/.test(h) && core.images.has(h));
7058
- if (input.kind === "presenter" && !hashes.length) {
7059
- throw Object.assign(new Error("add at least one photo of this person"), { statusCode: 400 });
7060
- }
7061
- if (input.kind === "scene" && !hashes.length && !input.instruction?.trim()) {
7610
+ if (!hashes.length && !input.instruction?.trim()) {
7062
7611
  throw Object.assign(new Error("add a reference image, or describe the place in a sentence"), { statusCode: 400 });
7063
7612
  }
7064
7613
  const job = {
7065
7614
  id: `ab-${randomUUID().slice(0, 8)}`,
7066
7615
  brandId: brand.id,
7067
7616
  kind: input.kind,
7068
- name: str3(input.name, 60) || (input.kind === "presenter" ? "New presenter" : "New scene"),
7617
+ name: str3(input.name, 60) || "New scene",
7069
7618
  stage: "queued",
7070
7619
  step: 0,
7071
- steps: input.kind === "presenter" ? STUDIO_FRAMES.length : 1,
7620
+ steps: 1,
7072
7621
  message: null,
7073
7622
  assetId: null,
7074
7623
  previewHash: null,
@@ -7096,8 +7645,7 @@ function patch(job, next) {
7096
7645
  }
7097
7646
  async function runBuild(deps, job, hashes, instruction, signal) {
7098
7647
  try {
7099
- if (job.kind === "presenter") await runPresenterBuild(deps, job, hashes, instruction, signal);
7100
- else await runSceneBuild(deps, job, hashes, instruction, signal);
7648
+ await runSceneBuild(deps, job, hashes, instruction, signal);
7101
7649
  } catch (err) {
7102
7650
  if (signal.aborted) {
7103
7651
  patch(job, { stage: "cancelled", message: null, finished: true });
@@ -7106,162 +7654,6 @@ async function runBuild(deps, job, hashes, instruction, signal) {
7106
7654
  patch(job, { stage: "failed", error: err?.message ?? "build failed", message: null, finished: true });
7107
7655
  }
7108
7656
  }
7109
- var STUDIO_FRAMES = [
7110
- /*
7111
- * The identity frame, and it comes first because that is the order a brief
7112
- * attaches: `shots[0]` is the essential character reference.
7113
- *
7114
- * Every other frame here is full-length head-to-toe, which is right for
7115
- * build, proportion and wardrobe and useless for a face — in a 1024x1280
7116
- * full-length frame the face is about 105px brow to chin, while a portrait
7117
- * output renders it at four times that. Measured 2026-08-30 against the
7118
- * reported failure: four outputs of one brief, four different jaws, and
7119
- * drift that tracked nothing but how big the face was in the output.
7120
- *
7121
- * Drawn `from: 'sources'` rather than chained off the front view, because
7122
- * the user's own photographs are the only real face evidence in the system
7123
- * and a chain would just enlarge the same 105px.
7124
- */
7125
- {
7126
- angle: "portrait",
7127
- from: "sources",
7128
- 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`
7129
- },
7130
- {
7131
- angle: "front",
7132
- from: "sources",
7133
- 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`
7134
- },
7135
- {
7136
- angle: "left-profile",
7137
- from: "front",
7138
- 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"
7139
- },
7140
- {
7141
- angle: "right-profile",
7142
- from: "left-profile",
7143
- 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"
7144
- },
7145
- {
7146
- angle: "back",
7147
- from: "front",
7148
- 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"
7149
- }
7150
- ];
7151
- 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";
7152
- function studioPrompt(subject) {
7153
- 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.`;
7154
- }
7155
- function whoIs(name, draft) {
7156
- if (!draft) return `the exact person in the attached photographs`;
7157
- const bits = [draft.promptName];
7158
- if (draft.hair && !draft.promptName.toLowerCase().includes(draft.hair.toLowerCase())) bits.push(draft.hair);
7159
- if (draft.identityNotes) bits.push(draft.identityNotes);
7160
- return bits.filter(Boolean).join(", ") || name;
7161
- }
7162
- async function runPresenterBuild(deps, job, hashes, instruction, signal) {
7163
- const { core } = deps;
7164
- const sourcePaths = hashes.map((h) => core.images.pathFor(h));
7165
- let draft = null;
7166
- if (deps.analyzer) {
7167
- patch(job, { stage: "analyzing", message: "Reading the photos" });
7168
- draft = await deps.analyzer.analyze(
7169
- {
7170
- kind: "presenter",
7171
- imagePaths: sourcePaths,
7172
- name: job.name,
7173
- instruction: instruction || void 0,
7174
- vocabulary: deps.vocabulary
7175
- },
7176
- signal
7177
- );
7178
- patch(job, { coverage: draft.coverage ?? [] });
7179
- }
7180
- if (signal.aborted) throw new Error("cancelled");
7181
- let shotHashes = hashes;
7182
- let shotAngles = [];
7183
- const warnings = [];
7184
- if (deps.engine) {
7185
- patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
7186
- const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
7187
- if (built2.hashes.length) {
7188
- shotHashes = built2.hashes;
7189
- shotAngles = built2.angles;
7190
- } else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
7191
- } else {
7192
- warnings.push("No engine could draw the studio views, so the photos are being used directly.");
7193
- }
7194
- if (signal.aborted) throw new Error("cancelled");
7195
- patch(job, { stage: "saving", message: null });
7196
- const generated = shotHashes !== hashes;
7197
- const frontIndex = shotAngles.indexOf("front");
7198
- const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
7199
- const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
7200
- const built = presenterRecordFrom({
7201
- name: job.name,
7202
- shotHashes,
7203
- shotAngles,
7204
- sourceHashes: hashes,
7205
- previewHash,
7206
- avatarHash,
7207
- promptName: draft?.promptName,
7208
- presentation: draft?.presentation,
7209
- descriptor: draft?.descriptor,
7210
- ageRange: draft?.ageRange,
7211
- hair: draft?.hair,
7212
- identityNotes: draft?.identityNotes,
7213
- negativeConstraints: draft?.negativeConstraints,
7214
- // What the caller asked for wins over what the analyzer guessed: the
7215
- // person choosing where this belongs knows their own library.
7216
- suitableCategories: job.facets.length ? job.facets : draft?.suitableCategories
7217
- });
7218
- if (!built.ok) throw new Error(built.error);
7219
- commit(core, job.brandId, (json) => {
7220
- json.characters = [...brandCharacters(json), built.presenter];
7221
- });
7222
- patch(job, {
7223
- stage: "done",
7224
- step: job.steps,
7225
- assetId: built.presenter.id,
7226
- previewHash: previewHash ?? cardSource ?? null,
7227
- warnings: [...job.warnings, ...warnings],
7228
- finished: true
7229
- });
7230
- }
7231
- async function generateStudioSet(deps, job, who, sourcePaths, signal) {
7232
- const engine = deps.engine;
7233
- if (!engine) return { hashes: [], angles: [] };
7234
- const caps = engine.capabilities();
7235
- if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
7236
- const byAngle = /* @__PURE__ */ new Map();
7237
- for (const frame of STUDIO_FRAMES) {
7238
- if (signal.aborted) throw new Error("cancelled");
7239
- const refs = frame.from === "sources" ? sourcePaths.slice(0, caps.maxReferenceImages) : [byAngle.get(frame.from)].filter((h) => !!h).map((h) => deps.core.images.pathFor(h));
7240
- if (!refs.length) continue;
7241
- try {
7242
- const drawn = await draw(deps, {
7243
- prompt: studioPrompt(frame.subject(who)),
7244
- brandId: job.brandId,
7245
- referenceImages: refs,
7246
- referenceRoles: refs.map(() => "character"),
7247
- signal
7248
- });
7249
- const hash = await trimEdgeBars(deps.core, drawn);
7250
- byAngle.set(frame.angle, hash);
7251
- patch(job, {
7252
- step: byAngle.size,
7253
- previewHash: job.previewHash ?? hash,
7254
- message: `Building the studio views (${byAngle.size} of ${STUDIO_FRAMES.length})`
7255
- });
7256
- } catch (err) {
7257
- if (signal.aborted) throw err;
7258
- if (frame.angle === "front") throw err;
7259
- patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
7260
- }
7261
- }
7262
- const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
7263
- return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
7264
- }
7265
7657
  async function edgeBarGeometry(buf) {
7266
7658
  const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7267
7659
  const W = info.width;
@@ -7332,7 +7724,7 @@ async function cardCrop(core, hash) {
7332
7724
  }
7333
7725
  var AVATAR_FIGURE_FRACTION = 0.22;
7334
7726
  var AVATAR_HEADROOM = 0.1;
7335
- var AVATAR_MAX_PX = 512;
7727
+ var AVATAR_MAX_PX = 1024;
7336
7728
  var FIGURE_TRIM_THRESHOLDS = [12, 25];
7337
7729
  async function avatarCrop(core, hash) {
7338
7730
  if (!hash || !core.images.has(hash)) return void 0;
@@ -7435,10 +7827,25 @@ async function figureBox(buf) {
7435
7827
  return null;
7436
7828
  }
7437
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
+ }
7438
7834
  const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
7439
7835
  const avatarHash = mode === "generated" ? await avatarCrop(core, hash) ?? await avatarCropSmart(core, hash) : await avatarCropSmart(core, hash) ?? await avatarCrop(core, hash);
7440
7836
  return { previewHash, avatarHash };
7441
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
+ }
7442
7849
  async function cardCropSmart(core, hash) {
7443
7850
  return smartCover(core, hash, (w, h) => {
7444
7851
  if (w / h > 0.8) return { width: Math.round(h * 0.8), height: h };
@@ -7577,8 +7984,8 @@ async function draw(deps, req) {
7577
7984
  brand: deps.brandContext(req.brandId),
7578
7985
  referenceImages: req.referenceImages,
7579
7986
  referenceRoles: req.referenceRoles,
7580
- width: ASSET_WIDTH,
7581
- height: ASSET_HEIGHT,
7987
+ width: req.width ?? ASSET_WIDTH,
7988
+ height: req.height ?? ASSET_HEIGHT,
7582
7989
  count: 1
7583
7990
  };
7584
7991
  const estimate = await engine.costEstimate(generateReq).catch(() => 0);
@@ -8733,6 +9140,8 @@ function registerLogoRoutes(app, deps) {
8733
9140
  var scans = /* @__PURE__ */ new Map();
8734
9141
  var controllers = /* @__PURE__ */ new Map();
8735
9142
  var KEEP = 32;
9143
+ var HARD_STOP_MS = 42e3;
9144
+ var KEEP_MS = 10 * 6e4;
8736
9145
  function remember(state) {
8737
9146
  scans.set(state.id, state);
8738
9147
  while (scans.size > KEEP) {
@@ -8743,7 +9152,20 @@ function remember(state) {
8743
9152
  }
8744
9153
  }
8745
9154
  function getScan(scanId) {
8746
- 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;
8747
9169
  }
8748
9170
  function startCatalogScan(deps, brandId, url) {
8749
9171
  const { core, fetchImpl } = deps;
@@ -8753,13 +9175,22 @@ function startCatalogScan(deps, brandId, url) {
8753
9175
  const state = { id, brandId, url, status: "running", startedAt: Date.now() };
8754
9176
  remember(state);
8755
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?.();
8756
9184
  void scanForCandidates({ url, fetchImpl, signal: ctrl.signal }).then((result) => {
8757
9185
  state.result = result;
8758
9186
  state.status = "done";
8759
9187
  }).catch((err) => {
8760
9188
  state.status = "error";
8761
- state.error = err instanceof Error ? err.message : String(err);
8762
- }).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
+ });
8763
9194
  return { scanId: id };
8764
9195
  }
8765
9196
 
@@ -8803,6 +9234,13 @@ function registerCatalogImportRoutes(app, deps) {
8803
9234
  if (!scan || scan.brandId !== brandId) return reply.status(404).send({ error: "scan not found" });
8804
9235
  return scan;
8805
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
+ });
8806
9244
  app.post("/api/brands/:id/catalog/details", async (req, reply) => {
8807
9245
  const brand = core.store.getBrand(req.params.id);
8808
9246
  if (!brand) return reply.status(404).send({ error: "brand not found" });
@@ -9052,8 +9490,11 @@ function registerPresenterRoutes(app, deps) {
9052
9490
  }
9053
9491
 
9054
9492
  // src/presenterRepair.ts
9055
- function presenterCropMode(firstShotFile, firstSourceFile) {
9056
- 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";
9057
9498
  }
9058
9499
  async function repairPresenterCrops(core, log = () => {
9059
9500
  }) {
@@ -9065,7 +9506,11 @@ async function repairPresenterCrops(core, log = () => {
9065
9506
  const firstShot = c.shots?.[0]?.file;
9066
9507
  const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
9067
9508
  if (!hash) continue;
9068
- 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
+ );
9069
9514
  const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
9070
9515
  const preview = previewHash ? `asset:${previewHash}` : void 0;
9071
9516
  const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
@@ -9084,22 +9529,1164 @@ async function repairPresenterCrops(core, log = () => {
9084
9529
  return { repaired };
9085
9530
  }
9086
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
+
9087
10684
  // src/routes/assetBuilds.ts
9088
- function registerAssetBuildRoutes(app, deps) {
10685
+ function makeBuildDeps(deps) {
9089
10686
  const { core, engines, scenes, presenters } = deps;
9090
10687
  const analyzer = deps.analyzer ?? createCodexAnalyzer({ runner: engines.codexRunner });
9091
- const buildEngine = async () => {
9092
- const ordered = [...engines.all()].sort((a, b) => {
9093
- const rank = (e) => e.capabilities().id === "codex-cli" ? 0 : 1;
9094
- return rank(a) - rank(b);
9095
- });
9096
- for (const engine of ordered) {
9097
- const caps = engine.capabilities();
9098
- if (!caps.maxReferenceImages || caps.placeholder) continue;
9099
- if ((await engine.isAvailable()).ok) return engine;
9100
- }
9101
- return null;
9102
- };
10688
+ const allowPlaceholder = process.env.SCENRI_DEMO_BUILDS === "1";
10689
+ const buildEngine = () => pickBuildEngine(engines, { allowPlaceholder });
9103
10690
  const buildDeps = async () => ({
9104
10691
  core,
9105
10692
  engine: await buildEngine(),
@@ -9109,8 +10696,17 @@ function registerAssetBuildRoutes(app, deps) {
9109
10696
  // person can actually click rather than inventing a category of one.
9110
10697
  vocabulary: { ...facetsOf(scenes), categories: presenterFacetsOf(presenters).categories }
9111
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) };
9112
10705
  app.get("/api/asset-builds/capabilities", async () => {
9113
- 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
+ ]);
9114
10710
  return {
9115
10711
  canAnalyze: probe.ok,
9116
10712
  analyzeReason: probe.ok ? null : probe.reason ?? null,
@@ -9140,8 +10736,9 @@ function registerAssetBuildRoutes(app, deps) {
9140
10736
  if (!brand) return;
9141
10737
  const body = req.body ?? {};
9142
10738
  const kind = String(body.kind ?? "");
9143
- if (kind !== "presenter" && kind !== "scene")
9144
- 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" });
9145
10742
  try {
9146
10743
  return startAssetBuild(await buildDeps(), {
9147
10744
  brandId: brand.id,
@@ -9215,6 +10812,32 @@ function registerAssetBuildRoutes(app, deps) {
9215
10812
  }
9216
10813
  return { presenter: built.presenter, brand: core.store.getBrand(brand.id) };
9217
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
+ });
9218
10841
  app.delete("/api/brands/:id/presenters/:presenterId", async (req, reply) => {
9219
10842
  const brand = brandOr404(req, reply);
9220
10843
  if (!brand) return;
@@ -9225,7 +10848,8 @@ function registerAssetBuildRoutes(app, deps) {
9225
10848
  commit(core, brand.id, (json) => {
9226
10849
  json.characters = brandCharacters(json).filter((c) => c.id !== id);
9227
10850
  });
9228
- return { ok: true };
10851
+ await releasePresenter(await buildDeps(), brand.id, base, hooks);
10852
+ return { ok: true, brand: core.store.getBrand(brand.id) };
9229
10853
  });
9230
10854
  app.post("/api/brands/:id/scenes", async (req, reply) => {
9231
10855
  const brand = brandOr404(req, reply);
@@ -9338,14 +10962,191 @@ async function withDerivedCrops(core, body, base) {
9338
10962
  const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
9339
10963
  if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
9340
10964
  const firstShot = `asset:${String(shots[0])}`;
9341
- const firstSource = Array.isArray(body.sourceHashes) ? `asset:${String(body.sourceHashes[0])}` : base?.sourceRefs?.[0]?.file;
9342
- 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));
9343
10969
  return {
9344
10970
  ...body,
9345
10971
  ...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
9346
10972
  ...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
9347
10973
  };
9348
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
+ }
9349
11150
  function registerDemoProductRoutes(app, deps) {
9350
11151
  const { templatesRoot, demoProducts, demoProductById, thumbs } = deps;
9351
11152
  const demoProductThumbPath = (id) => {
@@ -9824,13 +11625,12 @@ function registerImageRoutes(app, deps) {
9824
11625
  return reply.send(thumbs.stream(path));
9825
11626
  });
9826
11627
  app.post("/api/images", async (req, reply) => {
9827
- const part = await req.file();
9828
- if (!part) return reply.status(400).send({ error: "multipart file field required" });
9829
- const buf = await part.toBuffer();
9830
- if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
9831
- const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
9832
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
9833
- 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 };
9834
11634
  });
9835
11635
  app.post("/api/diff", async (req, reply) => {
9836
11636
  const { imageA, imageB } = req.body;
@@ -9851,6 +11651,21 @@ function registerImageRoutes(app, deps) {
9851
11651
 
9852
11652
  // src/release/notes.data.ts
9853
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
+ },
9854
11669
  {
9855
11670
  version: "0.9.4",
9856
11671
  date: "2026-09-14",
@@ -10859,7 +12674,7 @@ function registerDesktopRoutes(app, deps) {
10859
12674
  record: null
10860
12675
  };
10861
12676
  }
10862
- const { installDeps } = await import('./cli-MQO66C5O.js');
12677
+ const { installDeps } = await import('./cli-EXASA5BK.js');
10863
12678
  const { desktopStatus } = await import('./install-VXJPLYWM.js');
10864
12679
  return desktopStatus(installDeps(runtime.entry));
10865
12680
  });
@@ -10867,7 +12682,7 @@ function registerDesktopRoutes(app, deps) {
10867
12682
  if (!runtime.entry) {
10868
12683
  return { ok: false, reason: "unsupported", message: "Desktop shortcuts are not available on this system yet." };
10869
12684
  }
10870
- const { addToDesktop } = await import('./cli-MQO66C5O.js');
12685
+ const { addToDesktop } = await import('./cli-EXASA5BK.js');
10871
12686
  return addToDesktop(runtime.entry);
10872
12687
  });
10873
12688
  app.get("/api/desktop", async () => {
@@ -11153,7 +12968,10 @@ function buildServer(opts) {
11153
12968
  const presentersDir = join(templatesRoot, "presenters");
11154
12969
  const { presenters } = loadPresenters(presentersDir);
11155
12970
  registerPresenterRoutes(app, { templatesRoot, presenters, thumbs });
11156
- 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 });
11157
12975
  const { demoProducts } = loadDemoProducts(join(templatesRoot, "demo-products"));
11158
12976
  const demoProductById = demoProductResolver(demoProducts);
11159
12977
  registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById, thumbs });
@@ -12287,7 +14105,7 @@ function buildServer(opts) {
12287
14105
  // one physical run counts once, however many sibling nodes share its
12288
14106
  // controller — an update gate held open by a 4-shot batch is still held
12289
14107
  // open by exactly one piece of work
12290
- busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
14108
+ busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
12291
14109
  });
12292
14110
  let drained = null;
12293
14111
  app.decorate("drain", () => {
@@ -12309,7 +14127,7 @@ function buildServer(opts) {
12309
14127
  core,
12310
14128
  runtime,
12311
14129
  exitImpl: opts.exitImpl,
12312
- busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
14130
+ busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
12313
14131
  });
12314
14132
  if (opts.studioDist && existsSync(opts.studioDist)) {
12315
14133
  const dist = opts.studioDist;
@@ -12354,6 +14172,7 @@ async function run() {
12354
14172
  const core = createCore();
12355
14173
  const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
12356
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;
12357
14176
  const here = dirname(fileURLToPath(import.meta.url));
12358
14177
  const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];
12359
14178
  const studioDist = candidates.find((p) => existsSync(p));
@@ -12365,6 +14184,7 @@ async function run() {
12365
14184
  const app = buildServer({
12366
14185
  core,
12367
14186
  engines,
14187
+ analyzer,
12368
14188
  studioDist,
12369
14189
  access: { allowedHosts: reachableAt, token },
12370
14190
  runtime: {
@@ -12470,7 +14290,7 @@ async function run() {
12470
14290
  }
12471
14291
  }
12472
14292
  const ownEntry = fileURLToPath(import.meta.url);
12473
- const { addToDesktop, installDeps } = await import('./cli-MQO66C5O.js');
14293
+ const { addToDesktop, installDeps } = await import('./cli-EXASA5BK.js');
12474
14294
  const { refreshLauncher } = await import('./refresh-5HBDLLLB.js');
12475
14295
  const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-W6NJXMWT.js');
12476
14296
  const { launcherInstalled } = await import('./paths-6ZXRZUHY.js');