scenri 0.8.0 → 0.8.2

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) {
@@ -823,13 +1046,143 @@ function createStore(db) {
823
1046
  db.prepare("UPDATE nodes SET status='cancelled' WHERE id=?").run(id);
824
1047
  },
825
1048
  getNode(id) {
826
- 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);
827
1050
  return r ? rowToNode(r) : null;
828
1051
  },
829
- treeFor(projectId) {
830
- return db.prepare("SELECT * FROM nodes WHERE project_id=? ORDER BY created_at, id").all(projectId).map(
831
- 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))
832
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;
833
1186
  },
834
1187
  /**
835
1188
  * Every piece of work a brand has in flight, plus whatever finished lately,
@@ -842,21 +1195,26 @@ function createStore(db) {
842
1195
  * ISO string is a silent, timezone-shaped mis-filter.
843
1196
  */
844
1197
  recentActivity(brandId, limit = 60) {
845
- const rows = db.prepare(
846
- `SELECT n.*, (
1198
+ const cols = `${FEED_COLS}, (
847
1199
  SELECT group_concat(s.name, char(31))
848
1200
  FROM set_nodes sn JOIN sets s ON s.id = sn.set_id
849
1201
  WHERE sn.node_id = n.id
850
- ) AS set_names
851
- FROM nodes n JOIN projects p ON p.id = n.project_id
852
- WHERE p.brand_id = ?
853
- AND n.kind != 'root'
854
- AND (n.status = 'running' OR n.created_at >= datetime('now', '-2 days'))
855
- ORDER BY n.created_at DESC, n.id DESC
856
- LIMIT ?`
857
- ).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 });
858
1216
  return rows.map((r) => ({
859
- ...rowToNode(r),
1217
+ ...rowToFeedNode(r),
860
1218
  setNames: r.set_names ? String(r.set_names).split(SET_NAME_SEP) : []
861
1219
  }));
862
1220
  },
@@ -979,6 +1337,56 @@ var productById = (db, id) => {
979
1337
  var productsFor = (db, brandId) => db.prepare(
980
1338
  "SELECT * FROM catalog_products WHERE brand_id=? AND status!='unavailable' ORDER BY title COLLATE NOCASE"
981
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
+ };
982
1390
  var variantsFor = (db, productId) => db.prepare("SELECT * FROM catalog_variants WHERE product_id=?").all(productId).map((r) => ({
983
1391
  id: r.id,
984
1392
  productId: r.product_id,
@@ -1388,8 +1796,10 @@ function libraryMethods(db) {
1388
1796
  alt: s.alt ?? s.angle ?? null
1389
1797
  }))
1390
1798
  }));
1799
+ const imagesBy = imagesForBrand(db, brandId);
1800
+ const variantsBy = variantsForBrand(db, brandId);
1391
1801
  const catalog = productsFor(db, brandId).map((p) => {
1392
- const images = imagesFor(db, p.id);
1802
+ const images = imagesBy.get(p.id) ?? [];
1393
1803
  const shot = (i) => ({
1394
1804
  file: i.assetRef,
1395
1805
  locked: true,
@@ -1421,7 +1831,7 @@ function libraryMethods(db) {
1421
1831
  status: p.status,
1422
1832
  shots,
1423
1833
  hiddenShots,
1424
- variants: variantsFor(db, p.id)
1834
+ variants: variantsBy.get(p.id) ?? []
1425
1835
  };
1426
1836
  });
1427
1837
  return [...manual, ...catalog];
@@ -3098,7 +3508,7 @@ function createDemoEngine(saveImage, opts = {}) {
3098
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>
3099
3509
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
3100
3510
  </svg>`;
3101
- return sharp20(Buffer.from(svg)).png().toBuffer();
3511
+ return sharp21(Buffer.from(svg)).png().toBuffer();
3102
3512
  }
3103
3513
  return {
3104
3514
  capabilities() {
@@ -3289,7 +3699,7 @@ var resolvedRefs = /* @__PURE__ */ new Map();
3289
3699
  async function refHash(core, path) {
3290
3700
  const hit = resolvedRefs.get(path);
3291
3701
  if (hit && core.images.has(hit)) return hit;
3292
- const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
3702
+ const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
3293
3703
  resolvedRefs.set(path, hash);
3294
3704
  return hash;
3295
3705
  }
@@ -3389,14 +3799,21 @@ function demoProductFacetsOf(demoProducts) {
3389
3799
  function demoProductRefPath(templatesRoot, id, angle) {
3390
3800
  return contentFile(templatesRoot, "previews", "demo-products", id, `${angle}.jpg`);
3391
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
+ }
3392
3810
  async function resolveDemoProductImages(core, templatesRoot, product) {
3393
3811
  const angles = PRODUCT_ANGLES_BY_CATEGORY[product.category] ?? PRODUCT_ANGLES_BY_CATEGORY.other;
3394
3812
  const shots = [];
3395
3813
  for (const angle of angles) {
3396
3814
  const path = demoProductRefPath(templatesRoot, product.id, angle);
3397
3815
  if (!existsSync(path)) continue;
3398
- const png = await sharp20(readFileSync(path)).png().toBuffer();
3399
- const hash = core.images.save(png);
3816
+ const hash = await refHash2(core, path);
3400
3817
  shots.push({ file: `asset:${hash}`, angle, locked: true });
3401
3818
  }
3402
3819
  if (!shots.length) return null;
@@ -3546,7 +3963,7 @@ var assetHash = (ref) => {
3546
3963
  };
3547
3964
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3548
3965
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3549
- var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
3966
+ var toPng = (buf) => sharp21(buf).rotate().png().toBuffer();
3550
3967
  var COST_PROBE = {
3551
3968
  prompt: "",
3552
3969
  brand: { brand: {}, assetPaths: {} },
@@ -3559,11 +3976,11 @@ var MARK_MIN_EDGE = 1024;
3559
3976
  var MARK_TINY_EDGE = 256;
3560
3977
  var MARK_WARN_EDGE = 512;
3561
3978
  var toMarkPng = async (buf) => {
3562
- const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3563
- 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();
3564
3981
  const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3565
3982
  if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3566
- 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();
3567
3984
  }
3568
3985
  return out;
3569
3986
  };
@@ -3574,9 +3991,9 @@ async function capReferenceEdge(core, path, maxEdge) {
3574
3991
  if (hit) return hit;
3575
3992
  let out = path;
3576
3993
  try {
3577
- const meta = await sharp20(path).metadata();
3994
+ const meta = await sharp21(path).metadata();
3578
3995
  if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3579
- 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();
3580
3997
  out = core.images.pathFor(core.images.save(buf));
3581
3998
  }
3582
3999
  } catch {
@@ -4221,18 +4638,9 @@ function shotWords(prompt, max = 160) {
4221
4638
  return clipped.replace(/[.\s]+$/, "");
4222
4639
  }
4223
4640
  function shotWordsFor(core, brandId) {
4224
- let byHash = null;
4641
+ const byHash = /* @__PURE__ */ new Map();
4225
4642
  return (hash) => {
4226
- if (!byHash) {
4227
- byHash = /* @__PURE__ */ new Map();
4228
- for (const p of core.store.listProjects(brandId)) {
4229
- for (const n of core.store.treeFor(p.id)) {
4230
- const words = shotWords(n.prompt);
4231
- if (!words) continue;
4232
- for (const h of n.images ?? []) if (!byHash.has(h)) byHash.set(h, words);
4233
- }
4234
- }
4235
- }
4643
+ if (!byHash.has(hash)) byHash.set(hash, shotWords(core.store.promptForImage(brandId, hash)));
4236
4644
  return byHash.get(hash) ?? null;
4237
4645
  };
4238
4646
  }
@@ -6231,8 +6639,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
6231
6639
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
6232
6640
  return;
6233
6641
  }
6234
- const png = await sharp20(buf).rotate().png().toBuffer();
6235
- const meta = await sharp20(png).metadata();
6642
+ const png = await sharp21(buf).rotate().png().toBuffer();
6643
+ const meta = await sharp21(png).metadata();
6236
6644
  const hash = core.images.save(png);
6237
6645
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
6238
6646
  width: meta.width,
@@ -6709,7 +7117,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6709
7117
  return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
6710
7118
  }
6711
7119
  async function edgeBarGeometry(buf) {
6712
- const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7120
+ const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6713
7121
  const W = info.width;
6714
7122
  const H = info.height;
6715
7123
  const scan = (len, cross, at) => {
@@ -6763,7 +7171,7 @@ async function trimEdgeBars(core, hash) {
6763
7171
  const width = g.right - g.left + 1;
6764
7172
  const height = g.bottom - g.top + 1;
6765
7173
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
6766
- 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();
6767
7175
  return core.images.save(png);
6768
7176
  } catch {
6769
7177
  return hash;
@@ -6834,7 +7242,7 @@ async function identityCrop(core, hash) {
6834
7242
  if (!out) return void 0;
6835
7243
  try {
6836
7244
  const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
6837
- 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();
6838
7246
  const scaled = core.images.save(png);
6839
7247
  identityCrops.set(hash, scaled);
6840
7248
  return scaled;
@@ -6863,12 +7271,12 @@ async function brandJsonWithIdentityCrops(core, json, characterIds) {
6863
7271
  return changed ? { ...json, characters } : json;
6864
7272
  }
6865
7273
  async function figureBox(buf) {
6866
- const meta = await sharp20(buf).metadata();
7274
+ const meta = await sharp21(buf).metadata();
6867
7275
  const W = meta.width ?? 0;
6868
7276
  const H = meta.height ?? 0;
6869
7277
  if (!W || !H) return null;
6870
7278
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
6871
- const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
7279
+ const { info } = await sharp21(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6872
7280
  const left = Math.abs(info.trimOffsetLeft ?? 0);
6873
7281
  const top = Math.abs(info.trimOffsetTop ?? 0);
6874
7282
  const width = info.width ?? 0;
@@ -6901,13 +7309,13 @@ async function smartCover(core, hash, box) {
6901
7309
  if (!hash || !core.images.has(hash)) return void 0;
6902
7310
  try {
6903
7311
  const buf = core.images.read(hash);
6904
- const meta = await sharp20(buf).metadata();
7312
+ const meta = await sharp21(buf).metadata();
6905
7313
  const w = meta.width ?? 0;
6906
7314
  const h = meta.height ?? 0;
6907
7315
  if (!w || !h) return void 0;
6908
7316
  const raw = box(w, h);
6909
7317
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
6910
- 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();
6911
7319
  return core.images.save(png);
6912
7320
  } catch {
6913
7321
  return void 0;
@@ -6916,11 +7324,11 @@ async function smartCover(core, hash, box) {
6916
7324
  async function crop(core, hash, region, cap2) {
6917
7325
  if (!hash || !core.images.has(hash)) return void 0;
6918
7326
  try {
6919
- const meta = await sharp20(core.images.read(hash)).metadata();
7327
+ const meta = await sharp21(core.images.read(hash)).metadata();
6920
7328
  const w = meta.width ?? 0;
6921
7329
  const h = meta.height ?? 0;
6922
7330
  if (!w || !h) return void 0;
6923
- let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
7331
+ let pipeline = sharp21(core.images.read(hash)).extract(region(w, h));
6924
7332
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
6925
7333
  const png = await pipeline.png().toBuffer();
6926
7334
  return core.images.save(png);
@@ -7267,7 +7675,7 @@ var fromLab = (l, a, bb) => {
7267
7675
  return [clamp(R), clamp(G), clamp(B)];
7268
7676
  };
7269
7677
  var rawAt = async (png, edge) => {
7270
- let img = sharp20(png);
7678
+ let img = sharp21(png);
7271
7679
  if (edge) img = img.resize(edge, edge, { fit: "fill" });
7272
7680
  const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
7273
7681
  return { data, width: info.width, height: info.height };
@@ -7330,7 +7738,7 @@ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
7330
7738
  if (residual > GRADE_GATE_MEAN_DELTA) return null;
7331
7739
  const full = await rawAt(originalPng);
7332
7740
  applyAffine(full, T);
7333
- const image = await sharp20(full.data, {
7741
+ const image = await sharp21(full.data, {
7334
7742
  raw: { width: full.width, height: full.height, channels: 3 }
7335
7743
  }).png().toBuffer();
7336
7744
  return { image, residual };
@@ -7504,7 +7912,7 @@ function fitExpandToBudget(plan, source, pixelBudget) {
7504
7912
  }
7505
7913
  async function attentionCropOrigin(srcBuf, source, plan) {
7506
7914
  try {
7507
- 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 });
7508
7916
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
7509
7917
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
7510
7918
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -7657,23 +8065,23 @@ function relax(grid, seam, fixedSweeps) {
7657
8065
 
7658
8066
  // src/expand.ts
7659
8067
  async function expandCanvas(source, plan) {
7660
- 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();
7661
- 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();
7662
8070
  }
7663
8071
  async function compositeExpand(engineImage, source, plan) {
7664
- const meta = await sharp20(engineImage).metadata();
8072
+ const meta = await sharp21(engineImage).metadata();
7665
8073
  const want = plan.width / plan.height;
7666
8074
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
7667
8075
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
7668
8076
  const aligned = sameOrientation;
7669
8077
  const exact = meta.width === plan.width && meta.height === plan.height;
7670
- 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);
7671
8079
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
7672
- 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();
7673
8081
  return { image, aligned };
7674
8082
  }
7675
8083
  async function matchMarginsToSeam(surround, source, plan) {
7676
- const src = await sharp20(source).metadata();
8084
+ const src = await sharp21(source).metadata();
7677
8085
  if (!src.width || !src.height) return surround;
7678
8086
  const SW = src.width;
7679
8087
  const SH = src.height;
@@ -7720,8 +8128,8 @@ var MAX_CORRECTION = 60;
7720
8128
  async function reconcile(surround, source, side, axis) {
7721
8129
  const { margin } = side;
7722
8130
  if (margin.width < 1 || margin.height < 1) return surround;
7723
- const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
7724
- 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();
7725
8133
  const W = margin.width;
7726
8134
  const H = margin.height;
7727
8135
  const along = axis === "width" ? H : W;
@@ -7775,11 +8183,11 @@ async function reconcile(surround, source, side, axis) {
7775
8183
  }
7776
8184
  }
7777
8185
  }
7778
- const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
7779
- 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();
7780
8188
  }
7781
8189
  async function expandCanvasBedOnly(source, plan) {
7782
- 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();
7783
8191
  }
7784
8192
  function medianOf(rgb, channel, from, to) {
7785
8193
  const n = to - from;
@@ -7790,17 +8198,17 @@ function medianOf(rgb, channel, from, to) {
7790
8198
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
7791
8199
  }
7792
8200
  async function reframeExpand(engineImage, plan) {
7793
- const meta = await sharp20(engineImage).metadata();
8201
+ const meta = await sharp21(engineImage).metadata();
7794
8202
  if (!(meta.width && meta.height)) return null;
7795
8203
  const want = plan.width / plan.height;
7796
8204
  const got = meta.width / meta.height;
7797
8205
  if (got >= 1 !== want >= 1) return null;
7798
8206
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
7799
8207
  const straight = Math.abs(got - want) / want <= 0.02;
7800
- 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();
7801
8209
  }
7802
8210
  async function seamScore(image, plan, source) {
7803
- 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 });
7804
8212
  const W = info.width;
7805
8213
  const H = info.height;
7806
8214
  const horizontal = plan.axis === "width";
@@ -7833,7 +8241,7 @@ var SEAM_VISIBLE = 2.2;
7833
8241
  var OFFSET = 4;
7834
8242
  var RESIDUAL_VISIBLE = 15;
7835
8243
  async function seamResidual(image, plan, source) {
7836
- const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
8244
+ const { data, info } = await sharp21(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7837
8245
  const W = info.width;
7838
8246
  const H = info.height;
7839
8247
  const ch = info.channels;
@@ -7868,7 +8276,7 @@ var MAX_SHARE = 0.8;
7868
8276
  async function subjectFraction(src, source, axis) {
7869
8277
  try {
7870
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)) };
7871
- 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 });
7872
8280
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
7873
8281
  const span = axis === "width" ? source.width : source.height;
7874
8282
  const extent = axis === "width" ? window.width : window.height;
@@ -7891,14 +8299,14 @@ function placeExpand(plan, source, fraction) {
7891
8299
  }
7892
8300
  var NEUTRAL = { r: 128, g: 128, b: 128 };
7893
8301
  async function conditioningCanvas(source, plan, fill = "edge") {
7894
- const meta = await sharp20(source).metadata();
8302
+ const meta = await sharp21(source).metadata();
7895
8303
  const sw = meta.width ?? 0;
7896
8304
  const sh = meta.height ?? 0;
7897
8305
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
7898
8306
  const layers = [];
7899
8307
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
7900
8308
  layers.push({ input: source, left: plan.left, top: plan.top });
7901
- const canvas = sharp20({
8309
+ const canvas = sharp21({
7902
8310
  create: {
7903
8311
  width: plan.width,
7904
8312
  height: plan.height,
@@ -7910,7 +8318,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
7910
8318
  }
7911
8319
  async function edgeMargins(source, plan, size) {
7912
8320
  const out = [];
7913
- 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();
7914
8322
  if (plan.axis === "width") {
7915
8323
  const before = plan.left;
7916
8324
  const after = plan.width - plan.left - size.width;
@@ -8001,12 +8409,12 @@ async function resolveOutpaintRoute(all, shot) {
8001
8409
  return { engine: shot, method: "reframe", crossed: false };
8002
8410
  }
8003
8411
  async function driftDiff(a, b) {
8004
- const metaA = await sharp20(a).metadata();
8005
- const metaB = await sharp20(b).metadata();
8412
+ const metaA = await sharp21(a).metadata();
8413
+ const metaB = await sharp21(b).metadata();
8006
8414
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
8007
8415
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
8008
8416
  const [rawA, rawB] = await Promise.all(
8009
- [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())
8010
8418
  );
8011
8419
  const out = new PNG({ width, height });
8012
8420
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -8018,11 +8426,11 @@ async function driftDiff(a, b) {
8018
8426
  };
8019
8427
  }
8020
8428
  async function changeMask(a, b, cap2 = 1024) {
8021
- const metaA = await sharp20(a).metadata();
8429
+ const metaA = await sharp21(a).metadata();
8022
8430
  const width = Math.min(metaA.width ?? 1, cap2);
8023
8431
  const height = Math.min(metaA.height ?? 1, cap2);
8024
8432
  const [rawA, rawB] = await Promise.all(
8025
- [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())
8026
8434
  );
8027
8435
  const out = new PNG({ width, height });
8028
8436
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -8071,8 +8479,8 @@ function dilationFor(longEdge) {
8071
8479
  // src/localEdit.ts
8072
8480
  async function preserveOutsideChange(source, edited) {
8073
8481
  try {
8074
- const srcMeta = await sharp20(source).metadata();
8075
- const outMeta = await sharp20(edited).metadata();
8482
+ const srcMeta = await sharp21(source).metadata();
8483
+ const outMeta = await sharp21(edited).metadata();
8076
8484
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
8077
8485
  return { image: edited, outcome: "error", changed: 0 };
8078
8486
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -8082,15 +8490,15 @@ async function preserveOutsideChange(source, edited) {
8082
8490
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
8083
8491
  const r = dilationFor(Math.max(shape.width, shape.height));
8084
8492
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
8085
- const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
8086
- const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
8087
- const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
8088
- const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
8089
- const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
8090
- 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, {
8091
8499
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
8092
8500
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
8093
- const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
8501
+ const image = await sharp21(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
8094
8502
  return { image, outcome: "composited", changed: shape.changed };
8095
8503
  } catch {
8096
8504
  return { image: edited, outcome: "error", changed: 0 };
@@ -8128,7 +8536,7 @@ function registerLogoRoutes(app, deps) {
8128
8536
  const v = validateBrand(json);
8129
8537
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
8130
8538
  const row = core.store.updateBrand(brand.id, json);
8131
- 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);
8132
8540
  const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
8133
8541
  return { ...row, logoHash: part.hash, logoEdge };
8134
8542
  });
@@ -8250,7 +8658,7 @@ async function vibrantColor(input) {
8250
8658
  let data;
8251
8659
  let channels;
8252
8660
  try {
8253
- 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 });
8254
8662
  data = out.data;
8255
8663
  channels = out.info.channels;
8256
8664
  } catch {
@@ -8273,7 +8681,7 @@ async function vibrantColor(input) {
8273
8681
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
8274
8682
  if (best.score <= 0) {
8275
8683
  try {
8276
- const { dominant } = await sharp20(input).stats();
8684
+ const { dominant } = await sharp21(input).stats();
8277
8685
  return toHex(dominant.r, dominant.g, dominant.b);
8278
8686
  } catch {
8279
8687
  return null;
@@ -8782,8 +9190,22 @@ function registerShowcaseRoutes(app, deps) {
8782
9190
  }
8783
9191
 
8784
9192
  // src/routes/projects.ts
9193
+ var SORTS = ["newest", "oldest", "cost", "keepers"];
9194
+ var NAMES_TTL_MS = 2e3;
9195
+ var NAMES_MAX = 50;
8785
9196
  function registerProjectRoutes(app, deps) {
8786
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
+ };
8787
9209
  app.post("/api/projects", async (req, reply) => {
8788
9210
  const { brandId, name } = req.body;
8789
9211
  if (!core.store.getBrand(String(brandId))) return reply.status(404).send({ error: "brand not found" });
@@ -8807,10 +9229,61 @@ function registerProjectRoutes(app, deps) {
8807
9229
  const project = core.store.workspaceFor(brand.id);
8808
9230
  return {
8809
9231
  project,
8810
- nodes: core.store.treeFor(project.id),
9232
+ root: core.store.rootFor(project.id)?.id ?? null,
8811
9233
  sets: core.store.listSets(brand.id),
8812
- 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
8813
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) };
8814
9287
  });
8815
9288
  app.get("/api/brands/:id/sets", async (req, reply) => {
8816
9289
  const brand = core.store.getBrand(req.params.id);
@@ -8843,13 +9316,13 @@ function registerProjectRoutes(app, deps) {
8843
9316
  const nodeIds = (Array.isArray(raw) ? raw : []).map(String).filter((id) => core.store.getNode(id));
8844
9317
  if (nodeIds.length === 0) return reply.status(400).send({ error: "nodeIds must name at least one shot" });
8845
9318
  core.store.addToSet(set.id, nodeIds);
8846
- return { ok: true, added: nodeIds.length };
9319
+ return { ok: true, added: nodeIds.length, nodeIds: core.store.membersOf(set.id) };
8847
9320
  });
8848
9321
  app.delete("/api/sets/:id/nodes/:nodeId", async (req, reply) => {
8849
9322
  const { id, nodeId } = req.params;
8850
9323
  if (!core.store.getSet(id)) return reply.status(404).send({ error: "set not found" });
8851
9324
  core.store.removeFromSet(id, nodeId);
8852
- return { ok: true };
9325
+ return { ok: true, nodeIds: core.store.membersOf(id) };
8853
9326
  });
8854
9327
  }
8855
9328
 
@@ -8892,7 +9365,7 @@ async function buildExportZip(image, baseName, presetIds) {
8892
9365
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
8893
9366
  if (chosen.length === 0) throw new Error("No valid export presets selected");
8894
9367
  for (const p of chosen) {
8895
- 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;
8896
9369
  zip.file(`${baseName}-${p.id}.png`, buf);
8897
9370
  }
8898
9371
  return zip.generateAsync({ type: "nodebuffer" });
@@ -9038,23 +9511,136 @@ function readme(json, missing) {
9038
9511
  ${missing} referenced image${missing === 1 ? " was" : "s were"} missing and left out.` : ""
9039
9512
  ].join("\n");
9040
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
+ }
9041
9604
 
9042
9605
  // src/routes/images.ts
9606
+ var IMMUTABLE = "public, max-age=31536000, immutable";
9043
9607
  function registerImageRoutes(app, deps) {
9044
- const { core } = deps;
9608
+ const { core, thumbs } = deps;
9045
9609
  app.get("/api/images/:hash", async (req, reply) => {
9046
9610
  const hash = String(req.params.hash);
9047
- if (!core.images.has(hash)) return reply.status(404).send({ error: "image not found" });
9048
- reply.header("content-type", "image/png").header("cache-control", "public, max-age=31536000, immutable");
9049
- 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));
9050
9636
  });
9051
9637
  app.post("/api/images", async (req, reply) => {
9052
9638
  const part = await req.file();
9053
9639
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
9054
9640
  const buf = await part.toBuffer();
9055
9641
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
9056
- const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
9057
- 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();
9058
9644
  return { hash: core.images.save(png) };
9059
9645
  });
9060
9646
  app.post("/api/diff", async (req, reply) => {
@@ -9089,6 +9675,35 @@ function registerImageRoutes(app, deps) {
9089
9675
 
9090
9676
  // src/release/notes.data.ts
9091
9677
  var RELEASES = [
9678
+ {
9679
+ version: "0.8.2",
9680
+ date: "2026-09-03",
9681
+ sections: [
9682
+ {
9683
+ heading: "Security",
9684
+ body: "The server Scenri runs on and the URL parser under it are on their patched releases. Nothing about how Scenri works changes."
9685
+ }
9686
+ ]
9687
+ },
9688
+ {
9689
+ version: "0.8.1",
9690
+ date: "2026-09-03",
9691
+ title: "The same speed at any size.",
9692
+ sections: [
9693
+ {
9694
+ heading: "Create",
9695
+ 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."
9696
+ },
9697
+ {
9698
+ heading: "Library",
9699
+ 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."
9700
+ },
9701
+ {
9702
+ heading: "Fixes",
9703
+ 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."
9704
+ }
9705
+ ]
9706
+ },
9092
9707
  {
9093
9708
  version: "0.8.0",
9094
9709
  date: "2026-09-02",
@@ -9886,21 +10501,20 @@ function registerUpdateRoutes(app, deps) {
9886
10501
  });
9887
10502
  }
9888
10503
  function registerSystemRoutes(app, deps) {
9889
- const { core } = deps;
10504
+ const { core, thumbs } = deps;
9890
10505
  app.get("/api/home", async () => {
9891
10506
  const imagesDir = join(core.home, "images");
9892
- let files = 0, bytes = 0;
9893
- if (existsSync(imagesDir)) {
9894
- for (const f of readdirSync(imagesDir)) {
9895
- try {
9896
- bytes += statSync(join(imagesDir, f)).size;
9897
- files++;
9898
- } catch {
9899
- }
9900
- }
10507
+ let files = 0;
10508
+ let bytes = 0;
10509
+ const names = await readdir(imagesDir).catch(() => []);
10510
+ const sizes = await Promise.all(names.map((f) => stat(join(imagesDir, f)).catch(() => null)));
10511
+ for (const s of sizes) {
10512
+ if (!s?.isFile()) continue;
10513
+ bytes += s.size;
10514
+ files++;
9901
10515
  }
9902
10516
  const dbPath = join(core.home, "scenri.db");
9903
- const dbBytes = existsSync(dbPath) ? statSync(dbPath).size : 0;
10517
+ const dbBytes = (await stat(dbPath).catch(() => null))?.size ?? 0;
9904
10518
  return { dir: core.home, dbPath, images: files, bytes: bytes + dbBytes };
9905
10519
  });
9906
10520
  app.post("/api/system/reveal", async (_req, reply) => {
@@ -9945,10 +10559,11 @@ function registerSystemRoutes(app, deps) {
9945
10559
  removed++;
9946
10560
  }
9947
10561
  }
10562
+ thumbs.clear();
9948
10563
  return { ok: true, scope, projects: removed };
9949
10564
  }
9950
10565
  core.close();
9951
- for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "backups"]) {
10566
+ for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "thumbs", "backups"]) {
9952
10567
  rmSync(join(core.home, name), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
9953
10568
  }
9954
10569
  return { ok: true, scope };
@@ -9972,6 +10587,7 @@ function buildServer(opts) {
9972
10587
  registerAccessGuard(app, opts.access);
9973
10588
  const reserved = /* @__PURE__ */ new Map();
9974
10589
  const runningGenerations = /* @__PURE__ */ new Map();
10590
+ const thumbs = createThumbStore(core);
9975
10591
  const { scenes } = loadScenes(opts.templatesDir);
9976
10592
  const resolveScene = sceneResolver(scenes);
9977
10593
  const sceneFor = (brandJson) => (id) => brandSceneById(brandJson, id) ?? resolveScene(id);
@@ -10002,7 +10618,7 @@ function buildServer(opts) {
10002
10618
  // Measured as stored (post-toMarkPng), so the scrape judges the same
10003
10619
  // pixels the compiler will one day attach.
10004
10620
  probeLongEdge: async (buf) => {
10005
- const m = await sharp20(await toMarkPng(buf)).metadata();
10621
+ const m = await sharp21(await toMarkPng(buf)).metadata();
10006
10622
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
10007
10623
  },
10008
10624
  createdWith: `${meta.name}/${meta.version}`
@@ -10091,7 +10707,7 @@ function buildServer(opts) {
10091
10707
  fetchImpl: opts.fetchImpl,
10092
10708
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
10093
10709
  probeLongEdge: async (buf) => {
10094
- const m = await sharp20(await toMarkPng(buf)).metadata();
10710
+ const m = await sharp21(await toMarkPng(buf)).metadata();
10095
10711
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
10096
10712
  },
10097
10713
  createdWith: `${meta.name}/${meta.version}`
@@ -10388,7 +11004,19 @@ function buildServer(opts) {
10388
11004
  const { referenceImages, ...rest } = compiled2;
10389
11005
  return { ...rest, referenceCount: referenceImages.length, cap: engine.capabilities().maxReferenceImages };
10390
11006
  });
10391
- registerProjectRoutes(app, { core });
11007
+ registerProjectRoutes(app, {
11008
+ core,
11009
+ // what a search may match a brief token by: every name a token can carry today
11010
+ tokenNames: (brand) => [
11011
+ ...core.catalog.listLibraryProducts(brand.id, brand.json).map((p) => ({ id: p.id, name: p.name })),
11012
+ ...demoProducts.map((p) => ({ id: p.id, name: p.name })),
11013
+ ...brandCharacters(brand.json).map((c) => ({ id: String(c.id), name: String(c.name ?? "") })),
11014
+ ...presenters.map((p) => ({ id: p.id, name: p.name })),
11015
+ ...brandScenes(brand.json).map((sc) => ({ id: sc.id, name: sc.name })),
11016
+ ...scenes.map((sc) => ({ id: sc.id, name: sc.name }))
11017
+ ],
11018
+ engineNames: () => engines.all().map((e) => ({ id: e.capabilities().id, name: e.capabilities().displayName }))
11019
+ });
10392
11020
  registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup, codexRunner: engines.codexRunner });
10393
11021
  app.get("/api/engines", async () => {
10394
11022
  const list2 = [];
@@ -10452,11 +11080,11 @@ function buildServer(opts) {
10452
11080
  const out = [];
10453
11081
  for (const h of images) {
10454
11082
  const buf = core.images.read(h);
10455
- const meta2 = await sharp20(buf).metadata().catch(() => null);
11083
+ const meta2 = await sharp21(buf).metadata().catch(() => null);
10456
11084
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
10457
11085
  const oriented = (meta2.orientation ?? 1) !== 1;
10458
11086
  out.push(
10459
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
11087
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp21(buf).rotate().png().toBuffer())
10460
11088
  );
10461
11089
  }
10462
11090
  return out;
@@ -10468,7 +11096,7 @@ function buildServer(opts) {
10468
11096
  const out = [];
10469
11097
  for (const h of images) {
10470
11098
  const buf = core.images.read(h);
10471
- const meta2 = await sharp20(buf).metadata();
11099
+ const meta2 = await sharp21(buf).metadata();
10472
11100
  if (!meta2.width || !meta2.height) {
10473
11101
  out.push(h);
10474
11102
  continue;
@@ -10481,7 +11109,7 @@ function buildServer(opts) {
10481
11109
  }
10482
11110
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
10483
11111
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
10484
- const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
11112
+ const cropped = await sharp21(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
10485
11113
  app.log.info(
10486
11114
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
10487
11115
  "canvas: cropped a drifted frame to the asked ratio"
@@ -10500,7 +11128,7 @@ function buildServer(opts) {
10500
11128
  async function assertAspect(images, expect) {
10501
11129
  const want = expect.width / expect.height;
10502
11130
  for (const h of images) {
10503
- const meta2 = await sharp20(core.images.read(h)).metadata();
11131
+ const meta2 = await sharp21(core.images.read(h)).metadata();
10504
11132
  if (!meta2.width || !meta2.height) continue;
10505
11133
  const got = meta2.width / meta2.height;
10506
11134
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -10531,7 +11159,7 @@ function buildServer(opts) {
10531
11159
  if (post) own = await post(own);
10532
11160
  if (expect) await assertAspect(own, expect);
10533
11161
  try {
10534
- const meta2 = await sharp20(core.images.read(own[0])).metadata();
11162
+ const meta2 = await sharp21(core.images.read(own[0])).metadata();
10535
11163
  const node = core.store.getNode(id);
10536
11164
  if (node && meta2.width && meta2.height) {
10537
11165
  const brief = node.brief ?? {};
@@ -10546,6 +11174,7 @@ function buildServer(opts) {
10546
11174
  } catch {
10547
11175
  }
10548
11176
  core.store.completeNode(id, { images: own, costUsd: 0, durationMs: Date.now() - startedAt });
11177
+ thumbs.warm(own[0]);
10549
11178
  } catch (err) {
10550
11179
  core.store.failNode(id, String(err?.message ?? err));
10551
11180
  } finally {
@@ -10639,7 +11268,7 @@ function buildServer(opts) {
10639
11268
  crop: window
10640
11269
  });
10641
11270
  const work2 = async () => ({
10642
- images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
11271
+ images: [core.images.save(await sharp21(args.srcBuf).extract(window).png().toBuffer())],
10643
11272
  costUsd: 0
10644
11273
  });
10645
11274
  void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -10648,7 +11277,7 @@ function buildServer(opts) {
10648
11277
  return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
10649
11278
  };
10650
11279
  if (kind === "edit" && reshape === "crop") {
10651
- const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
11280
+ const rootForCrop = core.store.rootFor(project.id);
10652
11281
  if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
10653
11282
  const cropParentId = parentId ? String(parentId) : rootForCrop.id;
10654
11283
  if (brief && Array.isArray(brief.tokens)) {
@@ -10664,7 +11293,7 @@ function buildServer(opts) {
10664
11293
  if (!srcHash || !core.images.has(String(srcHash)))
10665
11294
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
10666
11295
  const srcBuf = core.images.read(String(srcHash));
10667
- const srcMeta = await sharp20(srcBuf).metadata();
11296
+ const srcMeta = await sharp21(srcBuf).metadata();
10668
11297
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
10669
11298
  return runCropNode({
10670
11299
  parentId: cropParentId,
@@ -10680,7 +11309,7 @@ function buildServer(opts) {
10680
11309
  if (!avail.ok) return reply.status(400).send({ error: avail.reason ?? "engine unavailable" });
10681
11310
  if (kind !== "generation" && kind !== "edit")
10682
11311
  return reply.status(400).send({ error: "kind must be generation|edit" });
10683
- const rootNode = core.store.treeFor(project.id).find((n) => n.kind === "root");
11312
+ const rootNode = core.store.rootFor(project.id);
10684
11313
  if (!rootNode) return reply.status(500).send({ error: "project has no root node" });
10685
11314
  const resolvedParentId = parentId ? String(parentId) : rootNode.id;
10686
11315
  const ctx = brandContext(core, project.brandId);
@@ -10879,7 +11508,7 @@ function buildServer(opts) {
10879
11508
  );
10880
11509
  }
10881
11510
  const srcBuf = core.images.read(String(srcHash));
10882
- const srcMeta = await sharp20(srcBuf).metadata();
11511
+ const srcMeta = await sharp21(srcBuf).metadata();
10883
11512
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
10884
11513
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
10885
11514
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
@@ -10913,7 +11542,7 @@ function buildServer(opts) {
10913
11542
  } else if (decision.op === "extend") {
10914
11543
  if (decision.assist) {
10915
11544
  expandAssist = { width: decision.assist.width, height: decision.assist.height };
10916
- workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
11545
+ workBuf = await sharp21(srcBuf).extract(decision.assist).png().toBuffer();
10917
11546
  workSize = { width: decision.assist.width, height: decision.assist.height };
10918
11547
  }
10919
11548
  expandPlan = planExpand(workSize, targetRatio);
@@ -10934,7 +11563,7 @@ function buildServer(opts) {
10934
11563
  const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
10935
11564
  if (fit.scale < 1) {
10936
11565
  expandPlan = fit.plan;
10937
- workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
11566
+ workBuf = await sharp21(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
10938
11567
  workSize = fit.source;
10939
11568
  extraWarnings.push(
10940
11569
  `${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.`
@@ -10957,7 +11586,7 @@ function buildServer(opts) {
10957
11586
  if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
10958
11587
  sentSize = stepped;
10959
11588
  budgetSourceHash = core.images.save(
10960
- await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
11589
+ await sharp21(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
10961
11590
  );
10962
11591
  if (!gradeOnlyAsk)
10963
11592
  extraWarnings.push(
@@ -11097,11 +11726,11 @@ function buildServer(opts) {
11097
11726
  const original = editedFrom ? core.images.read(editedFrom) : null;
11098
11727
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
11099
11728
  const enforceEditCanvas = async (images) => {
11100
- const srcMeta = await sharp20(original).metadata();
11729
+ const srcMeta = await sharp21(original).metadata();
11101
11730
  if (!srcMeta.width || !srcMeta.height) return images;
11102
11731
  const out = [];
11103
11732
  for (const h of images) {
11104
- const meta2 = await sharp20(core.images.read(h)).metadata();
11733
+ const meta2 = await sharp21(core.images.read(h)).metadata();
11105
11734
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
11106
11735
  const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
11107
11736
  pixelBudget: runEngine.capabilities().editPixelBudget
@@ -11131,7 +11760,7 @@ function buildServer(opts) {
11131
11760
  );
11132
11761
  out.push(
11133
11762
  core.images.save(
11134
- await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
11763
+ await sharp21(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
11135
11764
  )
11136
11765
  );
11137
11766
  try {
@@ -11153,7 +11782,7 @@ function buildServer(opts) {
11153
11782
  const out = [];
11154
11783
  for (const h of images) {
11155
11784
  const answer = core.images.read(h);
11156
- const got = await sharp20(answer).metadata();
11785
+ const got = await sharp21(answer).metadata();
11157
11786
  if (got.width !== plan.width || got.height !== plan.height)
11158
11787
  app.log.info(
11159
11788
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -11261,13 +11890,13 @@ function buildServer(opts) {
11261
11890
  const n = core.store.getNode(req.params.id);
11262
11891
  if (!n) return reply.status(404).send({ error: "node not found" });
11263
11892
  core.store.setKept(n.id, Boolean(req.body?.kept ?? true));
11264
- return core.store.getNode(n.id);
11893
+ return core.store.getFeedNode(n.id);
11265
11894
  });
11266
11895
  app.post("/api/nodes/:id/archive", async (req, reply) => {
11267
11896
  const n = core.store.getNode(req.params.id);
11268
11897
  if (!n) return reply.status(404).send({ error: "node not found" });
11269
11898
  core.store.setArchived(n.id, Boolean(req.body?.archived ?? true));
11270
- return core.store.getNode(n.id);
11899
+ return core.store.getFeedNode(n.id);
11271
11900
  });
11272
11901
  app.delete("/api/nodes/:id", async (req, reply) => {
11273
11902
  const n = core.store.getNode(req.params.id);
@@ -11289,7 +11918,7 @@ function buildServer(opts) {
11289
11918
  }
11290
11919
  return { ok: true, deleted };
11291
11920
  });
11292
- registerImageRoutes(app, { core });
11921
+ registerImageRoutes(app, { core, thumbs });
11293
11922
  const runtime = opts.runtime ?? { installKind: "unknown", supervised: false };
11294
11923
  const updates = createUpdateChecker({ name: meta.name, store: core.store, fetchImpl: opts.fetchImpl });
11295
11924
  app.decorate("updates", updates);
@@ -11314,12 +11943,13 @@ function buildServer(opts) {
11314
11943
  while (runningGenerations.size > 0 && Date.now() < deadline) {
11315
11944
  await new Promise((r) => setTimeout(r, 25));
11316
11945
  }
11946
+ await thumbs.settle();
11317
11947
  await app.close();
11318
11948
  core.close();
11319
11949
  })();
11320
11950
  return drained;
11321
11951
  });
11322
- registerSystemRoutes(app, { core });
11952
+ registerSystemRoutes(app, { core, thumbs });
11323
11953
  if (opts.studioDist && existsSync(opts.studioDist)) {
11324
11954
  const dist = opts.studioDist;
11325
11955
  app.register(fastifyStatic, { root: dist });
@@ -11383,7 +12013,6 @@ async function run() {
11383
12013
  const onlyThisMachine = LOOPBACK2.includes(HOST);
11384
12014
  const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
11385
12015
  const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
11386
- await repairPresenterCrops(core, (line) => console.log(line));
11387
12016
  const app = buildServer({
11388
12017
  core,
11389
12018
  engines,
@@ -11445,6 +12074,7 @@ async function run() {
11445
12074
  };
11446
12075
  process.on("SIGINT", shutdown);
11447
12076
  process.on("SIGTERM", shutdown);
12077
+ void repairPresenterCrops(core, (line) => console.log(line)).catch(() => void 0);
11448
12078
  app.updates.schedule();
11449
12079
  app.content.schedule();
11450
12080
  const query = token ? `/?t=${token}` : "";
@@ -11488,8 +12118,8 @@ async function verify() {
11488
12118
  const db = new Database2(":memory:");
11489
12119
  db.pragma("user_version");
11490
12120
  db.close();
11491
- const { default: sharp21 } = await import('sharp');
11492
- await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
12121
+ const { default: sharp22 } = await import('sharp');
12122
+ await sharp22({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
11493
12123
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
11494
12124
  } catch (err) {
11495
12125
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));