scenri 0.7.5 → 0.8.1

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.
package/dist/serve.js CHANGED
@@ -6,11 +6,11 @@ import { networkInterfaces, homedir, tmpdir } from 'os';
6
6
  import { sep, join, dirname, normalize } from 'path';
7
7
  import Database from 'better-sqlite3';
8
8
  import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
9
- import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, readdirSync, statSync, rmSync, renameSync } from 'fs';
9
+ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, createReadStream, rmSync, readdirSync, statSync, renameSync } from 'fs';
10
10
  import { fileURLToPath } from 'url';
11
- import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
11
+ import { readFile, copyFile, stat, access, readdir, mkdtemp, rm, rename, unlink, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp20 from 'sharp';
13
+ import sharp21 from 'sharp';
14
14
  import Fastify from 'fastify';
15
15
  import fastifyStatic from '@fastify/static';
16
16
  import fastifyMultipart from '@fastify/multipart';
@@ -297,8 +297,14 @@ function collapseProjects(db) {
297
297
  })();
298
298
  }
299
299
  }
300
+ var IMAGES_SPLIT_MARK = "v1";
300
301
  function splitMultiImageNodes(db) {
301
- const rows = db.prepare("SELECT * FROM nodes").all();
302
+ const done = db.prepare("SELECT value FROM settings WHERE key='images_split'").get()?.value;
303
+ if (done === IMAGES_SPLIT_MARK) return;
304
+ const mark = () => db.prepare(
305
+ "INSERT INTO settings (key, value) VALUES ('images_split', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
306
+ ).run(IMAGES_SPLIT_MARK);
307
+ const rows = db.prepare("SELECT * FROM nodes WHERE images LIKE '%,%'").all();
302
308
  const multi = rows.filter((r) => {
303
309
  try {
304
310
  return JSON.parse(r.images).length > 1;
@@ -306,7 +312,10 @@ function splitMultiImageNodes(db) {
306
312
  return false;
307
313
  }
308
314
  });
309
- if (!multi.length) return;
315
+ if (!multi.length) {
316
+ mark();
317
+ return;
318
+ }
310
319
  const parse2 = (s) => {
311
320
  if (!s) return null;
312
321
  try {
@@ -389,6 +398,89 @@ function splitMultiImageNodes(db) {
389
398
  }
390
399
  }
391
400
  })();
401
+ mark();
402
+ }
403
+ function ensureIndexes(db) {
404
+ db.exec(`
405
+ CREATE INDEX IF NOT EXISTS idx_nodes_project_created ON nodes(project_id, created_at, id);
406
+ CREATE INDEX IF NOT EXISTS idx_nodes_project_kept ON nodes(project_id, kept, created_at, id);
407
+ CREATE INDEX IF NOT EXISTS idx_nodes_project_cost ON nodes(project_id, cost_usd, created_at, id);
408
+ CREATE INDEX IF NOT EXISTS idx_nodes_project_state ON nodes(project_id, kind, archived, kept);
409
+ DROP INDEX IF EXISTS idx_nodes_parent;
410
+ CREATE INDEX IF NOT EXISTS idx_nodes_parent_created ON nodes(parent_id, created_at, id);
411
+ CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
412
+ CREATE INDEX IF NOT EXISTS idx_catalog_variants_product ON catalog_variants(product_id);
413
+ CREATE INDEX IF NOT EXISTS idx_cost_events_engine_ts ON cost_events(engine_id, ts);
414
+ `);
415
+ }
416
+ function searchTextSql(alias) {
417
+ const brief = `CASE WHEN json_valid(${alias}.brief) THEN ${alias}.brief ELSE '{}' END`;
418
+ return `trim(coalesce(${alias}.prompt, '') || ' ' ||
419
+ coalesce((SELECT group_concat(je.value, ' ') FROM json_each(${brief}, '$.templateFields') AS je), '') || ' ' ||
420
+ coalesce((SELECT group_concat(coalesce(json_extract(je.value, '$.name'), '') || ' ' || coalesce(json_extract(je.value, '$.hex'), ''), ' ')
421
+ FROM json_each(${brief}, '$.tokens') AS je WHERE json_extract(je.value, '$.t') = 'color'), ''))`;
422
+ }
423
+ function tokenRowsSql(alias) {
424
+ const brief = `CASE WHEN json_valid(${alias}.brief) THEN ${alias}.brief ELSE '{}' END`;
425
+ return `SELECT ${alias}.id, json_extract(je.value, '$.t'), json_extract(je.value, '$.id')
426
+ FROM json_each(${brief}, '$.tokens') AS je
427
+ WHERE json_extract(je.value, '$.t') IN ('product', 'character', 'template')
428
+ AND json_extract(je.value, '$.id') IS NOT NULL
429
+ UNION ALL
430
+ SELECT ${alias}.id, 'template', json_extract(${brief}, '$.templateId')
431
+ WHERE json_extract(${brief}, '$.templateId') IS NOT NULL`;
432
+ }
433
+ function tokenRowsFromNodesSql() {
434
+ const brief = "CASE WHEN json_valid(n.brief) THEN n.brief ELSE '{}' END";
435
+ return `SELECT n.id, json_extract(je.value, '$.t'), json_extract(je.value, '$.id')
436
+ FROM nodes n, json_each(${brief}, '$.tokens') AS je
437
+ WHERE json_extract(je.value, '$.t') IN ('product', 'character', 'template')
438
+ AND json_extract(je.value, '$.id') IS NOT NULL
439
+ UNION ALL
440
+ SELECT n.id, 'template', json_extract(${brief}, '$.templateId')
441
+ FROM nodes n
442
+ WHERE json_extract(${brief}, '$.templateId') IS NOT NULL`;
443
+ }
444
+ var SEARCH_INDEX_VERSION = "v1";
445
+ function ensureSearch(db) {
446
+ db.exec(`
447
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(text, tokenize='trigram case_sensitive 0 remove_diacritics 1');
448
+ CREATE TABLE IF NOT EXISTS node_tokens (
449
+ node_id TEXT NOT NULL,
450
+ kind TEXT NOT NULL,
451
+ token_id TEXT NOT NULL,
452
+ PRIMARY KEY (node_id, kind, token_id)
453
+ ) WITHOUT ROWID;
454
+ CREATE INDEX IF NOT EXISTS idx_node_tokens_token ON node_tokens(token_id, node_id);
455
+ CREATE TRIGGER IF NOT EXISTS nodes_search_ai AFTER INSERT ON nodes BEGIN
456
+ INSERT INTO nodes_fts(rowid, text) VALUES (new.rowid, ${searchTextSql("new")});
457
+ INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsSql("new")};
458
+ END;
459
+ CREATE TRIGGER IF NOT EXISTS nodes_search_au AFTER UPDATE OF prompt, brief ON nodes BEGIN
460
+ DELETE FROM nodes_fts WHERE rowid = old.rowid;
461
+ DELETE FROM node_tokens WHERE node_id = old.id;
462
+ INSERT INTO nodes_fts(rowid, text) VALUES (new.rowid, ${searchTextSql("new")});
463
+ INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsSql("new")};
464
+ END;
465
+ CREATE TRIGGER IF NOT EXISTS nodes_search_ad AFTER DELETE ON nodes BEGIN
466
+ DELETE FROM nodes_fts WHERE rowid = old.rowid;
467
+ DELETE FROM node_tokens WHERE node_id = old.id;
468
+ END;
469
+ `);
470
+ const marker = db.prepare("SELECT value FROM settings WHERE key='search_index'").get()?.value;
471
+ const bounds = db.prepare("SELECT min(rowid) AS lo, max(rowid) AS hi, count(*) AS c FROM nodes").get();
472
+ const indexed = (rowid) => rowid !== null && !!db.prepare("SELECT 1 FROM nodes_fts WHERE rowid = ?").get(rowid);
473
+ const whole = bounds.c === 0 || indexed(bounds.lo) && indexed(bounds.hi);
474
+ if (marker === SEARCH_INDEX_VERSION && whole) return;
475
+ db.transaction(() => {
476
+ db.exec("DELETE FROM nodes_fts");
477
+ db.exec("DELETE FROM node_tokens");
478
+ db.exec(`INSERT INTO nodes_fts(rowid, text) SELECT n.rowid, ${searchTextSql("n")} FROM nodes n`);
479
+ db.exec(`INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsFromNodesSql()}`);
480
+ db.prepare(
481
+ "INSERT INTO settings (key, value) VALUES ('search_index', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
482
+ ).run(SEARCH_INDEX_VERSION);
483
+ })();
392
484
  }
393
485
  var SCHEMA_VERSION = 2;
394
486
  var SchemaTooNewError = class extends Error {
@@ -461,9 +553,11 @@ function openDb(homeDir) {
461
553
  db.exec("ALTER TABLE catalog_images ADD COLUMN excluded INTEGER NOT NULL DEFAULT 0");
462
554
  }
463
555
  widenNodeStatusCheck(db);
556
+ ensureIndexes(db);
464
557
  backfillSlugs(db);
465
558
  collapseProjects(db);
466
559
  splitMultiImageNodes(db);
560
+ ensureSearch(db);
467
561
  db.prepare(
468
562
  "UPDATE nodes SET status='error', error='interrupted: server restarted mid-generation' WHERE status='running'"
469
563
  ).run();
@@ -544,6 +638,31 @@ function createLedger(db) {
544
638
  }
545
639
  };
546
640
  }
641
+
642
+ // ../core/src/searchRules.ts
643
+ function fold(s) {
644
+ return s.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").replace(/[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g, "").toLowerCase();
645
+ }
646
+ var STEM_MIN = 4;
647
+ var TRIGRAM_MIN = 3;
648
+ function searchTerms(q) {
649
+ return fold(q).trim().split(/\s+/).filter(Boolean).map((text) => ({
650
+ text,
651
+ stem: text.length >= STEM_MIN && text.endsWith("s") ? text.slice(0, -1) : null
652
+ }));
653
+ }
654
+ function termMatches(haystack, term) {
655
+ const h = fold(haystack);
656
+ return h.includes(term.text) || term.stem !== null && h.includes(term.stem);
657
+ }
658
+ var quote = (s) => `"${s.replace(/"/g, '""')}"`;
659
+ function ftsMatch(term) {
660
+ if (term.text.length < TRIGRAM_MIN) return null;
661
+ return term.stem ? `(${quote(term.text)} OR ${quote(term.stem)})` : quote(term.text);
662
+ }
663
+
664
+ // ../core/src/store.ts
665
+ var PROMPT_HEAD_CHARS = 240;
547
666
  function uniqueSlug(db, name, id) {
548
667
  const stmt = db.prepare("SELECT 1 FROM brands WHERE slug=? AND id IS NOT ?");
549
668
  return firstFree(slugifyWithId(name, id), (c) => RESERVED_SLUGS.has(c) || !!stmt.get(c, id));
@@ -567,13 +686,20 @@ function rowToSet(r) {
567
686
  updatedAt: r.updated_at
568
687
  };
569
688
  }
570
- function rowToNode(r) {
689
+ var headOf = (prompt) => Array.from(String(prompt ?? "")).slice(0, PROMPT_HEAD_CHARS).join("");
690
+ var CHILD_COUNT_SQL = "(CASE WHEN n.kind = 'root' THEN 0 ELSE (SELECT count(*) FROM nodes c WHERE c.parent_id = n.id AND c.archived = 0) END)";
691
+ var LINEAGE_SIBLINGS_RADIUS = 25;
692
+ var LINEAGE_CHILDREN_MAX = 60;
693
+ var FEED_COLS = `n.id, n.project_id, n.parent_id, n.kind, substr(n.prompt, 1, ${PROMPT_HEAD_CHARS}) AS prompt_head,
694
+ n.engine_id, n.status, n.images, n.cost_usd, n.duration_ms, n.kept, n.error, n.created_at, n.brief, n.archived,
695
+ n.batch_id, n.batch_index, ${CHILD_COUNT_SQL} AS child_count`;
696
+ function rowToFeedNode(r) {
571
697
  return {
572
698
  id: r.id,
573
699
  projectId: r.project_id,
574
700
  parentId: r.parent_id,
575
701
  kind: r.kind,
576
- prompt: r.prompt,
702
+ promptHead: r.prompt_head ?? headOf(r.prompt),
577
703
  engineId: r.engine_id,
578
704
  status: r.status,
579
705
  images: JSON.parse(r.images),
@@ -582,11 +708,18 @@ function rowToNode(r) {
582
708
  kept: !!r.kept,
583
709
  error: r.error,
584
710
  createdAt: r.created_at,
585
- overlays: JSON.parse(r.overlays ?? "{}"),
586
711
  brief: r.brief ? JSON.parse(r.brief) : null,
587
712
  archived: !!r.archived,
588
713
  batchId: r.batch_id ?? null,
589
- batchIndex: r.batch_index ?? 0
714
+ batchIndex: r.batch_index ?? 0,
715
+ childCount: r.child_count ?? 0
716
+ };
717
+ }
718
+ function rowToNode(r) {
719
+ return {
720
+ ...rowToFeedNode(r),
721
+ prompt: r.prompt,
722
+ overlays: JSON.parse(r.overlays ?? "{}")
590
723
  };
591
724
  }
592
725
  var lastBatchStamp = 0;
@@ -599,6 +732,92 @@ function batchStamps(count) {
599
732
  return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
600
733
  });
601
734
  }
735
+ var encodeCursor = (k) => Buffer.from(JSON.stringify(k)).toString("base64url");
736
+ function decodeCursor(cursor) {
737
+ try {
738
+ const k = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
739
+ if (typeof k?.c === "string" && typeof k?.i === "string") return k;
740
+ } catch {
741
+ }
742
+ throw new Error("invalid cursor");
743
+ }
744
+ function filterSql(f, params, withLens) {
745
+ const where = ["n.kind != 'root'"];
746
+ if (withLens) {
747
+ if (f.lens === "archived") where.push("n.archived = 1");
748
+ else if (f.lens === "keepers") where.push("n.archived = 0 AND n.kept = 1");
749
+ else where.push("n.archived = 0");
750
+ }
751
+ if (f.lineage) {
752
+ params.lineage = f.lineage;
753
+ where.push(
754
+ "n.id IN (WITH RECURSIVE d(id) AS (SELECT @lineage UNION ALL SELECT c.id FROM nodes c JOIN d ON c.parent_id = d.id) SELECT id FROM d)"
755
+ );
756
+ } else if (f.set) {
757
+ params.set = f.set;
758
+ where.push("n.id IN (SELECT node_id FROM set_nodes WHERE set_id = @set)");
759
+ } else if (f.ungrouped) {
760
+ where.push("NOT EXISTS (SELECT 1 FROM set_nodes sn WHERE sn.node_id = n.id)");
761
+ }
762
+ if (f.tokens?.length) {
763
+ const names = f.tokens.map((t, i) => {
764
+ params[`tok${i}`] = t;
765
+ return `@tok${i}`;
766
+ });
767
+ where.push(`n.id IN (SELECT node_id FROM node_tokens WHERE token_id IN (${names.join(", ")}))`);
768
+ }
769
+ (f.terms ?? []).forEach((term, i) => {
770
+ const any = [];
771
+ const match = ftsMatch(term);
772
+ if (match) {
773
+ params[`m${i}`] = match;
774
+ any.push(`n.rowid IN (SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH @m${i})`);
775
+ }
776
+ if (term.tokenIds.length) {
777
+ const names = term.tokenIds.map((t, j) => {
778
+ params[`t${i}_${j}`] = t;
779
+ return `@t${i}_${j}`;
780
+ });
781
+ any.push(`n.id IN (SELECT node_id FROM node_tokens WHERE token_id IN (${names.join(", ")}))`);
782
+ }
783
+ if (term.engineIds.length) {
784
+ const names = term.engineIds.map((e, j) => {
785
+ params[`e${i}_${j}`] = e;
786
+ return `@e${i}_${j}`;
787
+ });
788
+ any.push(`n.engine_id IN (${names.join(", ")})`);
789
+ }
790
+ if (any.length) where.push(`(${any.join(" OR ")})`);
791
+ });
792
+ return where;
793
+ }
794
+ function sortSql(sort, cursor, params) {
795
+ if (cursor) {
796
+ params.c = cursor.c;
797
+ params.i = cursor.i;
798
+ params.v = cursor.v ?? 0;
799
+ }
800
+ const newest = "(n.created_at < @c OR (n.created_at = @c AND n.id < @i))";
801
+ const oldest = "(n.created_at > @c OR (n.created_at = @c AND n.id > @i))";
802
+ switch (sort) {
803
+ case "oldest":
804
+ return { order: "n.created_at ASC, n.id ASC", after: cursor ? oldest : null };
805
+ case "cost":
806
+ return {
807
+ order: "n.cost_usd DESC, n.created_at DESC, n.id DESC",
808
+ after: cursor ? `(n.cost_usd < @v OR (n.cost_usd = @v AND ${newest}))` : null
809
+ };
810
+ case "keepers":
811
+ return {
812
+ order: "n.kept DESC, n.created_at DESC, n.id DESC",
813
+ after: cursor ? `(n.kept < @v OR (n.kept = @v AND ${newest}))` : null
814
+ };
815
+ default:
816
+ return { order: "n.created_at DESC, n.id DESC", after: cursor ? newest : null };
817
+ }
818
+ }
819
+ var keysetOf = (n, sort) => sort === "cost" ? { c: n.createdAt, i: n.id, v: n.costUsd } : sort === "keepers" ? { c: n.createdAt, i: n.id, v: n.kept ? 1 : 0 } : { c: n.createdAt, i: n.id };
820
+ var FEED_PAGE_MAX = 200;
602
821
  function createStore(db) {
603
822
  return {
604
823
  // brands
@@ -750,6 +969,10 @@ function createStore(db) {
750
969
  }
751
970
  return out;
752
971
  },
972
+ /** One set's members, in the order they were filed. */
973
+ membersOf(setId) {
974
+ return db.prepare("SELECT node_id FROM set_nodes WHERE set_id=? ORDER BY added_at, node_id").all(setId).map((r) => r.node_id);
975
+ },
753
976
  // nodes / version tree
754
977
  addNode(input) {
755
978
  if (input.parentId) {
@@ -806,6 +1029,16 @@ function createStore(db) {
806
1029
  id
807
1030
  );
808
1031
  },
1032
+ /**
1033
+ * The run's money, written once it is known. A batch's first sibling used
1034
+ * to be charged inside completeNode, at the end of the whole call; a
1035
+ * sibling now completes the moment its own image lands, and the cost is
1036
+ * only known when the call resolves, so it is written afterwards, onto a
1037
+ * node that finished. A failed or running node keeps 0, as before.
1038
+ */
1039
+ chargeNode(id, costUsd) {
1040
+ db.prepare("UPDATE nodes SET cost_usd=? WHERE id=? AND status='done'").run(costUsd, id);
1041
+ },
809
1042
  failNode(id, error) {
810
1043
  db.prepare("UPDATE nodes SET status='error', error=? WHERE id=?").run(error, id);
811
1044
  },
@@ -813,13 +1046,143 @@ function createStore(db) {
813
1046
  db.prepare("UPDATE nodes SET status='cancelled' WHERE id=?").run(id);
814
1047
  },
815
1048
  getNode(id) {
816
- const r = db.prepare("SELECT * FROM nodes WHERE id=?").get(id);
1049
+ const r = db.prepare(`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.id=?`).get(id);
817
1050
  return r ? rowToNode(r) : null;
818
1051
  },
819
- treeFor(projectId) {
820
- return db.prepare("SELECT * FROM nodes WHERE project_id=? ORDER BY created_at, id").all(projectId).map(
821
- rowToNode
1052
+ /** The list shape of one shot: what a keep or an archive answers with. */
1053
+ getFeedNode(id) {
1054
+ const r = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.id=?`).get(id);
1055
+ return r ? rowToFeedNode(r) : null;
1056
+ },
1057
+ /** The project's root, by index, rather than the whole tree read to find it. */
1058
+ rootFor(projectId) {
1059
+ const rows = db.prepare(`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.project_id=? AND n.kind='root'`).all(projectId);
1060
+ rows.sort(
1061
+ (a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id))
822
1062
  );
1063
+ return rows.length ? rowToNode(rows[0]) : null;
1064
+ },
1065
+ treeFor(projectId) {
1066
+ return db.prepare(
1067
+ `SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.project_id=? ORDER BY n.created_at, n.id`
1068
+ ).all(projectId).map(rowToNode);
1069
+ },
1070
+ /**
1071
+ * One page of a project's shots for a place, lens, search and sort.
1072
+ *
1073
+ * Keyset paging on the sort's own columns, never OFFSET: the cost of page
1074
+ * forty is the cost of page one, and a shot landing between two pages
1075
+ * shifts nothing already read. Every clause is served by an index or by
1076
+ * the search index; the whole workspace is never read.
1077
+ */
1078
+ feedPage(projectId, q) {
1079
+ const limit = Math.max(1, Math.min(FEED_PAGE_MAX, Math.floor(q.limit ?? 60)));
1080
+ const sort = q.sort ?? "newest";
1081
+ const params = { project: projectId, limit: limit + 1 };
1082
+ const where = ["n.project_id = @project", ...filterSql(q, params, true)];
1083
+ const { order, after } = sortSql(sort, q.cursor ? decodeCursor(q.cursor) : null, params);
1084
+ if (after) where.push(after);
1085
+ const rows = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT @limit`).all(params);
1086
+ const items = rows.slice(0, limit).map(rowToFeedNode);
1087
+ const more = rows.length > limit;
1088
+ return { items, next: more && items.length ? encodeCursor(keysetOf(items[items.length - 1], sort)) : null };
1089
+ },
1090
+ /**
1091
+ * What each lens would show from a place and search, plus the two
1092
+ * unscoped totals. The scoped sums and the total read the state index
1093
+ * alone (project, kind, archived, kept: nothing that needs the row), and
1094
+ * the grouped count walks the brand's memberships rather than asking
1095
+ * every shot whether it is in a set.
1096
+ */
1097
+ feedCounts(projectId, f) {
1098
+ const params = { project: projectId };
1099
+ const where = ["n.project_id = @project", ...filterSql({ ...f, lens: void 0 }, params, false)];
1100
+ const scoped = db.prepare(
1101
+ `SELECT coalesce(sum(n.archived = 0), 0) AS live, coalesce(sum(n.archived = 0 AND n.kept = 1), 0) AS kept,
1102
+ coalesce(sum(n.archived = 1), 0) AS archived
1103
+ FROM nodes n WHERE ${where.join(" AND ")}`
1104
+ ).get(params);
1105
+ const totals = db.prepare(
1106
+ `SELECT count(*) AS total, coalesce(sum(n.archived = 0), 0) AS live
1107
+ FROM nodes n WHERE n.project_id = ? AND n.kind != 'root'`
1108
+ ).get(projectId);
1109
+ const grouped = db.prepare(
1110
+ `SELECT count(DISTINCT sn.node_id) AS c
1111
+ FROM sets s
1112
+ CROSS JOIN set_nodes sn ON sn.set_id = s.id
1113
+ CROSS JOIN nodes n ON n.id = sn.node_id
1114
+ WHERE s.brand_id = (SELECT brand_id FROM projects WHERE id = ?)
1115
+ AND n.project_id = ? AND n.archived = 0`
1116
+ ).get(projectId, projectId).c;
1117
+ return {
1118
+ total: totals.total,
1119
+ all: scoped.live,
1120
+ keepers: scoped.kept,
1121
+ archived: scoped.archived,
1122
+ ungrouped: totals.live - grouped
1123
+ };
1124
+ },
1125
+ /**
1126
+ * Where one shot sits in its tree, from the parent index: its ancestors
1127
+ * up to (never including) the root, the siblings around it, and what
1128
+ * hangs off it. Archived versions stay in the strip, as they did when the
1129
+ * overlay walked the whole workspace.
1130
+ *
1131
+ * The siblings are a window: this shot, and up to twenty-five on either
1132
+ * side in filing order. A top-level shot's siblings are every top-level
1133
+ * shot in the brand, and the whole list was eighteen megabytes on a
1134
+ * brand of twenty thousand; the overlay only ever steps to a neighbour,
1135
+ * and each step asks again, so the window re-centres as it goes.
1136
+ */
1137
+ lineageOf(id) {
1138
+ const node = this.getFeedNode(id);
1139
+ if (!node) return null;
1140
+ if (node.kind === "root") return { ancestors: [], siblings: [], children: [] };
1141
+ const ancestors = [];
1142
+ let cur = node.parentId ? this.getFeedNode(node.parentId) : null;
1143
+ for (let hops = 0; cur && cur.kind !== "root" && hops < 64; hops++) {
1144
+ ancestors.unshift(cur);
1145
+ cur = cur.parentId ? this.getFeedNode(cur.parentId) : null;
1146
+ }
1147
+ const before = db.prepare(
1148
+ `SELECT count(*) AS c FROM nodes n
1149
+ WHERE n.parent_id IS ? AND (n.created_at < ? OR (n.created_at = ? AND n.id < ?))`
1150
+ ).get(node.parentId, node.createdAt, node.createdAt, node.id).c;
1151
+ const skip = Math.max(0, before - LINEAGE_SIBLINGS_RADIUS);
1152
+ const take = before - skip + 1 + LINEAGE_SIBLINGS_RADIUS;
1153
+ const siblings = db.prepare(
1154
+ `SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id IS ?
1155
+ ORDER BY n.created_at, n.id LIMIT ? OFFSET ?`
1156
+ ).all(node.parentId, take, skip).map(rowToFeedNode);
1157
+ const children = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id = ? ORDER BY n.created_at, n.id LIMIT ?`).all(node.id, LINEAGE_CHILDREN_MAX).map(rowToFeedNode);
1158
+ return { ancestors, siblings, children };
1159
+ },
1160
+ /** The newest finished shots, newest first, for the rail and the attach panel. */
1161
+ recentShots(projectId, limit = 48) {
1162
+ return db.prepare(
1163
+ `SELECT ${FEED_COLS} FROM nodes n
1164
+ WHERE n.project_id = ? AND n.kind != 'root' AND n.status = 'done' AND n.images != '[]'
1165
+ ORDER BY n.created_at DESC, n.id DESC LIMIT ?`
1166
+ ).all(projectId, Math.max(1, Math.min(FEED_PAGE_MAX, limit))).map(rowToFeedNode);
1167
+ },
1168
+ /** A year of runs by day, counted where the rows are. */
1169
+ usageByDay(brandId) {
1170
+ return db.prepare(
1171
+ `SELECT substr(n.created_at, 1, 10) AS day,
1172
+ coalesce(sum(n.kind = 'generation'), 0) AS generations,
1173
+ coalesce(sum(n.kind = 'edit'), 0) AS edits
1174
+ FROM nodes n JOIN projects p ON p.id = n.project_id
1175
+ WHERE p.brand_id = ? AND n.kind != 'root' AND n.created_at >= date('now', '-400 days')
1176
+ GROUP BY day ORDER BY day`
1177
+ ).all(brandId).map((r) => ({ day: String(r.day), generations: Number(r.generations), edits: Number(r.edits) }));
1178
+ },
1179
+ /** The compiled prompt of the shot that produced an image, for a reference described in words. */
1180
+ promptForImage(brandId, hash) {
1181
+ const r = db.prepare(
1182
+ `SELECT n.prompt FROM nodes n JOIN projects p ON p.id = n.project_id
1183
+ WHERE p.brand_id = ? AND n.images LIKE ? ORDER BY n.created_at, n.id LIMIT 1`
1184
+ ).get(brandId, `%"${hash}"%`);
1185
+ return r?.prompt ?? null;
823
1186
  },
824
1187
  /**
825
1188
  * Every piece of work a brand has in flight, plus whatever finished lately,
@@ -832,21 +1195,26 @@ function createStore(db) {
832
1195
  * ISO string is a silent, timezone-shaped mis-filter.
833
1196
  */
834
1197
  recentActivity(brandId, limit = 60) {
835
- const rows = db.prepare(
836
- `SELECT n.*, (
1198
+ const cols = `${FEED_COLS}, (
837
1199
  SELECT group_concat(s.name, char(31))
838
1200
  FROM set_nodes sn JOIN sets s ON s.id = sn.set_id
839
1201
  WHERE sn.node_id = n.id
840
- ) AS set_names
841
- FROM nodes n JOIN projects p ON p.id = n.project_id
842
- WHERE p.brand_id = ?
843
- AND n.kind != 'root'
844
- AND (n.status = 'running' OR n.created_at >= datetime('now', '-2 days'))
845
- ORDER BY n.created_at DESC, n.id DESC
846
- LIMIT ?`
847
- ).all(brandId, limit);
1202
+ ) AS set_names`;
1203
+ const inBrand = "n.project_id IN (SELECT id FROM projects WHERE brand_id = @brand)";
1204
+ const rows = db.prepare(
1205
+ `SELECT * FROM (
1206
+ SELECT ${cols} FROM nodes n
1207
+ WHERE ${inBrand} AND n.kind != 'root' AND n.status = 'running'
1208
+ UNION ALL
1209
+ SELECT ${cols} FROM nodes n
1210
+ WHERE ${inBrand} AND n.kind != 'root' AND n.status != 'running'
1211
+ AND n.created_at >= datetime('now', '-2 days')
1212
+ )
1213
+ ORDER BY created_at DESC, id DESC
1214
+ LIMIT @limit`
1215
+ ).all({ brand: brandId, limit });
848
1216
  return rows.map((r) => ({
849
- ...rowToNode(r),
1217
+ ...rowToFeedNode(r),
850
1218
  setNames: r.set_names ? String(r.set_names).split(SET_NAME_SEP) : []
851
1219
  }));
852
1220
  },
@@ -969,6 +1337,56 @@ var productById = (db, id) => {
969
1337
  var productsFor = (db, brandId) => db.prepare(
970
1338
  "SELECT * FROM catalog_products WHERE brand_id=? AND status!='unavailable' ORDER BY title COLLATE NOCASE"
971
1339
  ).all(brandId).map(rowProduct);
1340
+ var rowVariant = (r) => ({
1341
+ id: r.id,
1342
+ productId: r.product_id,
1343
+ externalKey: r.external_key,
1344
+ title: r.title,
1345
+ sku: r.sku,
1346
+ price: r.price,
1347
+ compareAtPrice: r.compare_at_price,
1348
+ currency: r.currency,
1349
+ available: r.available == null ? null : !!r.available,
1350
+ options: JSON.parse(r.options || "{}")
1351
+ });
1352
+ var rowImage = (r) => ({
1353
+ id: r.id,
1354
+ productId: r.product_id,
1355
+ sourceUrl: r.source_url,
1356
+ assetRef: r.asset_ref,
1357
+ width: r.width,
1358
+ height: r.height,
1359
+ position: r.position,
1360
+ alt: r.alt,
1361
+ angle: r.angle ?? null,
1362
+ excluded: !!r.excluded
1363
+ });
1364
+ var variantsForBrand = (db, brandId) => {
1365
+ const out = /* @__PURE__ */ new Map();
1366
+ const rows = db.prepare(
1367
+ `SELECT v.* FROM catalog_variants v JOIN catalog_products p ON p.id = v.product_id
1368
+ WHERE p.brand_id=? AND p.status!='unavailable' ORDER BY v.product_id, v.rowid`
1369
+ ).all(brandId);
1370
+ for (const r of rows) {
1371
+ const list2 = out.get(r.product_id) ?? [];
1372
+ list2.push(rowVariant(r));
1373
+ out.set(r.product_id, list2);
1374
+ }
1375
+ return out;
1376
+ };
1377
+ var imagesForBrand = (db, brandId) => {
1378
+ const out = /* @__PURE__ */ new Map();
1379
+ const rows = db.prepare(
1380
+ `SELECT i.* FROM catalog_images i JOIN catalog_products p ON p.id = i.product_id
1381
+ WHERE p.brand_id=? AND p.status!='unavailable' ORDER BY i.product_id, i.position`
1382
+ ).all(brandId);
1383
+ for (const r of rows) {
1384
+ const list2 = out.get(r.product_id) ?? [];
1385
+ list2.push(rowImage(r));
1386
+ out.set(r.product_id, list2);
1387
+ }
1388
+ return out;
1389
+ };
972
1390
  var variantsFor = (db, productId) => db.prepare("SELECT * FROM catalog_variants WHERE product_id=?").all(productId).map((r) => ({
973
1391
  id: r.id,
974
1392
  productId: r.product_id,
@@ -1378,8 +1796,10 @@ function libraryMethods(db) {
1378
1796
  alt: s.alt ?? s.angle ?? null
1379
1797
  }))
1380
1798
  }));
1799
+ const imagesBy = imagesForBrand(db, brandId);
1800
+ const variantsBy = variantsForBrand(db, brandId);
1381
1801
  const catalog = productsFor(db, brandId).map((p) => {
1382
- const images = imagesFor(db, p.id);
1802
+ const images = imagesBy.get(p.id) ?? [];
1383
1803
  const shot = (i) => ({
1384
1804
  file: i.assetRef,
1385
1805
  locked: true,
@@ -1411,7 +1831,7 @@ function libraryMethods(db) {
1411
1831
  status: p.status,
1412
1832
  shots,
1413
1833
  hiddenShots,
1414
- variants: variantsFor(db, p.id)
1834
+ variants: variantsBy.get(p.id) ?? []
1415
1835
  };
1416
1836
  });
1417
1837
  return [...manual, ...catalog];
@@ -1630,7 +2050,7 @@ function createOpenRouterEngine(opts) {
1630
2050
  const count = "count" in req ? req.count : 1;
1631
2051
  return count * perImageUsd;
1632
2052
  },
1633
- async generate(req, signal) {
2053
+ async generate(req, signal, onImage) {
1634
2054
  const key = requireKey();
1635
2055
  const roles = req.referenceRoles ?? [];
1636
2056
  const refs = req.referenceImages ?? [];
@@ -1666,7 +2086,9 @@ function createOpenRouterEngine(opts) {
1666
2086
  signal
1667
2087
  );
1668
2088
  raws.push(json);
1669
- for (const buf of extractImages(json)) hashes.push(opts.saveImage(buf));
2089
+ const own = extractImages(json).map((buf) => opts.saveImage(buf));
2090
+ hashes.push(...own);
2091
+ if (own[0]) onImage?.(i, own[0]);
1670
2092
  if (typeof json?.usage?.cost === "number") {
1671
2093
  reportedCost += json.usage.cost;
1672
2094
  sawReportedCost = true;
@@ -2863,7 +3285,7 @@ function createCodexEngine(opts) {
2863
3285
  async costEstimate() {
2864
3286
  return 0;
2865
3287
  },
2866
- async generate(req, signal) {
3288
+ async generate(req, signal, onImage) {
2867
3289
  const count = Math.max(1, req.count);
2868
3290
  const refs = req.referenceImages ?? [];
2869
3291
  const roles = req.referenceRoles ?? refs.map(() => "reference");
@@ -2902,6 +3324,7 @@ function createCodexEngine(opts) {
2902
3324
  const i = next++;
2903
3325
  try {
2904
3326
  results[i] = await jobs[i]();
3327
+ if (results[i][0]) onImage?.(i, results[i][0]);
2905
3328
  } catch (err) {
2906
3329
  if (signal?.aborted && signal.reason !== BUDGET_EXHAUSTED) throw err;
2907
3330
  results[i] = [];
@@ -3041,7 +3464,30 @@ function createEngineRegistry(core, extra = []) {
3041
3464
  codexRunner
3042
3465
  };
3043
3466
  }
3044
- function createDemoEngine(saveImage) {
3467
+ function demoOptionsFromEnv(env) {
3468
+ const out = {};
3469
+ const stagger = Number(env.SCENRI_DEMO_STAGGER_MS);
3470
+ if (env.SCENRI_DEMO_STAGGER_MS && Number.isFinite(stagger) && stagger > 0) out.staggerMs = stagger;
3471
+ if (env.SCENRI_DEMO_ORDER === "reverse") out.order = "reverse";
3472
+ const fail = Number(env.SCENRI_DEMO_FAIL_SLOT);
3473
+ if (env.SCENRI_DEMO_FAIL_SLOT && Number.isInteger(fail) && fail >= 0) out.failSlot = fail;
3474
+ return out;
3475
+ }
3476
+ function sleep2(ms, signal) {
3477
+ return new Promise((resolve) => {
3478
+ if (signal?.aborted) return resolve();
3479
+ const onAbort = () => {
3480
+ clearTimeout(timer);
3481
+ resolve();
3482
+ };
3483
+ const timer = setTimeout(() => {
3484
+ signal?.removeEventListener("abort", onAbort);
3485
+ resolve();
3486
+ }, ms);
3487
+ signal?.addEventListener("abort", onAbort, { once: true });
3488
+ });
3489
+ }
3490
+ function createDemoEngine(saveImage, opts = {}) {
3045
3491
  const paletteOf = (req) => {
3046
3492
  const p = req.brand?.brand?.palette;
3047
3493
  const hexes = [p?.primary?.hex, p?.secondary?.hex, ...(p?.accent ?? []).map((a) => a?.hex)].filter(
@@ -3062,7 +3508,7 @@ function createDemoEngine(saveImage) {
3062
3508
  <text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
3063
3509
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
3064
3510
  </svg>`;
3065
- return sharp20(Buffer.from(svg)).png().toBuffer();
3511
+ return sharp21(Buffer.from(svg)).png().toBuffer();
3066
3512
  }
3067
3513
  return {
3068
3514
  capabilities() {
@@ -3087,13 +3533,31 @@ function createDemoEngine(saveImage) {
3087
3533
  async costEstimate() {
3088
3534
  return 0;
3089
3535
  },
3090
- async generate(req) {
3536
+ async generate(req, signal, onImage) {
3091
3537
  const colors = paletteOf(req);
3092
- const images = [];
3093
- for (let i = 0; i < Math.max(1, req.count); i++) {
3094
- images.push(saveImage(await render(colors, req.prompt, req.width, req.height, i + req.prompt.length)));
3095
- }
3096
- return { images, costUsd: 0 };
3538
+ const count = Math.max(1, req.count);
3539
+ const slots = Array.from({ length: count }, (_, i) => i);
3540
+ if (opts.order === "reverse") slots.reverse();
3541
+ const landed = /* @__PURE__ */ new Map();
3542
+ const failures = [];
3543
+ for (const [position, slot] of slots.entries()) {
3544
+ if (position > 0 && opts.staggerMs) await sleep2(opts.staggerMs, signal);
3545
+ if (signal?.aborted) {
3546
+ if (signal.reason === BUDGET_EXHAUSTED) break;
3547
+ throw Object.assign(new Error("generation cancelled"), { name: "AbortError" });
3548
+ }
3549
+ if (slot === opts.failSlot) {
3550
+ failures.push(`demo: slot ${slot + 1} refused`);
3551
+ continue;
3552
+ }
3553
+ const hash = saveImage(await render(colors, req.prompt, req.width, req.height, slot + req.prompt.length));
3554
+ landed.set(slot, hash);
3555
+ onImage?.(slot, hash);
3556
+ }
3557
+ const done = [...landed.keys()].sort((a, b) => a - b);
3558
+ const images = done.map((slot) => landed.get(slot));
3559
+ if (done.length === count) return { images, costUsd: 0 };
3560
+ return { images, costUsd: 0, raw: { requested: count, variantIndexes: done, partialFailures: failures } };
3097
3561
  },
3098
3562
  async edit(req) {
3099
3563
  const colors = paletteOf(req);
@@ -3235,7 +3699,7 @@ var resolvedRefs = /* @__PURE__ */ new Map();
3235
3699
  async function refHash(core, path) {
3236
3700
  const hit = resolvedRefs.get(path);
3237
3701
  if (hit && core.images.has(hit)) return hit;
3238
- const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
3702
+ const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
3239
3703
  resolvedRefs.set(path, hash);
3240
3704
  return hash;
3241
3705
  }
@@ -3335,14 +3799,21 @@ function demoProductFacetsOf(demoProducts) {
3335
3799
  function demoProductRefPath(templatesRoot, id, angle) {
3336
3800
  return contentFile(templatesRoot, "previews", "demo-products", id, `${angle}.jpg`);
3337
3801
  }
3802
+ var resolvedRefs2 = /* @__PURE__ */ new Map();
3803
+ async function refHash2(core, path) {
3804
+ const hit = resolvedRefs2.get(path);
3805
+ if (hit && core.images.has(hit)) return hit;
3806
+ const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
3807
+ resolvedRefs2.set(path, hash);
3808
+ return hash;
3809
+ }
3338
3810
  async function resolveDemoProductImages(core, templatesRoot, product) {
3339
3811
  const angles = PRODUCT_ANGLES_BY_CATEGORY[product.category] ?? PRODUCT_ANGLES_BY_CATEGORY.other;
3340
3812
  const shots = [];
3341
3813
  for (const angle of angles) {
3342
3814
  const path = demoProductRefPath(templatesRoot, product.id, angle);
3343
3815
  if (!existsSync(path)) continue;
3344
- const png = await sharp20(readFileSync(path)).png().toBuffer();
3345
- const hash = core.images.save(png);
3816
+ const hash = await refHash2(core, path);
3346
3817
  shots.push({ file: `asset:${hash}`, angle, locked: true });
3347
3818
  }
3348
3819
  if (!shots.length) return null;
@@ -3398,6 +3869,15 @@ function defaultDemoProductsDir() {
3398
3869
  }
3399
3870
 
3400
3871
  // src/attachmentBudget.ts
3872
+ var SEAT_TIER = {
3873
+ brand: 0,
3874
+ reference: 0,
3875
+ product: 0,
3876
+ character: 0,
3877
+ scene: 0,
3878
+ composition: 1,
3879
+ style: 2
3880
+ };
3401
3881
  var ROLE_PRIORITY = {
3402
3882
  product: 0,
3403
3883
  character: 1,
@@ -3420,11 +3900,8 @@ function allocateAttachments(attachments, cap2) {
3420
3900
  kept.add(x.i);
3421
3901
  keptGroups.add(groupOf(x.a));
3422
3902
  };
3423
- for (const x of legacyOrder) {
3424
- if (kept.size >= max) break;
3425
- if (x.a.essential) admit(x);
3426
- }
3427
- for (const x of legacyOrder) {
3903
+ const seatOrder = [...indexed].sort((x, y) => SEAT_TIER[x.a.role] - SEAT_TIER[y.a.role] || x.i - y.i);
3904
+ for (const x of seatOrder) {
3428
3905
  if (kept.size >= max) break;
3429
3906
  if (!kept.has(x.i) && !keptGroups.has(groupOf(x.a))) admit(x);
3430
3907
  }
@@ -3449,7 +3926,13 @@ function allocateAttachments(attachments, cap2) {
3449
3926
  }
3450
3927
  return {
3451
3928
  kept: legacyOrder.filter((x) => kept.has(x.i)).map((x) => x.a),
3452
- dropped: legacyOrder.filter((x) => !kept.has(x.i)).map((x) => x.a)
3929
+ dropped: legacyOrder.filter((x) => !kept.has(x.i)).map((x) => x.a),
3930
+ // The same images in the order the brief placed them, for a caller that
3931
+ // will allocate again: `kept` is re-sorted by role for the consumers
3932
+ // below, and feeding that back into a second, tighter allocation put
3933
+ // every product ahead of every face on a refinement whatever the line
3934
+ // said.
3935
+ seated: seatOrder.filter((x) => kept.has(x.i)).map((x) => x.a)
3453
3936
  };
3454
3937
  }
3455
3938
  function mergeEditAttachments(own, inherited, cap2) {
@@ -3480,7 +3963,7 @@ var assetHash = (ref) => {
3480
3963
  };
3481
3964
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3482
3965
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3483
- var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
3966
+ var toPng = (buf) => sharp21(buf).rotate().png().toBuffer();
3484
3967
  var COST_PROBE = {
3485
3968
  prompt: "",
3486
3969
  brand: { brand: {}, assetPaths: {} },
@@ -3493,11 +3976,11 @@ var MARK_MIN_EDGE = 1024;
3493
3976
  var MARK_TINY_EDGE = 256;
3494
3977
  var MARK_WARN_EDGE = 512;
3495
3978
  var toMarkPng = async (buf) => {
3496
- const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3497
- const meta = await sharp20(out).metadata();
3979
+ const out = await sharp21(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3980
+ const meta = await sharp21(out).metadata();
3498
3981
  const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3499
3982
  if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3500
- return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3983
+ return sharp21(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3501
3984
  }
3502
3985
  return out;
3503
3986
  };
@@ -3508,9 +3991,9 @@ async function capReferenceEdge(core, path, maxEdge) {
3508
3991
  if (hit) return hit;
3509
3992
  let out = path;
3510
3993
  try {
3511
- const meta = await sharp20(path).metadata();
3994
+ const meta = await sharp21(path).metadata();
3512
3995
  if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3513
- const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3996
+ const buf = await sharp21(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3514
3997
  out = core.images.pathFor(core.images.save(buf));
3515
3998
  }
3516
3999
  } catch {
@@ -3614,12 +4097,19 @@ function shotSpecifiesCamera(text) {
3614
4097
  text
3615
4098
  );
3616
4099
  }
4100
+ function namesAreNotLetteringDirective() {
4101
+ return "The names in this brief identify what to show and are never text to render: no caption, label, signage, engraving or lettering spells a product name or a person's name, in any language or script, anywhere in the picture. Printing that is part of a product's own packaging stays exactly as photographed, and nothing else spells a name unless the direction above explicitly asks for it to be written.";
4102
+ }
3617
4103
  function sceneFigureDirectives(opts) {
3618
4104
  const figure = opts.figure.trim().replace(/[.\s]+$/, "");
3619
4105
  if (!figure) return [];
3620
4106
  const treatment = (opts.treatment ?? "").trim().replace(/[.\s]+$/, "");
3621
4107
  const out = [];
3622
- if (opts.hasPerson) {
4108
+ if (opts.hasPerson && (opts.people ?? 1) > 1) {
4109
+ out.push(
4110
+ `This world is built around one figure: ${figure}. The attached presenters share that role: the first named presenter takes the figure position and the others stand with them in the same frame, every one of them clearly visible. Any person the scene direction describes IS one of the attached presenters and never an extra person, and each identity comes from their own attached photograph alone, never from anything the scene direction says about a body.`
4111
+ );
4112
+ } else if (opts.hasPerson) {
3623
4113
  out.push(
3624
4114
  `This world is built around one figure: ${figure}. The attached presenter is that figure. Any person the scene direction describes IS the presenter and never a second person, and their identity comes from their own attached photograph alone, never from anything the scene direction says about a body.`
3625
4115
  );
@@ -3740,6 +4230,8 @@ function validateBrief(brief) {
3740
4230
  case "ref":
3741
4231
  case "mark":
3742
4232
  if (!str4(t.imageHash)) errors.push(`${at}.imageHash must be a non-empty string`);
4233
+ if (t.t === "ref" && t.label !== void 0 && typeof t.label !== "string")
4234
+ errors.push(`${at}.label must be a string when present`);
3743
4235
  break;
3744
4236
  case "format":
3745
4237
  if (!Number.isFinite(t.w) || !Number.isFinite(t.h) || Number(t.w) <= 0 || Number(t.h) <= 0)
@@ -3766,6 +4258,7 @@ function compileBrief(brief, ctx) {
3766
4258
  const characters = ctx.brand?.characters ?? [];
3767
4259
  const inlineTemplates = [];
3768
4260
  let hasPerson = false;
4261
+ let people = 0;
3769
4262
  let sentence = "";
3770
4263
  const append = (s) => {
3771
4264
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
@@ -3825,6 +4318,7 @@ function compileBrief(brief, ctx) {
3825
4318
  break;
3826
4319
  }
3827
4320
  hasPerson = true;
4321
+ people += 1;
3828
4322
  append(c.promptName ?? c.name);
3829
4323
  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));
3830
4324
  if (cshots.length) {
@@ -3869,8 +4363,10 @@ function compileBrief(brief, ctx) {
3869
4363
  }
3870
4364
  case "color": {
3871
4365
  const hex = tok.hex.toUpperCase();
3872
- append(tok.name ? `${tok.name} (${hex})` : hex);
3873
- otherDirectives.push(`Use ${hex} as a defining color in the composition.`);
4366
+ append(tok.name ? `(brand color ${tok.name} ${hex})` : `(brand color ${hex})`);
4367
+ otherDirectives.push(
4368
+ `Use ${hex} as a defining color in the composition, in surfaces, materials and light, never as lettering.`
4369
+ );
3874
4370
  break;
3875
4371
  }
3876
4372
  case "ref": {
@@ -4007,7 +4503,7 @@ function compileBrief(brief, ctx) {
4007
4503
  }
4008
4504
  }
4009
4505
  const max = ctx.engineCaps.maxReferenceImages;
4010
- const { kept, dropped: budgetDropped } = allocateAttachments(attachments, max);
4506
+ const { kept, dropped: budgetDropped, seated } = allocateAttachments(attachments, max);
4011
4507
  const presentKeys = new Set((ctx.presentAttachments ?? kept).map((a) => `${a.role}:${a.hash}`));
4012
4508
  const resolveDirective = (d) => {
4013
4509
  if (typeof d === "string") return d;
@@ -4019,6 +4515,24 @@ function compileBrief(brief, ctx) {
4019
4515
  }
4020
4516
  return presentKeys.has(`${d.role}:${d.hash}`) ? d.text : null;
4021
4517
  };
4518
+ const absentDirectives = [];
4519
+ const absentSeen = /* @__PURE__ */ new Set();
4520
+ for (const a of attachments) {
4521
+ const key = `${a.role}:${a.hash}`;
4522
+ if (presentKeys.has(key) || absentSeen.has(key)) continue;
4523
+ if (a.role === "reference") {
4524
+ absentSeen.add(key);
4525
+ const words = ctx.wordsFor?.(a.hash) ?? null;
4526
+ absentDirectives.push(
4527
+ words ? `A reference shot was not attached this time; it showed ${words}. Match that composition, lighting and treatment.` : "A reference image was attached but not sent this time."
4528
+ );
4529
+ } else if (a.role === "brand") {
4530
+ absentSeen.add(key);
4531
+ absentDirectives.push(
4532
+ `The brand mark "${a.label}" was not attached this time; keep every branded surface plain and do not invent a logo.`
4533
+ );
4534
+ }
4535
+ }
4022
4536
  const guard = scene ? sceneGuardDirectives({
4023
4537
  hasProduct: !!productId,
4024
4538
  hasPerson,
@@ -4035,10 +4549,12 @@ function compileBrief(brief, ctx) {
4035
4549
  "Any earlier instruction that bans props, hands, people, or a presenter from the frame is a solo-packshot rule for this product and does not apply to this shot: the attached presenter is deliberate and must appear as directed.",
4036
4550
  productHandlingDirective()
4037
4551
  ] : [];
4552
+ const nameDirectives = productId || hasPerson ? [namesAreNotLetteringDirective()] : [];
4038
4553
  const figureDirectives = scene?.figure ? sceneFigureDirectives({
4039
4554
  figure: scene.figure,
4040
4555
  treatment: scene.figureTreatment,
4041
4556
  hasPerson,
4557
+ people,
4042
4558
  // The treatment's fictional-brands rule needs to know a real mark is
4043
4559
  // deliberately in play - and only one that actually rides counts,
4044
4560
  // same honesty rule as the photo guard above.
@@ -4067,12 +4583,16 @@ function compileBrief(brief, ctx) {
4067
4583
  }) ? [garmentDisplayDirective()] : [];
4068
4584
  const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
4069
4585
  const allDirectives = [
4586
+ // First, beside the sentence that names things: the model reads the names
4587
+ // and this line in one breath, before any spec repeats them.
4588
+ ...nameDirectives,
4070
4589
  ...productDirectives,
4071
4590
  ...personDirectives,
4072
4591
  ...pairDirectives,
4073
4592
  ...figureDirectives,
4074
4593
  ...closeUpDirectives,
4075
4594
  ...otherDirectives,
4595
+ ...absentDirectives,
4076
4596
  ...cameraDirectives,
4077
4597
  ...apparelUnworn,
4078
4598
  ...brandLines,
@@ -4082,15 +4602,14 @@ function compileBrief(brief, ctx) {
4082
4602
  ];
4083
4603
  const spoken = dedupe(allDirectives.map(resolveDirective).filter((s) => s !== null));
4084
4604
  if (spoken.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${spoken.join(" ")}`;
4085
- if (budgetDropped.some((d) => d.role !== "scene")) {
4605
+ if (max === 0 && budgetDropped.some((d) => d.role !== "scene")) {
4086
4606
  const keptLabels = new Set(kept.map((a) => a.label));
4087
4607
  const names = [...new Set(budgetDropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
4088
4608
  (l) => !keptLabels.has(l)
4089
4609
  );
4090
4610
  if (names.length) {
4091
- const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
4092
4611
  warnings.push(
4093
- `${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out \u2014 ${ctx.engineCaps.displayName} ${reads}.`
4612
+ `${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out \u2014 ${ctx.engineCaps.displayName} reads no reference images.`
4094
4613
  );
4095
4614
  }
4096
4615
  }
@@ -4103,11 +4622,28 @@ function compileBrief(brief, ctx) {
4103
4622
  width,
4104
4623
  height,
4105
4624
  attachments: kept,
4625
+ seated,
4106
4626
  warnings,
4107
4627
  productId
4108
4628
  };
4109
4629
  }
4110
4630
  var dedupe = (xs) => [...new Set(xs)];
4631
+
4632
+ // src/shotWords.ts
4633
+ function shotWords(prompt, max = 160) {
4634
+ if (!prompt) return null;
4635
+ const head = prompt.split(/\. (?=[A-Z])/)[0]?.trim() ?? "";
4636
+ if (!head) return null;
4637
+ const clipped = head.length > max ? `${head.slice(0, max).replace(/\s+\S*$/, "")}\u2026` : head;
4638
+ return clipped.replace(/[.\s]+$/, "");
4639
+ }
4640
+ function shotWordsFor(core, brandId) {
4641
+ const byHash = /* @__PURE__ */ new Map();
4642
+ return (hash) => {
4643
+ if (!byHash.has(hash)) byHash.set(hash, shotWords(core.store.promptForImage(brandId, hash)));
4644
+ return byHash.get(hash) ?? null;
4645
+ };
4646
+ }
4111
4647
  var DEFAULT_CONTENT_URL = "https://github.com/tonygorb/scenri/releases/download/content-latest/scenri-content.zip";
4112
4648
  var TIMEOUT_MS = 10 * 60 * 1e3;
4113
4649
  function resolveContentUrl(env = process.env, override) {
@@ -5034,7 +5570,7 @@ function dedupeProducts(products) {
5034
5570
 
5035
5571
  // ../catalog/src/http/fetch.ts
5036
5572
  var USER_AGENT = "scenri-catalog/0.1 (+https://scenri.co)";
5037
- function sleep2(ms) {
5573
+ function sleep3(ms) {
5038
5574
  return new Promise((r) => setTimeout(r, ms));
5039
5575
  }
5040
5576
  async function httpGet(url, opts = {}) {
@@ -5058,7 +5594,7 @@ async function httpGet(url, opts = {}) {
5058
5594
  }
5059
5595
  });
5060
5596
  if ((res.status === 429 || res.status >= 500) && attempt < retries) {
5061
- await sleep2(400 * 2 ** attempt);
5597
+ await sleep3(400 * 2 ** attempt);
5062
5598
  continue;
5063
5599
  }
5064
5600
  return res;
@@ -5066,7 +5602,7 @@ async function httpGet(url, opts = {}) {
5066
5602
  lastErr = err;
5067
5603
  if (opts.signal?.aborted) throw err;
5068
5604
  if (attempt < retries) {
5069
- await sleep2(400 * 2 ** attempt);
5605
+ await sleep3(400 * 2 ** attempt);
5070
5606
  continue;
5071
5607
  }
5072
5608
  throw err;
@@ -6103,8 +6639,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
6103
6639
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
6104
6640
  return;
6105
6641
  }
6106
- const png = await sharp20(buf).rotate().png().toBuffer();
6107
- const meta = await sharp20(png).metadata();
6642
+ const png = await sharp21(buf).rotate().png().toBuffer();
6643
+ const meta = await sharp21(png).metadata();
6108
6644
  const hash = core.images.save(png);
6109
6645
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
6110
6646
  width: meta.width,
@@ -6581,7 +7117,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6581
7117
  return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
6582
7118
  }
6583
7119
  async function edgeBarGeometry(buf) {
6584
- const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7120
+ const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6585
7121
  const W = info.width;
6586
7122
  const H = info.height;
6587
7123
  const scan = (len, cross, at) => {
@@ -6635,7 +7171,7 @@ async function trimEdgeBars(core, hash) {
6635
7171
  const width = g.right - g.left + 1;
6636
7172
  const height = g.bottom - g.top + 1;
6637
7173
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
6638
- const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
7174
+ const png = await sharp21(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
6639
7175
  return core.images.save(png);
6640
7176
  } catch {
6641
7177
  return hash;
@@ -6706,7 +7242,7 @@ async function identityCrop(core, hash) {
6706
7242
  if (!out) return void 0;
6707
7243
  try {
6708
7244
  const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
6709
- const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
7245
+ const png = await sharp21(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
6710
7246
  const scaled = core.images.save(png);
6711
7247
  identityCrops.set(hash, scaled);
6712
7248
  return scaled;
@@ -6735,12 +7271,12 @@ async function brandJsonWithIdentityCrops(core, json, characterIds) {
6735
7271
  return changed ? { ...json, characters } : json;
6736
7272
  }
6737
7273
  async function figureBox(buf) {
6738
- const meta = await sharp20(buf).metadata();
7274
+ const meta = await sharp21(buf).metadata();
6739
7275
  const W = meta.width ?? 0;
6740
7276
  const H = meta.height ?? 0;
6741
7277
  if (!W || !H) return null;
6742
7278
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
6743
- const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
7279
+ const { info } = await sharp21(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6744
7280
  const left = Math.abs(info.trimOffsetLeft ?? 0);
6745
7281
  const top = Math.abs(info.trimOffsetTop ?? 0);
6746
7282
  const width = info.width ?? 0;
@@ -6773,13 +7309,13 @@ async function smartCover(core, hash, box) {
6773
7309
  if (!hash || !core.images.has(hash)) return void 0;
6774
7310
  try {
6775
7311
  const buf = core.images.read(hash);
6776
- const meta = await sharp20(buf).metadata();
7312
+ const meta = await sharp21(buf).metadata();
6777
7313
  const w = meta.width ?? 0;
6778
7314
  const h = meta.height ?? 0;
6779
7315
  if (!w || !h) return void 0;
6780
7316
  const raw = box(w, h);
6781
7317
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
6782
- const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
7318
+ const png = await sharp21(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
6783
7319
  return core.images.save(png);
6784
7320
  } catch {
6785
7321
  return void 0;
@@ -6788,11 +7324,11 @@ async function smartCover(core, hash, box) {
6788
7324
  async function crop(core, hash, region, cap2) {
6789
7325
  if (!hash || !core.images.has(hash)) return void 0;
6790
7326
  try {
6791
- const meta = await sharp20(core.images.read(hash)).metadata();
7327
+ const meta = await sharp21(core.images.read(hash)).metadata();
6792
7328
  const w = meta.width ?? 0;
6793
7329
  const h = meta.height ?? 0;
6794
7330
  if (!w || !h) return void 0;
6795
- let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
7331
+ let pipeline = sharp21(core.images.read(hash)).extract(region(w, h));
6796
7332
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
6797
7333
  const png = await pipeline.png().toBuffer();
6798
7334
  return core.images.save(png);
@@ -7139,7 +7675,7 @@ var fromLab = (l, a, bb) => {
7139
7675
  return [clamp(R), clamp(G), clamp(B)];
7140
7676
  };
7141
7677
  var rawAt = async (png, edge) => {
7142
- let img = sharp20(png);
7678
+ let img = sharp21(png);
7143
7679
  if (edge) img = img.resize(edge, edge, { fit: "fill" });
7144
7680
  const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
7145
7681
  return { data, width: info.width, height: info.height };
@@ -7202,7 +7738,7 @@ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
7202
7738
  if (residual > GRADE_GATE_MEAN_DELTA) return null;
7203
7739
  const full = await rawAt(originalPng);
7204
7740
  applyAffine(full, T);
7205
- const image = await sharp20(full.data, {
7741
+ const image = await sharp21(full.data, {
7206
7742
  raw: { width: full.width, height: full.height, channels: 3 }
7207
7743
  }).png().toBuffer();
7208
7744
  return { image, residual };
@@ -7376,7 +7912,7 @@ function fitExpandToBudget(plan, source, pixelBudget) {
7376
7912
  }
7377
7913
  async function attentionCropOrigin(srcBuf, source, plan) {
7378
7914
  try {
7379
- const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7915
+ const { info } = await sharp21(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7380
7916
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
7381
7917
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
7382
7918
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -7529,23 +8065,23 @@ function relax(grid, seam, fixedSweeps) {
7529
8065
 
7530
8066
  // src/expand.ts
7531
8067
  async function expandCanvas(source, plan) {
7532
- const bed = await sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
7533
- return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8068
+ const bed = await sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
8069
+ return sharp21(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
7534
8070
  }
7535
8071
  async function compositeExpand(engineImage, source, plan) {
7536
- const meta = await sharp20(engineImage).metadata();
8072
+ const meta = await sharp21(engineImage).metadata();
7537
8073
  const want = plan.width / plan.height;
7538
8074
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
7539
8075
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
7540
8076
  const aligned = sameOrientation;
7541
8077
  const exact = meta.width === plan.width && meta.height === plan.height;
7542
- const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
8078
+ const surround = aligned ? exact ? engineImage : await sharp21(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
7543
8079
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
7544
- const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8080
+ const image = await sharp21(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
7545
8081
  return { image, aligned };
7546
8082
  }
7547
8083
  async function matchMarginsToSeam(surround, source, plan) {
7548
- const src = await sharp20(source).metadata();
8084
+ const src = await sharp21(source).metadata();
7549
8085
  if (!src.width || !src.height) return surround;
7550
8086
  const SW = src.width;
7551
8087
  const SH = src.height;
@@ -7592,8 +8128,8 @@ var MAX_CORRECTION = 60;
7592
8128
  async function reconcile(surround, source, side, axis) {
7593
8129
  const { margin } = side;
7594
8130
  if (margin.width < 1 || margin.height < 1) return surround;
7595
- const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
7596
- const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
8131
+ const marginRaw = await sharp21(surround).extract(margin).removeAlpha().raw().toBuffer();
8132
+ const edgeRaw = await sharp21(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
7597
8133
  const W = margin.width;
7598
8134
  const H = margin.height;
7599
8135
  const along = axis === "width" ? H : W;
@@ -7647,11 +8183,11 @@ async function reconcile(surround, source, side, axis) {
7647
8183
  }
7648
8184
  }
7649
8185
  }
7650
- const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
7651
- return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
8186
+ const patch2 = await sharp21(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
8187
+ return sharp21(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
7652
8188
  }
7653
8189
  async function expandCanvasBedOnly(source, plan) {
7654
- return sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
8190
+ return sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
7655
8191
  }
7656
8192
  function medianOf(rgb, channel, from, to) {
7657
8193
  const n = to - from;
@@ -7662,17 +8198,17 @@ function medianOf(rgb, channel, from, to) {
7662
8198
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
7663
8199
  }
7664
8200
  async function reframeExpand(engineImage, plan) {
7665
- const meta = await sharp20(engineImage).metadata();
8201
+ const meta = await sharp21(engineImage).metadata();
7666
8202
  if (!(meta.width && meta.height)) return null;
7667
8203
  const want = plan.width / plan.height;
7668
8204
  const got = meta.width / meta.height;
7669
8205
  if (got >= 1 !== want >= 1) return null;
7670
8206
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
7671
8207
  const straight = Math.abs(got - want) / want <= 0.02;
7672
- return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
8208
+ return sharp21(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
7673
8209
  }
7674
8210
  async function seamScore(image, plan, source) {
7675
- const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
8211
+ const { data, info } = await sharp21(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
7676
8212
  const W = info.width;
7677
8213
  const H = info.height;
7678
8214
  const horizontal = plan.axis === "width";
@@ -7705,7 +8241,7 @@ var SEAM_VISIBLE = 2.2;
7705
8241
  var OFFSET = 4;
7706
8242
  var RESIDUAL_VISIBLE = 15;
7707
8243
  async function seamResidual(image, plan, source) {
7708
- const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
8244
+ const { data, info } = await sharp21(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7709
8245
  const W = info.width;
7710
8246
  const H = info.height;
7711
8247
  const ch = info.channels;
@@ -7740,7 +8276,7 @@ var MAX_SHARE = 0.8;
7740
8276
  async function subjectFraction(src, source, axis) {
7741
8277
  try {
7742
8278
  const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
7743
- const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
8279
+ const { info } = await sharp21(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7744
8280
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
7745
8281
  const span = axis === "width" ? source.width : source.height;
7746
8282
  const extent = axis === "width" ? window.width : window.height;
@@ -7763,14 +8299,14 @@ function placeExpand(plan, source, fraction) {
7763
8299
  }
7764
8300
  var NEUTRAL = { r: 128, g: 128, b: 128 };
7765
8301
  async function conditioningCanvas(source, plan, fill = "edge") {
7766
- const meta = await sharp20(source).metadata();
8302
+ const meta = await sharp21(source).metadata();
7767
8303
  const sw = meta.width ?? 0;
7768
8304
  const sh = meta.height ?? 0;
7769
8305
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
7770
8306
  const layers = [];
7771
8307
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
7772
8308
  layers.push({ input: source, left: plan.left, top: plan.top });
7773
- const canvas = sharp20({
8309
+ const canvas = sharp21({
7774
8310
  create: {
7775
8311
  width: plan.width,
7776
8312
  height: plan.height,
@@ -7782,7 +8318,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
7782
8318
  }
7783
8319
  async function edgeMargins(source, plan, size) {
7784
8320
  const out = [];
7785
- const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
8321
+ const strip = async (extract, width, height) => sharp21(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
7786
8322
  if (plan.axis === "width") {
7787
8323
  const before = plan.left;
7788
8324
  const after = plan.width - plan.left - size.width;
@@ -7873,12 +8409,12 @@ async function resolveOutpaintRoute(all, shot) {
7873
8409
  return { engine: shot, method: "reframe", crossed: false };
7874
8410
  }
7875
8411
  async function driftDiff(a, b) {
7876
- const metaA = await sharp20(a).metadata();
7877
- const metaB = await sharp20(b).metadata();
8412
+ const metaA = await sharp21(a).metadata();
8413
+ const metaB = await sharp21(b).metadata();
7878
8414
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
7879
8415
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
7880
8416
  const [rawA, rawB] = await Promise.all(
7881
- [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
8417
+ [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7882
8418
  );
7883
8419
  const out = new PNG({ width, height });
7884
8420
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -7890,11 +8426,11 @@ async function driftDiff(a, b) {
7890
8426
  };
7891
8427
  }
7892
8428
  async function changeMask(a, b, cap2 = 1024) {
7893
- const metaA = await sharp20(a).metadata();
8429
+ const metaA = await sharp21(a).metadata();
7894
8430
  const width = Math.min(metaA.width ?? 1, cap2);
7895
8431
  const height = Math.min(metaA.height ?? 1, cap2);
7896
8432
  const [rawA, rawB] = await Promise.all(
7897
- [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
8433
+ [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7898
8434
  );
7899
8435
  const out = new PNG({ width, height });
7900
8436
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -7943,8 +8479,8 @@ function dilationFor(longEdge) {
7943
8479
  // src/localEdit.ts
7944
8480
  async function preserveOutsideChange(source, edited) {
7945
8481
  try {
7946
- const srcMeta = await sharp20(source).metadata();
7947
- const outMeta = await sharp20(edited).metadata();
8482
+ const srcMeta = await sharp21(source).metadata();
8483
+ const outMeta = await sharp21(edited).metadata();
7948
8484
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
7949
8485
  return { image: edited, outcome: "error", changed: 0 };
7950
8486
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -7954,15 +8490,15 @@ async function preserveOutsideChange(source, edited) {
7954
8490
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
7955
8491
  const r = dilationFor(Math.max(shape.width, shape.height));
7956
8492
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
7957
- const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7958
- const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7959
- const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7960
- const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7961
- const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7962
- const masked = await sharp20(editedRgb, {
8493
+ const spread = await sharp21(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
8494
+ const dilated = await sharp21(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
8495
+ const feathered = await sharp21(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
8496
+ const grown = await sharp21(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
8497
+ const editedRgb = await sharp21(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
8498
+ const masked = await sharp21(editedRgb, {
7963
8499
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
7964
8500
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
7965
- const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
8501
+ const image = await sharp21(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7966
8502
  return { image, outcome: "composited", changed: shape.changed };
7967
8503
  } catch {
7968
8504
  return { image: edited, outcome: "error", changed: 0 };
@@ -8000,7 +8536,7 @@ function registerLogoRoutes(app, deps) {
8000
8536
  const v = validateBrand(json);
8001
8537
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
8002
8538
  const row = core.store.updateBrand(brand.id, json);
8003
- const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
8539
+ const meta = await sharp21(core.images.read(part.hash)).metadata().catch(() => null);
8004
8540
  const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
8005
8541
  return { ...row, logoHash: part.hash, logoEdge };
8006
8542
  });
@@ -8036,11 +8572,6 @@ function registerLogoRoutes(app, deps) {
8036
8572
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
8037
8573
  return core.store.updateBrand(brand.id, json);
8038
8574
  });
8039
- app.get("/api/brands/:id/directives", async (req, reply) => {
8040
- const brand = core.store.getBrand(req.params.id);
8041
- if (!brand) return reply.status(404).send({ error: "brand not found" });
8042
- return { directives: brandRuleDirectives(brand.json) };
8043
- });
8044
8575
  app.delete("/api/brands/:id/logos/:hash", async (req, reply) => {
8045
8576
  const brand = core.store.getBrand(req.params.id);
8046
8577
  if (!brand) return reply.status(404).send({ error: "brand not found" });
@@ -8127,7 +8658,7 @@ async function vibrantColor(input) {
8127
8658
  let data;
8128
8659
  let channels;
8129
8660
  try {
8130
- const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
8661
+ const out = await sharp21(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
8131
8662
  data = out.data;
8132
8663
  channels = out.info.channels;
8133
8664
  } catch {
@@ -8150,7 +8681,7 @@ async function vibrantColor(input) {
8150
8681
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
8151
8682
  if (best.score <= 0) {
8152
8683
  try {
8153
- const { dominant } = await sharp20(input).stats();
8684
+ const { dominant } = await sharp21(input).stats();
8154
8685
  return toHex(dominant.r, dominant.g, dominant.b);
8155
8686
  } catch {
8156
8687
  return null;
@@ -8659,8 +9190,22 @@ function registerShowcaseRoutes(app, deps) {
8659
9190
  }
8660
9191
 
8661
9192
  // src/routes/projects.ts
9193
+ var SORTS = ["newest", "oldest", "cost", "keepers"];
9194
+ var NAMES_TTL_MS = 2e3;
9195
+ var NAMES_MAX = 50;
8662
9196
  function registerProjectRoutes(app, deps) {
8663
9197
  const { core } = deps;
9198
+ const names = /* @__PURE__ */ new Map();
9199
+ const tokenNames = (brand) => {
9200
+ const key = `${brand.id}:${brand.updatedAt}`;
9201
+ const now = Date.now();
9202
+ const hit = names.get(key);
9203
+ if (hit && now - hit.at < NAMES_TTL_MS) return hit.list;
9204
+ const list2 = deps.tokenNames(brand);
9205
+ if (names.size >= NAMES_MAX) names.delete(names.keys().next().value);
9206
+ names.set(key, { at: now, list: list2 });
9207
+ return list2;
9208
+ };
8664
9209
  app.post("/api/projects", async (req, reply) => {
8665
9210
  const { brandId, name } = req.body;
8666
9211
  if (!core.store.getBrand(String(brandId))) return reply.status(404).send({ error: "brand not found" });
@@ -8684,10 +9229,61 @@ function registerProjectRoutes(app, deps) {
8684
9229
  const project = core.store.workspaceFor(brand.id);
8685
9230
  return {
8686
9231
  project,
8687
- nodes: core.store.treeFor(project.id),
9232
+ root: core.store.rootFor(project.id)?.id ?? null,
8688
9233
  sets: core.store.listSets(brand.id),
8689
- membership: core.store.membershipFor(brand.id)
9234
+ membership: core.store.membershipFor(brand.id),
9235
+ recent: core.store.recentShots(project.id, 48)
9236
+ };
9237
+ });
9238
+ app.get("/api/brands/:id/feed", async (req, reply) => {
9239
+ const brand = core.store.getBrand(req.params.id);
9240
+ if (!brand) return reply.status(404).send({ error: "brand not found" });
9241
+ const project = core.store.workspaceFor(brand.id);
9242
+ const qs = req.query ?? {};
9243
+ const lens = qs.lens === "keepers" || qs.lens === "archived" ? qs.lens : "all";
9244
+ const sort = SORTS.includes(qs.sort) ? qs.sort : "newest";
9245
+ const tokens = qs.token ? qs.token.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
9246
+ const q = qs.q ?? "";
9247
+ let terms;
9248
+ if (q.trim()) {
9249
+ const known = tokenNames(brand);
9250
+ const engines = deps.engineNames();
9251
+ terms = searchTerms(q).map((t) => ({
9252
+ ...t,
9253
+ tokenIds: known.filter((n) => termMatches(n.name, t)).map((n) => n.id),
9254
+ engineIds: engines.filter((e) => termMatches(e.name, t)).map((e) => e.id)
9255
+ }));
9256
+ }
9257
+ const filter = {
9258
+ lens,
9259
+ set: qs.set || void 0,
9260
+ ungrouped: qs.ungrouped === "1" || qs.ungrouped === "true",
9261
+ lineage: qs.lineage || void 0,
9262
+ tokens: tokens?.length ? tokens : void 0,
9263
+ terms
8690
9264
  };
9265
+ let page;
9266
+ try {
9267
+ page = core.store.feedPage(project.id, {
9268
+ ...filter,
9269
+ sort,
9270
+ limit: Number(qs.limit) || 60,
9271
+ cursor: qs.cursor || null
9272
+ });
9273
+ } catch (err) {
9274
+ if (/cursor/.test(String(err.message))) return reply.status(400).send({ error: "invalid cursor" });
9275
+ throw err;
9276
+ }
9277
+ return qs.cursor ? page : { ...page, counts: core.store.feedCounts(project.id, filter) };
9278
+ });
9279
+ app.get("/api/nodes/:id/lineage", async (req, reply) => {
9280
+ const lineage = core.store.lineageOf(req.params.id);
9281
+ return lineage ?? reply.status(404).send({ error: "node not found" });
9282
+ });
9283
+ app.get("/api/brands/:id/usage", async (req, reply) => {
9284
+ const brand = core.store.getBrand(req.params.id);
9285
+ if (!brand) return reply.status(404).send({ error: "brand not found" });
9286
+ return { days: core.store.usageByDay(brand.id) };
8691
9287
  });
8692
9288
  app.get("/api/brands/:id/sets", async (req, reply) => {
8693
9289
  const brand = core.store.getBrand(req.params.id);
@@ -8720,13 +9316,13 @@ function registerProjectRoutes(app, deps) {
8720
9316
  const nodeIds = (Array.isArray(raw) ? raw : []).map(String).filter((id) => core.store.getNode(id));
8721
9317
  if (nodeIds.length === 0) return reply.status(400).send({ error: "nodeIds must name at least one shot" });
8722
9318
  core.store.addToSet(set.id, nodeIds);
8723
- return { ok: true, added: nodeIds.length };
9319
+ return { ok: true, added: nodeIds.length, nodeIds: core.store.membersOf(set.id) };
8724
9320
  });
8725
9321
  app.delete("/api/sets/:id/nodes/:nodeId", async (req, reply) => {
8726
9322
  const { id, nodeId } = req.params;
8727
9323
  if (!core.store.getSet(id)) return reply.status(404).send({ error: "set not found" });
8728
9324
  core.store.removeFromSet(id, nodeId);
8729
- return { ok: true };
9325
+ return { ok: true, nodeIds: core.store.membersOf(id) };
8730
9326
  });
8731
9327
  }
8732
9328
 
@@ -8769,7 +9365,7 @@ async function buildExportZip(image, baseName, presetIds) {
8769
9365
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
8770
9366
  if (chosen.length === 0) throw new Error("No valid export presets selected");
8771
9367
  for (const p of chosen) {
8772
- const buf = p.width && p.height ? await sharp20(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
9368
+ const buf = p.width && p.height ? await sharp21(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
8773
9369
  zip.file(`${baseName}-${p.id}.png`, buf);
8774
9370
  }
8775
9371
  return zip.generateAsync({ type: "nodebuffer" });
@@ -8915,23 +9511,136 @@ function readme(json, missing) {
8915
9511
  ${missing} referenced image${missing === 1 ? " was" : "s were"} missing and left out.` : ""
8916
9512
  ].join("\n");
8917
9513
  }
9514
+ var THUMB_WIDTHS = [640, 160];
9515
+ var isThumbWidth = (w) => THUMB_WIDTHS.includes(w);
9516
+ var QUALITY = { 640: 82, 160: 75 };
9517
+ function createThumbStore(core, opts = {}) {
9518
+ const dir = join(core.home, "thumbs");
9519
+ let enabled = true;
9520
+ try {
9521
+ mkdirSync(dir, { recursive: true, mode: 448 });
9522
+ } catch {
9523
+ enabled = false;
9524
+ }
9525
+ const pathFor = (hash, w) => join(dir, `${hash}-w${w}.webp`);
9526
+ const inflight = /* @__PURE__ */ new Map();
9527
+ const failed = /* @__PURE__ */ new Set();
9528
+ const concurrency = Math.max(1, opts.concurrency ?? 2);
9529
+ let active = 0;
9530
+ const waiting = [];
9531
+ const acquire = () => new Promise((resolve) => {
9532
+ if (active < concurrency) {
9533
+ active++;
9534
+ resolve();
9535
+ } else waiting.push(resolve);
9536
+ });
9537
+ const release = () => {
9538
+ const next = waiting.shift();
9539
+ if (next) next();
9540
+ else active--;
9541
+ };
9542
+ async function make(hash, w) {
9543
+ const final = pathFor(hash, w);
9544
+ const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
9545
+ await acquire();
9546
+ try {
9547
+ await sharp21(core.images.pathFor(hash)).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
9548
+ await rename(tmp, final);
9549
+ return final;
9550
+ } catch {
9551
+ await unlink(tmp).catch(() => {
9552
+ });
9553
+ failed.add(`${hash}-w${w}`);
9554
+ return null;
9555
+ } finally {
9556
+ release();
9557
+ }
9558
+ }
9559
+ return {
9560
+ dir,
9561
+ async ensure(hash, w) {
9562
+ if (!enabled || !/^[a-f0-9]{32}$/.test(hash)) return null;
9563
+ const key = `${hash}-w${w}`;
9564
+ if (failed.has(key)) return null;
9565
+ const final = pathFor(hash, w);
9566
+ try {
9567
+ await access(final);
9568
+ return final;
9569
+ } catch {
9570
+ }
9571
+ let job = inflight.get(key);
9572
+ if (!job) {
9573
+ job = make(hash, w).finally(() => inflight.delete(key));
9574
+ inflight.set(key, job);
9575
+ }
9576
+ return job;
9577
+ },
9578
+ warm(hash) {
9579
+ for (const w of THUMB_WIDTHS) void this.ensure(hash, w);
9580
+ },
9581
+ async settle() {
9582
+ await Promise.allSettled([...inflight.values()]);
9583
+ },
9584
+ clear() {
9585
+ failed.clear();
9586
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
9587
+ try {
9588
+ mkdirSync(dir, { recursive: true, mode: 448 });
9589
+ } catch {
9590
+ enabled = false;
9591
+ }
9592
+ },
9593
+ stream: (path) => createReadStream(path)
9594
+ };
9595
+ }
9596
+ async function fileSize(path) {
9597
+ try {
9598
+ const s = await stat(path);
9599
+ return s.isFile() ? s.size : null;
9600
+ } catch {
9601
+ return null;
9602
+ }
9603
+ }
8918
9604
 
8919
9605
  // src/routes/images.ts
9606
+ var IMMUTABLE = "public, max-age=31536000, immutable";
8920
9607
  function registerImageRoutes(app, deps) {
8921
- const { core } = deps;
9608
+ const { core, thumbs } = deps;
8922
9609
  app.get("/api/images/:hash", async (req, reply) => {
8923
9610
  const hash = String(req.params.hash);
8924
- if (!core.images.has(hash)) return reply.status(404).send({ error: "image not found" });
8925
- reply.header("content-type", "image/png").header("cache-control", "public, max-age=31536000, immutable");
8926
- return reply.send(core.images.read(hash));
9611
+ if (!/^[a-f0-9]{32}$/.test(hash)) return reply.status(404).send({ error: "image not found" });
9612
+ const path = core.images.pathFor(hash);
9613
+ const size = await fileSize(path);
9614
+ if (size === null) return reply.status(404).send({ error: "image not found" });
9615
+ const etag = `"${hash}"`;
9616
+ reply.header("cache-control", IMMUTABLE).header("etag", etag);
9617
+ if (req.headers["if-none-match"] === etag) return reply.status(304).send();
9618
+ reply.header("content-type", "image/png").header("content-length", String(size));
9619
+ return reply.send(createReadStream(path));
9620
+ });
9621
+ app.get("/api/images/:hash/thumb", async (req, reply) => {
9622
+ const hash = String(req.params.hash);
9623
+ const w = Number(req.query?.w);
9624
+ if (!isThumbWidth(w)) return reply.status(400).send({ error: "w must be 640 or 160" });
9625
+ if (!/^[a-f0-9]{32}$/.test(hash)) return reply.status(404).send({ error: "image not found" });
9626
+ const etag = `"${hash}-w${w}"`;
9627
+ if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", IMMUTABLE).send();
9628
+ if (await fileSize(core.images.pathFor(hash)) === null)
9629
+ return reply.status(404).send({ error: "image not found" });
9630
+ const path = await thumbs.ensure(hash, w);
9631
+ if (!path) return reply.header("cache-control", "no-store").redirect(`/api/images/${hash}`, 307);
9632
+ const size = await fileSize(path);
9633
+ if (size === null) return reply.header("cache-control", "no-store").redirect(`/api/images/${hash}`, 307);
9634
+ reply.header("content-type", "image/webp").header("cache-control", IMMUTABLE).header("etag", etag).header("content-length", String(size));
9635
+ return reply.send(thumbs.stream(path));
8927
9636
  });
8928
9637
  app.post("/api/images", async (req, reply) => {
8929
9638
  const part = await req.file();
8930
9639
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
8931
9640
  const buf = await part.toBuffer();
8932
9641
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
8933
- const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
8934
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
9642
+ const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
9643
+ const png = fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
8935
9644
  return { hash: core.images.save(png) };
8936
9645
  });
8937
9646
  app.post("/api/diff", async (req, reply) => {
@@ -8966,6 +9675,48 @@ function registerImageRoutes(app, deps) {
8966
9675
 
8967
9676
  // src/release/notes.data.ts
8968
9677
  var RELEASES = [
9678
+ {
9679
+ version: "0.8.1",
9680
+ date: "2026-09-03",
9681
+ title: "The same speed at any size.",
9682
+ sections: [
9683
+ {
9684
+ heading: "Create",
9685
+ body: "The feed opens on its first page and brings in more as you scroll, so a brand holding ten shots and a brand holding ten thousand open in the same moment. Pictures arrive as light copies sized for where they are shown, and the full image only where you actually look at it, so a long feed scrolls smoothly and a shot opens with its picture already in hand. Search reads the whole brand from the third letter you type, and finds shots by the products, people and scenes they were made with as well as by their words."
9686
+ },
9687
+ {
9688
+ heading: "Library",
9689
+ body: "Opening Scenri no longer slows down as your library grows. The first start after this update builds a search index once, which takes a moment on a very large library; every start after that is immediate. Clearing every shot from the danger zone now removes their thumbnails too, which used to stay on disk with nothing left that could find them."
9690
+ },
9691
+ {
9692
+ heading: "Fixes",
9693
+ body: "Dragging a chip to reorder it shows the insertion mark in the gap you are pointing at, rather than a chip or two further along, and a drag that changes nothing leaves the caret where it was. A drag released outside the brief no longer leaves the line unable to take a caret at all. A shot that is still rendering now shows the same selection ring as every other tile when you open it."
9694
+ }
9695
+ ]
9696
+ },
9697
+ {
9698
+ version: "0.8.0",
9699
+ date: "2026-09-02",
9700
+ title: "The composer, rebuilt around what a shot is made of.",
9701
+ sections: [
9702
+ {
9703
+ heading: "Create",
9704
+ body: "Ingredients are compact chips now, each carrying the picture it stands for, and hovering one shows you what it is holding. A shot can hold twelve of them: the ones your engine can photograph are lit, and the rest ride along in words rather than being dropped, with every chip saying which it is. Photos go out in the order you wrote them, so the first thing you named is the first thing pictured. The asset panel beside the brief is a second door into it, so a tile ticks when its chip is in and clicking it again takes the chip out."
9705
+ },
9706
+ {
9707
+ heading: "Refine",
9708
+ body: "The panel beside an open shot has been redrawn, and its edge can be dragged to the width you want. Above the brief it shows what the picture is actually made of, resolved through the whole chain of refinements rather than just the last one, so nothing is repeated and nothing is lost at depth."
9709
+ },
9710
+ {
9711
+ heading: "Brand",
9712
+ body: "A product name is something to show in a shot, never lettering to paint into it, and a brand colour is now spoken as a note rather than an instruction, which keeps invented signage out of your pictures. Brand rules apply to every shot from Settings, so the composer no longer carries a row to say so."
9713
+ },
9714
+ {
9715
+ heading: "Fixes",
9716
+ body: "A finished shot appears the moment it lands instead of waiting for the rest of its batch, and a batch sent from a set files every shot in it. Typing around a chip behaves: the chip owns the space beside it, one press removes it, and two chips always keep the single space between them. Two presenters in a scene no longer compose one of them out."
9717
+ }
9718
+ ]
9719
+ },
8969
9720
  {
8970
9721
  version: "0.7.5",
8971
9722
  date: "2026-09-01",
@@ -9740,21 +10491,20 @@ function registerUpdateRoutes(app, deps) {
9740
10491
  });
9741
10492
  }
9742
10493
  function registerSystemRoutes(app, deps) {
9743
- const { core } = deps;
10494
+ const { core, thumbs } = deps;
9744
10495
  app.get("/api/home", async () => {
9745
10496
  const imagesDir = join(core.home, "images");
9746
- let files = 0, bytes = 0;
9747
- if (existsSync(imagesDir)) {
9748
- for (const f of readdirSync(imagesDir)) {
9749
- try {
9750
- bytes += statSync(join(imagesDir, f)).size;
9751
- files++;
9752
- } catch {
9753
- }
9754
- }
10497
+ let files = 0;
10498
+ let bytes = 0;
10499
+ const names = await readdir(imagesDir).catch(() => []);
10500
+ const sizes = await Promise.all(names.map((f) => stat(join(imagesDir, f)).catch(() => null)));
10501
+ for (const s of sizes) {
10502
+ if (!s?.isFile()) continue;
10503
+ bytes += s.size;
10504
+ files++;
9755
10505
  }
9756
10506
  const dbPath = join(core.home, "scenri.db");
9757
- const dbBytes = existsSync(dbPath) ? statSync(dbPath).size : 0;
10507
+ const dbBytes = (await stat(dbPath).catch(() => null))?.size ?? 0;
9758
10508
  return { dir: core.home, dbPath, images: files, bytes: bytes + dbBytes };
9759
10509
  });
9760
10510
  app.post("/api/system/reveal", async (_req, reply) => {
@@ -9799,10 +10549,11 @@ function registerSystemRoutes(app, deps) {
9799
10549
  removed++;
9800
10550
  }
9801
10551
  }
10552
+ thumbs.clear();
9802
10553
  return { ok: true, scope, projects: removed };
9803
10554
  }
9804
10555
  core.close();
9805
- for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "backups"]) {
10556
+ for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "thumbs", "backups"]) {
9806
10557
  rmSync(join(core.home, name), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
9807
10558
  }
9808
10559
  return { ok: true, scope };
@@ -9826,6 +10577,7 @@ function buildServer(opts) {
9826
10577
  registerAccessGuard(app, opts.access);
9827
10578
  const reserved = /* @__PURE__ */ new Map();
9828
10579
  const runningGenerations = /* @__PURE__ */ new Map();
10580
+ const thumbs = createThumbStore(core);
9829
10581
  const { scenes } = loadScenes(opts.templatesDir);
9830
10582
  const resolveScene = sceneResolver(scenes);
9831
10583
  const sceneFor = (brandJson) => (id) => brandSceneById(brandJson, id) ?? resolveScene(id);
@@ -9856,7 +10608,7 @@ function buildServer(opts) {
9856
10608
  // Measured as stored (post-toMarkPng), so the scrape judges the same
9857
10609
  // pixels the compiler will one day attach.
9858
10610
  probeLongEdge: async (buf) => {
9859
- const m = await sharp20(await toMarkPng(buf)).metadata();
10611
+ const m = await sharp21(await toMarkPng(buf)).metadata();
9860
10612
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
9861
10613
  },
9862
10614
  createdWith: `${meta.name}/${meta.version}`
@@ -9945,7 +10697,7 @@ function buildServer(opts) {
9945
10697
  fetchImpl: opts.fetchImpl,
9946
10698
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
9947
10699
  probeLongEdge: async (buf) => {
9948
- const m = await sharp20(await toMarkPng(buf)).metadata();
10700
+ const m = await sharp21(await toMarkPng(buf)).metadata();
9949
10701
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
9950
10702
  },
9951
10703
  createdWith: `${meta.name}/${meta.version}`
@@ -10128,6 +10880,7 @@ function buildServer(opts) {
10128
10880
  const compileCtx = {
10129
10881
  brand: brandJson,
10130
10882
  images: core.images,
10883
+ wordsFor: shotWordsFor(core, brandId),
10131
10884
  engineCaps: uncapped,
10132
10885
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10133
10886
  templateById: sceneById,
@@ -10164,25 +10917,19 @@ function buildServer(opts) {
10164
10917
  }).map((a) => ({ ...a, inherited: true }));
10165
10918
  }
10166
10919
  const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
10167
- const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
10920
+ const merged = mergeEditAttachments(compiled2.seated, inheritedAttachments, cap2);
10168
10921
  const prompt = compileBrief(brief, { ...compileCtx, presentAttachments: merged.kept }).prompt;
10169
10922
  const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
10170
10923
  if (inherited.truncated)
10171
10924
  warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
10172
- if (merged.dropped.length) {
10173
- if (engineCaps.maxReferenceImages <= 1) {
10174
- warnings.push(`The identity rides on the shot itself \u2014 ${engineCaps.displayName} reads no other images.`);
10175
- } else {
10176
- const keptLabels = new Set(merged.kept.map((a) => a.label));
10177
- const names = [...new Set(merged.dropped.map((d) => d.label))].filter((l) => !keptLabels.has(l));
10178
- if (names.length)
10179
- warnings.push(`${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out of this refinement.`);
10180
- }
10181
- }
10925
+ if (merged.dropped.length && engineCaps.maxReferenceImages <= 1)
10926
+ warnings.push(`The identity rides on the shot itself \u2014 ${engineCaps.displayName} reads no other images.`);
10182
10927
  return {
10183
10928
  compiled: { ...compiled2, prompt },
10184
10929
  inheritedTokens,
10185
10930
  merged,
10931
+ /** The seats this refinement had: the engine's, less the source frame. */
10932
+ cap: cap2,
10186
10933
  warnings,
10187
10934
  editScope: verdict.scope,
10188
10935
  editRemoval: verdict.removal ?? false
@@ -10211,7 +10958,11 @@ function buildServer(opts) {
10211
10958
  // missing-photo identities; the budget losses live on the merge.
10212
10959
  dropped: [...edit.compiled.dropped, ...edit.merged.dropped],
10213
10960
  warnings: edit.warnings,
10214
- referenceCount: edit.merged.kept.length
10961
+ referenceCount: edit.merged.kept.length,
10962
+ // How many photo groups this refine can carry in total: the engine's
10963
+ // slots less the one the source frame holds. The composer refuses a
10964
+ // pick past it rather than warning after the fact.
10965
+ cap: edit.cap
10215
10966
  };
10216
10967
  }
10217
10968
  const brandJson = await brandJsonWithIdentityCrops(
@@ -10235,14 +10986,27 @@ function buildServer(opts) {
10235
10986
  const compiled2 = compileBrief(brief, {
10236
10987
  brand: brandJson,
10237
10988
  images: core.images,
10989
+ wordsFor: shotWordsFor(core, brand.id),
10238
10990
  engineCaps: engine.capabilities(),
10239
10991
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10240
10992
  templateById: sceneById
10241
10993
  });
10242
10994
  const { referenceImages, ...rest } = compiled2;
10243
- return { ...rest, referenceCount: referenceImages.length };
10995
+ return { ...rest, referenceCount: referenceImages.length, cap: engine.capabilities().maxReferenceImages };
10996
+ });
10997
+ registerProjectRoutes(app, {
10998
+ core,
10999
+ // what a search may match a brief token by: every name a token can carry today
11000
+ tokenNames: (brand) => [
11001
+ ...core.catalog.listLibraryProducts(brand.id, brand.json).map((p) => ({ id: p.id, name: p.name })),
11002
+ ...demoProducts.map((p) => ({ id: p.id, name: p.name })),
11003
+ ...brandCharacters(brand.json).map((c) => ({ id: String(c.id), name: String(c.name ?? "") })),
11004
+ ...presenters.map((p) => ({ id: p.id, name: p.name })),
11005
+ ...brandScenes(brand.json).map((sc) => ({ id: sc.id, name: sc.name })),
11006
+ ...scenes.map((sc) => ({ id: sc.id, name: sc.name }))
11007
+ ],
11008
+ engineNames: () => engines.all().map((e) => ({ id: e.capabilities().id, name: e.capabilities().displayName }))
10244
11009
  });
10245
- registerProjectRoutes(app, { core });
10246
11010
  registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup, codexRunner: engines.codexRunner });
10247
11011
  app.get("/api/engines", async () => {
10248
11012
  const list2 = [];
@@ -10306,11 +11070,11 @@ function buildServer(opts) {
10306
11070
  const out = [];
10307
11071
  for (const h of images) {
10308
11072
  const buf = core.images.read(h);
10309
- const meta2 = await sharp20(buf).metadata().catch(() => null);
11073
+ const meta2 = await sharp21(buf).metadata().catch(() => null);
10310
11074
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
10311
11075
  const oriented = (meta2.orientation ?? 1) !== 1;
10312
11076
  out.push(
10313
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
11077
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp21(buf).rotate().png().toBuffer())
10314
11078
  );
10315
11079
  }
10316
11080
  return out;
@@ -10322,7 +11086,7 @@ function buildServer(opts) {
10322
11086
  const out = [];
10323
11087
  for (const h of images) {
10324
11088
  const buf = core.images.read(h);
10325
- const meta2 = await sharp20(buf).metadata();
11089
+ const meta2 = await sharp21(buf).metadata();
10326
11090
  if (!meta2.width || !meta2.height) {
10327
11091
  out.push(h);
10328
11092
  continue;
@@ -10335,7 +11099,7 @@ function buildServer(opts) {
10335
11099
  }
10336
11100
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
10337
11101
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
10338
- const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
11102
+ const cropped = await sharp21(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
10339
11103
  app.log.info(
10340
11104
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
10341
11105
  "canvas: cropped a drifted frame to the asked ratio"
@@ -10354,7 +11118,7 @@ function buildServer(opts) {
10354
11118
  async function assertAspect(images, expect) {
10355
11119
  const want = expect.width / expect.height;
10356
11120
  for (const h of images) {
10357
- const meta2 = await sharp20(core.images.read(h)).metadata();
11121
+ const meta2 = await sharp21(core.images.read(h)).metadata();
10358
11122
  if (!meta2.width || !meta2.height) continue;
10359
11123
  const got = meta2.width / meta2.height;
10360
11124
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -10377,58 +11141,69 @@ function buildServer(opts) {
10377
11141
  }, bound);
10378
11142
  const startedAt = Date.now();
10379
11143
  const settled = /* @__PURE__ */ new Set();
11144
+ const settleSlot = async (slot, hash) => {
11145
+ const id = nodeIds[slot];
11146
+ try {
11147
+ let own = await normalizePngs([hash]);
11148
+ const post = postFor?.(id);
11149
+ if (post) own = await post(own);
11150
+ if (expect) await assertAspect(own, expect);
11151
+ try {
11152
+ const meta2 = await sharp21(core.images.read(own[0])).metadata();
11153
+ const node = core.store.getNode(id);
11154
+ if (node && meta2.width && meta2.height) {
11155
+ const brief = node.brief ?? {};
11156
+ const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
11157
+ core.store.setBrief(id, { ...brief, rendered: { sizes: [[meta2.width, meta2.height]], ...asked } });
11158
+ if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && meta2.width === expect.width && meta2.height === expect.height)
11159
+ app.log.warn(
11160
+ { nodeId: id },
11161
+ "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
11162
+ );
11163
+ }
11164
+ } catch {
11165
+ }
11166
+ core.store.completeNode(id, { images: own, costUsd: 0, durationMs: Date.now() - startedAt });
11167
+ thumbs.warm(own[0]);
11168
+ } catch (err) {
11169
+ core.store.failNode(id, String(err?.message ?? err));
11170
+ } finally {
11171
+ settled.add(id);
11172
+ }
11173
+ };
11174
+ const landing = /* @__PURE__ */ new Map();
11175
+ let accepting = true;
11176
+ const onImage = (slot, hash) => {
11177
+ if (!accepting || !Number.isInteger(slot) || slot < 0 || slot >= nodeIds.length || landing.has(slot)) return;
11178
+ landing.set(slot, settleSlot(slot, hash));
11179
+ };
10380
11180
  try {
10381
- const result = await work(ctrl.signal);
11181
+ const result = await work(ctrl.signal, onImage);
11182
+ accepting = false;
10382
11183
  clearTimeout(watchdog);
10383
- const images = await normalizePngs(result.images);
11184
+ await Promise.allSettled(landing.values());
10384
11185
  const raw = result.raw;
10385
11186
  const bySlot = new Array(nodeIds.length);
10386
- images.forEach((h, k) => {
11187
+ result.images.forEach((h, k) => {
10387
11188
  const slot = raw?.variantIndexes?.[k] ?? k;
10388
11189
  if (slot < nodeIds.length && bySlot[slot] === void 0) bySlot[slot] = h;
10389
11190
  });
10390
- const wall = Date.now() - startedAt;
10391
11191
  const failures = [...raw?.partialFailures ?? []];
10392
11192
  for (let slot = 0; slot < nodeIds.length; slot++) {
10393
- const id = nodeIds[slot];
11193
+ if (landing.has(slot)) continue;
10394
11194
  const hash = bySlot[slot];
10395
11195
  if (hash === void 0) {
10396
- core.store.failNode(id, failures.shift() ?? "the engine returned no image for this shot");
10397
- settled.add(id);
11196
+ core.store.failNode(nodeIds[slot], failures.shift() ?? "the engine returned no image for this shot");
11197
+ settled.add(nodeIds[slot]);
10398
11198
  continue;
10399
11199
  }
10400
- try {
10401
- let own = [hash];
10402
- const post = postFor?.(id);
10403
- if (post) own = await post(own);
10404
- if (expect) await assertAspect(own, expect);
10405
- try {
10406
- const meta2 = await sharp20(core.images.read(own[0])).metadata();
10407
- const node = core.store.getNode(id);
10408
- if (node && meta2.width && meta2.height) {
10409
- const brief = node.brief ?? {};
10410
- const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
10411
- core.store.setBrief(id, { ...brief, rendered: { sizes: [[meta2.width, meta2.height]], ...asked } });
10412
- if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && meta2.width === expect.width && meta2.height === expect.height)
10413
- app.log.warn(
10414
- { nodeId: id },
10415
- "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
10416
- );
10417
- }
10418
- } catch {
10419
- }
10420
- core.store.completeNode(id, {
10421
- images: own,
10422
- costUsd: slot === 0 ? result.costUsd : 0,
10423
- durationMs: wall
10424
- });
10425
- } catch (err) {
10426
- core.store.failNode(id, String(err?.message ?? err));
10427
- }
10428
- settled.add(id);
11200
+ await settleSlot(slot, hash);
10429
11201
  }
11202
+ core.store.chargeNode(nodeIds[0], result.costUsd);
10430
11203
  core.ledger.recordCost(engineId, nodeIds[0], result.costUsd);
10431
11204
  } catch (err) {
11205
+ accepting = false;
11206
+ await Promise.allSettled(landing.values());
10432
11207
  for (const id of nodeIds) {
10433
11208
  if (settled.has(id)) continue;
10434
11209
  if (watchdogFired) core.store.failNode(id, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
@@ -10483,7 +11258,7 @@ function buildServer(opts) {
10483
11258
  crop: window
10484
11259
  });
10485
11260
  const work2 = async () => ({
10486
- images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
11261
+ images: [core.images.save(await sharp21(args.srcBuf).extract(window).png().toBuffer())],
10487
11262
  costUsd: 0
10488
11263
  });
10489
11264
  void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -10492,7 +11267,7 @@ function buildServer(opts) {
10492
11267
  return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
10493
11268
  };
10494
11269
  if (kind === "edit" && reshape === "crop") {
10495
- const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
11270
+ const rootForCrop = core.store.rootFor(project.id);
10496
11271
  if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
10497
11272
  const cropParentId = parentId ? String(parentId) : rootForCrop.id;
10498
11273
  if (brief && Array.isArray(brief.tokens)) {
@@ -10508,7 +11283,7 @@ function buildServer(opts) {
10508
11283
  if (!srcHash || !core.images.has(String(srcHash)))
10509
11284
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
10510
11285
  const srcBuf = core.images.read(String(srcHash));
10511
- const srcMeta = await sharp20(srcBuf).metadata();
11286
+ const srcMeta = await sharp21(srcBuf).metadata();
10512
11287
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
10513
11288
  return runCropNode({
10514
11289
  parentId: cropParentId,
@@ -10524,7 +11299,7 @@ function buildServer(opts) {
10524
11299
  if (!avail.ok) return reply.status(400).send({ error: avail.reason ?? "engine unavailable" });
10525
11300
  if (kind !== "generation" && kind !== "edit")
10526
11301
  return reply.status(400).send({ error: "kind must be generation|edit" });
10527
- const rootNode = core.store.treeFor(project.id).find((n) => n.kind === "root");
11302
+ const rootNode = core.store.rootFor(project.id);
10528
11303
  if (!rootNode) return reply.status(500).send({ error: "project has no root node" });
10529
11304
  const resolvedParentId = parentId ? String(parentId) : rootNode.id;
10530
11305
  const ctx = brandContext(core, project.brandId);
@@ -10580,6 +11355,7 @@ function buildServer(opts) {
10580
11355
  compiled2 = compileBrief(brief, {
10581
11356
  brand: brandJson,
10582
11357
  images: core.images,
11358
+ wordsFor: shotWordsFor(core, project.brandId),
10583
11359
  engineCaps: engine.capabilities(),
10584
11360
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10585
11361
  templateById: sceneById
@@ -10617,6 +11393,7 @@ function buildServer(opts) {
10617
11393
  compiled2 = compileBrief(legacyBrief, {
10618
11394
  brand: brandJson,
10619
11395
  images: core.images,
11396
+ wordsFor: shotWordsFor(core, project.brandId),
10620
11397
  engineCaps: engine.capabilities(),
10621
11398
  templateById: sceneFor(brandJson)
10622
11399
  });
@@ -10641,7 +11418,8 @@ function buildServer(opts) {
10641
11418
  if (kind === "generation") {
10642
11419
  const cap2 = engine.capabilities().maxReferenceImages;
10643
11420
  const wantedCount = Math.min(Math.max(1, Number(count)), 8);
10644
- const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
11421
+ const blind = engine.capabilities().maxReferenceImages === 0;
11422
+ const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential && (d.reason === "missing" || blind));
10645
11423
  if (lostIdentity.length) {
10646
11424
  const missing = lostIdentity.filter((d) => d.reason === "missing");
10647
11425
  if (missing.length) {
@@ -10692,7 +11470,7 @@ function buildServer(opts) {
10692
11470
  ...variations.length ? { variations } : {}
10693
11471
  };
10694
11472
  estimate = await engine.costEstimate(genReq);
10695
- work = (signal) => engine.generate(genReq, signal);
11473
+ work = (signal, onImage) => engine.generate(genReq, signal, onImage);
10696
11474
  expectShape = { width, height };
10697
11475
  } else {
10698
11476
  const parent = core.store.getNode(resolvedParentId);
@@ -10720,7 +11498,7 @@ function buildServer(opts) {
10720
11498
  );
10721
11499
  }
10722
11500
  const srcBuf = core.images.read(String(srcHash));
10723
- const srcMeta = await sharp20(srcBuf).metadata();
11501
+ const srcMeta = await sharp21(srcBuf).metadata();
10724
11502
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
10725
11503
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
10726
11504
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
@@ -10754,7 +11532,7 @@ function buildServer(opts) {
10754
11532
  } else if (decision.op === "extend") {
10755
11533
  if (decision.assist) {
10756
11534
  expandAssist = { width: decision.assist.width, height: decision.assist.height };
10757
- workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
11535
+ workBuf = await sharp21(srcBuf).extract(decision.assist).png().toBuffer();
10758
11536
  workSize = { width: decision.assist.width, height: decision.assist.height };
10759
11537
  }
10760
11538
  expandPlan = planExpand(workSize, targetRatio);
@@ -10775,7 +11553,7 @@ function buildServer(opts) {
10775
11553
  const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
10776
11554
  if (fit.scale < 1) {
10777
11555
  expandPlan = fit.plan;
10778
- workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
11556
+ workBuf = await sharp21(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
10779
11557
  workSize = fit.source;
10780
11558
  extraWarnings.push(
10781
11559
  `${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
@@ -10794,10 +11572,11 @@ function buildServer(opts) {
10794
11572
  expectShape = { width: expandPlan.width, height: expandPlan.height };
10795
11573
  }
10796
11574
  const editPixelBudget = runEngine.capabilities().editPixelBudget;
10797
- if (!expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget) {
10798
- sentSize = budgetSize(srcMeta.width, srcMeta.height, editPixelBudget);
11575
+ const stepped = !expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget ? budgetSize(srcMeta.width, srcMeta.height, editPixelBudget) : null;
11576
+ if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
11577
+ sentSize = stepped;
10799
11578
  budgetSourceHash = core.images.save(
10800
- await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
11579
+ await sharp21(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
10801
11580
  );
10802
11581
  if (!gradeOnlyAsk)
10803
11582
  extraWarnings.push(
@@ -10930,18 +11709,18 @@ function buildServer(opts) {
10930
11709
  }
10931
11710
  } : {},
10932
11711
  // What the refinement carried, recorded apart from what it asked for:
10933
- // the detail view shows both, and remix reads tokens alone.
11712
+ // the detail view shows both, and reuse setup merges both (mergeCarried).
10934
11713
  ...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
10935
11714
  });
10936
11715
  const plan = expandPlan;
10937
11716
  const original = editedFrom ? core.images.read(editedFrom) : null;
10938
11717
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
10939
11718
  const enforceEditCanvas = async (images) => {
10940
- const srcMeta = await sharp20(original).metadata();
11719
+ const srcMeta = await sharp21(original).metadata();
10941
11720
  if (!srcMeta.width || !srcMeta.height) return images;
10942
11721
  const out = [];
10943
11722
  for (const h of images) {
10944
- const meta2 = await sharp20(core.images.read(h)).metadata();
11723
+ const meta2 = await sharp21(core.images.read(h)).metadata();
10945
11724
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
10946
11725
  const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
10947
11726
  pixelBudget: runEngine.capabilities().editPixelBudget
@@ -10971,7 +11750,7 @@ function buildServer(opts) {
10971
11750
  );
10972
11751
  out.push(
10973
11752
  core.images.save(
10974
- await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
11753
+ await sharp21(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
10975
11754
  )
10976
11755
  );
10977
11756
  try {
@@ -10993,7 +11772,7 @@ function buildServer(opts) {
10993
11772
  const out = [];
10994
11773
  for (const h of images) {
10995
11774
  const answer = core.images.read(h);
10996
- const got = await sharp20(answer).metadata();
11775
+ const got = await sharp21(answer).metadata();
10997
11776
  if (got.width !== plan.width || got.height !== plan.height)
10998
11777
  app.log.info(
10999
11778
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -11101,13 +11880,13 @@ function buildServer(opts) {
11101
11880
  const n = core.store.getNode(req.params.id);
11102
11881
  if (!n) return reply.status(404).send({ error: "node not found" });
11103
11882
  core.store.setKept(n.id, Boolean(req.body?.kept ?? true));
11104
- return core.store.getNode(n.id);
11883
+ return core.store.getFeedNode(n.id);
11105
11884
  });
11106
11885
  app.post("/api/nodes/:id/archive", async (req, reply) => {
11107
11886
  const n = core.store.getNode(req.params.id);
11108
11887
  if (!n) return reply.status(404).send({ error: "node not found" });
11109
11888
  core.store.setArchived(n.id, Boolean(req.body?.archived ?? true));
11110
- return core.store.getNode(n.id);
11889
+ return core.store.getFeedNode(n.id);
11111
11890
  });
11112
11891
  app.delete("/api/nodes/:id", async (req, reply) => {
11113
11892
  const n = core.store.getNode(req.params.id);
@@ -11129,7 +11908,7 @@ function buildServer(opts) {
11129
11908
  }
11130
11909
  return { ok: true, deleted };
11131
11910
  });
11132
- registerImageRoutes(app, { core });
11911
+ registerImageRoutes(app, { core, thumbs });
11133
11912
  const runtime = opts.runtime ?? { installKind: "unknown", supervised: false };
11134
11913
  const updates = createUpdateChecker({ name: meta.name, store: core.store, fetchImpl: opts.fetchImpl });
11135
11914
  app.decorate("updates", updates);
@@ -11154,12 +11933,13 @@ function buildServer(opts) {
11154
11933
  while (runningGenerations.size > 0 && Date.now() < deadline) {
11155
11934
  await new Promise((r) => setTimeout(r, 25));
11156
11935
  }
11936
+ await thumbs.settle();
11157
11937
  await app.close();
11158
11938
  core.close();
11159
11939
  })();
11160
11940
  return drained;
11161
11941
  });
11162
- registerSystemRoutes(app, { core });
11942
+ registerSystemRoutes(app, { core, thumbs });
11163
11943
  if (opts.studioDist && existsSync(opts.studioDist)) {
11164
11944
  const dist = opts.studioDist;
11165
11945
  app.register(fastifyStatic, { root: dist });
@@ -11213,7 +11993,7 @@ async function serve() {
11213
11993
  }
11214
11994
  async function run() {
11215
11995
  const core = createCore();
11216
- const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b))] : [];
11996
+ const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
11217
11997
  const engines = createEngineRegistry(core, stubs);
11218
11998
  const here = dirname(fileURLToPath(import.meta.url));
11219
11999
  const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];
@@ -11223,7 +12003,6 @@ async function run() {
11223
12003
  const onlyThisMachine = LOOPBACK2.includes(HOST);
11224
12004
  const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
11225
12005
  const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
11226
- await repairPresenterCrops(core, (line) => console.log(line));
11227
12006
  const app = buildServer({
11228
12007
  core,
11229
12008
  engines,
@@ -11285,6 +12064,7 @@ async function run() {
11285
12064
  };
11286
12065
  process.on("SIGINT", shutdown);
11287
12066
  process.on("SIGTERM", shutdown);
12067
+ void repairPresenterCrops(core, (line) => console.log(line)).catch(() => void 0);
11288
12068
  app.updates.schedule();
11289
12069
  app.content.schedule();
11290
12070
  const query = token ? `/?t=${token}` : "";
@@ -11328,8 +12108,8 @@ async function verify() {
11328
12108
  const db = new Database2(":memory:");
11329
12109
  db.pragma("user_version");
11330
12110
  db.close();
11331
- const { default: sharp21 } = await import('sharp');
11332
- await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
12111
+ const { default: sharp22 } = await import('sharp');
12112
+ await sharp22({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
11333
12113
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
11334
12114
  } catch (err) {
11335
12115
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));