scenri 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -1,1938 +1,29 @@
1
- import { portBusyLines } from './chunk-WGIZJXNE.js';
2
1
  import { createUpdateChecker, classify, isReleaseTriplet, findNpm, stageVersion } from './chunk-FYQ5BAFA.js';
3
- import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
4
- import { compareSemver, newestStaged } from './chunk-4MAFHYAD.js';
5
- import { networkInterfaces, homedir, tmpdir } from 'os';
6
- import { sep, join, dirname, normalize } from 'path';
7
- import Database from 'better-sqlite3';
8
- import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
9
- import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, createReadStream, rmSync, readdirSync, statSync, renameSync } from 'fs';
10
- import { fileURLToPath } from 'url';
11
- import { readFile, copyFile, stat, access, readdir, mkdtemp, rm, rename, unlink, writeFile } from 'fs/promises';
12
- import { spawn } from 'child_process';
13
- import sharp21 from 'sharp';
14
- import Fastify from 'fastify';
15
- import fastifyStatic from '@fastify/static';
16
- import fastifyMultipart from '@fastify/multipart';
17
- import JSZip from 'jszip';
18
- import { Ajv2020 } from 'ajv/dist/2020.js';
19
- import addFormats from 'ajv-formats';
20
- import * as cheerio from 'cheerio';
21
- import { parse } from 'node-html-parser';
22
- import pixelmatch from 'pixelmatch';
23
- import { PNG } from 'pngjs';
24
-
25
- // ../core/src/slug.ts
26
- var slugify = (s, fallback = "brand") => s.normalize("NFKD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 48).replace(/-+$/g, "") || fallback;
27
- var slugifyWithId = (s, id, fallback = "brand") => {
28
- const base = slugify(s, fallback);
29
- return base === fallback ? `${fallback}-${id.slice(0, 8)}` : base;
30
- };
31
- var RESERVED_SLUGS = /* @__PURE__ */ new Set(["api", "assets", "b", "setup"]);
32
- function firstFree(base, taken) {
33
- for (let n = 1; ; n++) {
34
- const candidate = n === 1 ? base : `${base}-${n}`;
35
- if (!taken(candidate)) return candidate;
36
- }
37
- }
38
-
39
- // ../core/src/db.ts
40
- var MIGRATIONS = `
41
- CREATE TABLE IF NOT EXISTS brands (
42
- id TEXT PRIMARY KEY,
43
- slug TEXT NOT NULL,
44
- json TEXT NOT NULL,
45
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
46
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
47
- );
48
- CREATE TABLE IF NOT EXISTS projects (
49
- id TEXT PRIMARY KEY,
50
- brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
51
- name TEXT NOT NULL,
52
- slug TEXT,
53
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
54
- );
55
- CREATE TABLE IF NOT EXISTS nodes (
56
- id TEXT PRIMARY KEY,
57
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
58
- parent_id TEXT REFERENCES nodes(id),
59
- kind TEXT NOT NULL CHECK (kind IN ('root','generation','edit')),
60
- prompt TEXT NOT NULL DEFAULT '',
61
- engine_id TEXT NOT NULL DEFAULT '',
62
- status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','error')),
63
- images TEXT NOT NULL DEFAULT '[]',
64
- cost_usd REAL NOT NULL DEFAULT 0,
65
- kept INTEGER NOT NULL DEFAULT 0,
66
- error TEXT,
67
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
68
- );
69
- CREATE INDEX IF NOT EXISTS idx_nodes_project ON nodes(project_id);
70
- CREATE TABLE IF NOT EXISTS sets (
71
- id TEXT PRIMARY KEY,
72
- brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
73
- name TEXT NOT NULL,
74
- slug TEXT NOT NULL,
75
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
76
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
77
- );
78
- CREATE UNIQUE INDEX IF NOT EXISTS idx_sets_slug ON sets(brand_id, slug);
79
- CREATE TABLE IF NOT EXISTS set_nodes (
80
- set_id TEXT NOT NULL REFERENCES sets(id) ON DELETE CASCADE,
81
- node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
82
- added_at TEXT NOT NULL DEFAULT (datetime('now')),
83
- PRIMARY KEY (set_id, node_id)
84
- );
85
- CREATE INDEX IF NOT EXISTS idx_set_nodes_node ON set_nodes(node_id);
86
- CREATE TABLE IF NOT EXISTS cost_events (
87
- id INTEGER PRIMARY KEY AUTOINCREMENT,
88
- engine_id TEXT NOT NULL,
89
- node_id TEXT,
90
- cost_usd REAL NOT NULL,
91
- ts TEXT NOT NULL DEFAULT (datetime('now'))
92
- );
93
- CREATE TABLE IF NOT EXISTS spend_caps (
94
- engine_id TEXT PRIMARY KEY,
95
- monthly_cap_usd REAL NOT NULL
96
- );
97
- CREATE TABLE IF NOT EXISTS settings (
98
- key TEXT PRIMARY KEY,
99
- value TEXT NOT NULL
100
- );
101
- CREATE TABLE IF NOT EXISTS catalog_sources (
102
- id TEXT PRIMARY KEY,
103
- brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
104
- url TEXT NOT NULL,
105
- platform TEXT NOT NULL DEFAULT 'unknown',
106
- status TEXT NOT NULL DEFAULT 'idle',
107
- last_import_at TEXT,
108
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
109
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
110
- UNIQUE(brand_id, url)
111
- );
112
- CREATE TABLE IF NOT EXISTS catalog_products (
113
- id TEXT PRIMARY KEY,
114
- source_id TEXT NOT NULL REFERENCES catalog_sources(id) ON DELETE CASCADE,
115
- brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
116
- external_key TEXT NOT NULL,
117
- title TEXT NOT NULL,
118
- description_html TEXT,
119
- url TEXT NOT NULL,
120
- handle TEXT,
121
- vendor TEXT,
122
- product_type TEXT,
123
- tags TEXT NOT NULL DEFAULT '[]',
124
- category TEXT,
125
- price REAL,
126
- compare_at_price REAL,
127
- currency TEXT,
128
- available INTEGER,
129
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','unavailable')),
130
- raw TEXT,
131
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
132
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
133
- UNIQUE(source_id, external_key)
134
- );
135
- CREATE INDEX IF NOT EXISTS idx_catalog_products_brand ON catalog_products(brand_id);
136
- CREATE TABLE IF NOT EXISTS catalog_variants (
137
- id TEXT PRIMARY KEY,
138
- product_id TEXT NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
139
- external_key TEXT NOT NULL,
140
- title TEXT,
141
- sku TEXT,
142
- price REAL,
143
- compare_at_price REAL,
144
- currency TEXT,
145
- available INTEGER,
146
- options TEXT NOT NULL DEFAULT '{}'
147
- );
148
- CREATE TABLE IF NOT EXISTS catalog_images (
149
- id TEXT PRIMARY KEY,
150
- product_id TEXT NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
151
- source_url TEXT NOT NULL,
152
- asset_ref TEXT,
153
- width INTEGER,
154
- height INTEGER,
155
- position INTEGER NOT NULL DEFAULT 0,
156
- alt TEXT
157
- );
158
- CREATE INDEX IF NOT EXISTS idx_catalog_images_product ON catalog_images(product_id);
159
- CREATE TABLE IF NOT EXISTS catalog_collections (
160
- id TEXT PRIMARY KEY,
161
- source_id TEXT NOT NULL REFERENCES catalog_sources(id) ON DELETE CASCADE,
162
- external_key TEXT NOT NULL,
163
- title TEXT NOT NULL,
164
- url TEXT,
165
- UNIQUE(source_id, external_key)
166
- );
167
- CREATE TABLE IF NOT EXISTS catalog_collection_products (
168
- collection_id TEXT NOT NULL REFERENCES catalog_collections(id) ON DELETE CASCADE,
169
- product_id TEXT NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
170
- PRIMARY KEY (collection_id, product_id)
171
- );
172
- CREATE TABLE IF NOT EXISTS import_jobs (
173
- id TEXT PRIMARY KEY,
174
- brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
175
- source_id TEXT REFERENCES catalog_sources(id) ON DELETE SET NULL,
176
- url TEXT NOT NULL,
177
- platform TEXT NOT NULL DEFAULT 'unknown',
178
- stage TEXT NOT NULL DEFAULT 'queued',
179
- discovered INTEGER NOT NULL DEFAULT 0,
180
- fetched INTEGER NOT NULL DEFAULT 0,
181
- upserted INTEGER NOT NULL DEFAULT 0,
182
- images_done INTEGER NOT NULL DEFAULT 0,
183
- images_total INTEGER NOT NULL DEFAULT 0,
184
- errors TEXT NOT NULL DEFAULT '[]',
185
- warnings TEXT NOT NULL DEFAULT '[]',
186
- message TEXT,
187
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
188
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
189
- finished_at TEXT
190
- );
191
- CREATE INDEX IF NOT EXISTS idx_import_jobs_brand ON import_jobs(brand_id);
192
- `;
193
- function widenNodeStatusCheck(db) {
194
- const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='nodes'").get();
195
- if (!row || row.sql.includes("'cancelled'")) return;
196
- db.pragma("foreign_keys = OFF");
197
- db.transaction(() => {
198
- db.exec(`
199
- CREATE TABLE nodes_new (
200
- id TEXT PRIMARY KEY,
201
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
202
- parent_id TEXT REFERENCES nodes_new(id),
203
- kind TEXT NOT NULL CHECK (kind IN ('root','generation','edit')),
204
- prompt TEXT NOT NULL DEFAULT '',
205
- engine_id TEXT NOT NULL DEFAULT '',
206
- status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','error','cancelled')),
207
- images TEXT NOT NULL DEFAULT '[]',
208
- cost_usd REAL NOT NULL DEFAULT 0,
209
- kept INTEGER NOT NULL DEFAULT 0,
210
- error TEXT,
211
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
212
- overlays TEXT NOT NULL DEFAULT '{}',
213
- brief TEXT,
214
- archived INTEGER NOT NULL DEFAULT 0,
215
- duration_ms INTEGER,
216
- batch_id TEXT,
217
- batch_index INTEGER NOT NULL DEFAULT 0
218
- );
219
- INSERT INTO nodes_new
220
- SELECT id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept, error,
221
- created_at, overlays, brief, archived, duration_ms, batch_id, batch_index
222
- FROM nodes;
223
- DROP TABLE nodes;
224
- ALTER TABLE nodes_new RENAME TO nodes;
225
- CREATE INDEX IF NOT EXISTS idx_nodes_project ON nodes(project_id);
226
- `);
227
- })();
228
- db.pragma("foreign_keys = ON");
229
- }
230
- var SLUG_CHARS = /^[a-z0-9-]+$/;
231
- function backfillSlugs(db) {
232
- const brands = db.prepare("SELECT id, slug, json FROM brands ORDER BY created_at, id").all();
233
- const setBrand = db.prepare("UPDATE brands SET slug=? WHERE id=?");
234
- const takenBrand = /* @__PURE__ */ new Set();
235
- for (const b of brands) {
236
- const needsRederiving = /^brand(-\d+)?$/.test(b.slug) || !SLUG_CHARS.test(b.slug);
237
- let name;
238
- if (needsRederiving) {
239
- try {
240
- name = JSON.parse(b.json)?.meta?.name;
241
- } catch {
242
- }
243
- }
244
- const wanted = needsRederiving && name ? slugifyWithId(name, b.id) : b.slug;
245
- const slug2 = firstFree(wanted, (c) => RESERVED_SLUGS.has(c) || takenBrand.has(c));
246
- takenBrand.add(slug2);
247
- if (slug2 !== b.slug) setBrand.run(slug2, b.id);
248
- }
249
- db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_brands_slug ON brands(slug)");
250
- const projects = db.prepare("SELECT id, brand_id, name, slug FROM projects ORDER BY created_at, id").all();
251
- const setProject = db.prepare("UPDATE projects SET slug=? WHERE id=?");
252
- const takenProject = /* @__PURE__ */ new Map();
253
- for (const p of projects) {
254
- const inBrand = takenProject.get(p.brand_id) ?? /* @__PURE__ */ new Set();
255
- takenProject.set(p.brand_id, inBrand);
256
- const current = p.slug && SLUG_CHARS.test(p.slug) ? p.slug : null;
257
- const slug2 = firstFree(current || slugifyWithId(p.name, p.id, "project"), (c) => inBrand.has(c));
258
- inBrand.add(slug2);
259
- if (slug2 !== p.slug) setProject.run(slug2, p.id);
260
- }
261
- db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(brand_id, slug)");
262
- }
263
- function collapseProjects(db) {
264
- const brands = db.prepare("SELECT id FROM brands").all();
265
- const listProjects = db.prepare("SELECT id, name, slug FROM projects WHERE brand_id=? ORDER BY created_at, id");
266
- const shotsIn = db.prepare("SELECT id FROM nodes WHERE project_id=? AND kind!='root' ORDER BY created_at, id");
267
- const takenSlug = db.prepare("SELECT slug FROM sets WHERE brand_id=?");
268
- const addSet = db.prepare("INSERT INTO sets (id, brand_id, name, slug) VALUES (?,?,?,?)");
269
- const addMember = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
270
- for (const brand of brands) {
271
- const projects = listProjects.all(brand.id);
272
- if (projects.length <= 1) continue;
273
- const workspace = projects[0];
274
- db.transaction(() => {
275
- const taken = new Set(takenSlug.all(brand.id).map((r) => r.slug));
276
- for (const p of projects) {
277
- const shots = shotsIn.all(p.id);
278
- if (shots.length === 0) continue;
279
- const setId = randomUUID();
280
- const current = p.slug && SLUG_CHARS.test(p.slug) ? p.slug : null;
281
- const slug2 = firstFree(current || slugifyWithId(p.name, setId, "set"), (c) => taken.has(c));
282
- taken.add(slug2);
283
- addSet.run(setId, brand.id, p.name, slug2);
284
- for (const s of shots) addMember.run(setId, s.id);
285
- }
286
- db.prepare(
287
- "UPDATE nodes SET project_id=? WHERE project_id IN (SELECT id FROM projects WHERE brand_id=? AND id!=?)"
288
- ).run(workspace.id, brand.id, workspace.id);
289
- const roots = db.prepare("SELECT id FROM nodes WHERE project_id=? AND kind='root' ORDER BY created_at, id").all(workspace.id);
290
- const surplus = roots.slice(1).map((r) => r.id);
291
- if (surplus.length > 0) {
292
- const holes = surplus.map(() => "?").join(",");
293
- db.prepare(`UPDATE nodes SET parent_id=NULL WHERE parent_id IN (${holes})`).run(...surplus);
294
- db.prepare(`DELETE FROM nodes WHERE id IN (${holes})`).run(...surplus);
295
- }
296
- db.prepare("DELETE FROM projects WHERE brand_id=? AND id!=?").run(brand.id, workspace.id);
297
- })();
298
- }
299
- }
300
- var IMAGES_SPLIT_MARK = "v1";
301
- function splitMultiImageNodes(db) {
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();
308
- const multi = rows.filter((r) => {
309
- try {
310
- return JSON.parse(r.images).length > 1;
311
- } catch {
312
- return false;
313
- }
314
- });
315
- if (!multi.length) {
316
- mark();
317
- return;
318
- }
319
- const parse2 = (s) => {
320
- if (!s) return null;
321
- try {
322
- return JSON.parse(s);
323
- } catch {
324
- return null;
325
- }
326
- };
327
- const stampOf = (iso, minusMs) => {
328
- const t = (/* @__PURE__ */ new Date(`${iso.replace(" ", "T")}Z`)).getTime() - minusMs;
329
- const d = new Date(t);
330
- const p = (n, w = 2) => String(n).padStart(w, "0");
331
- return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
332
- };
333
- const updateOriginal = db.prepare(
334
- "UPDATE nodes SET images=?, overlays=?, brief=?, batch_id=?, batch_index=0 WHERE id=?"
335
- );
336
- const insertSibling = db.prepare(
337
- `INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept,
338
- error, created_at, overlays, brief, archived, duration_ms, batch_id, batch_index)
339
- VALUES (?,?,?,?,?,?,?,?,0,?,?,?,?,?,?,NULL,?,?)`
340
- );
341
- const childrenOf = db.prepare("SELECT id, brief FROM nodes WHERE parent_id=?");
342
- const repoint = db.prepare("UPDATE nodes SET parent_id=? WHERE id=?");
343
- const setsOf = db.prepare("SELECT set_id FROM set_nodes WHERE node_id=?");
344
- const addMember = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
345
- db.transaction(() => {
346
- for (const r of multi) {
347
- const images = JSON.parse(r.images);
348
- const overlays = parse2(r.overlays) ?? {};
349
- const brief = parse2(r.brief);
350
- const sizes = Array.isArray(brief?.rendered?.sizes) ? brief.rendered.sizes : null;
351
- const briefFor = (i) => {
352
- if (!brief) return null;
353
- const b = { ...brief, variants: images.length };
354
- if (brief.rendered) {
355
- const { requested: _req, variantIndexes: _vi, ...rendered } = brief.rendered;
356
- b.rendered = { ...rendered, ...sizes ? { sizes: sizes[i] !== void 0 ? [sizes[i]] : [] } : {} };
357
- }
358
- return JSON.stringify(b);
359
- };
360
- const siblingIds = [r.id];
361
- updateOriginal.run(
362
- JSON.stringify([images[0]]),
363
- JSON.stringify(overlays["0"] !== void 0 ? { "0": overlays["0"] } : {}),
364
- briefFor(0),
365
- r.id,
366
- r.id
367
- );
368
- for (let i = 1; i < images.length; i++) {
369
- const id = randomUUID();
370
- siblingIds.push(id);
371
- insertSibling.run(
372
- id,
373
- r.project_id,
374
- r.parent_id,
375
- r.kind,
376
- r.prompt,
377
- r.engine_id,
378
- r.status,
379
- JSON.stringify([images[i]]),
380
- r.kept,
381
- r.error,
382
- stampOf(r.created_at, i),
383
- JSON.stringify(overlays[String(i)] !== void 0 ? { "0": overlays[String(i)] } : {}),
384
- briefFor(i),
385
- r.archived,
386
- r.id,
387
- i
388
- );
389
- }
390
- for (const child of childrenOf.all(r.id)) {
391
- const src = parse2(child.brief)?.sourceImage;
392
- if (typeof src !== "string") continue;
393
- const at = images.indexOf(src);
394
- if (at > 0) repoint.run(siblingIds[at], child.id);
395
- }
396
- for (const s of setsOf.all(r.id)) {
397
- for (let i = 1; i < siblingIds.length; i++) addMember.run(s.set_id, siblingIds[i]);
398
- }
399
- }
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
- })();
484
- }
485
- var SCHEMA_VERSION = 2;
486
- var SchemaTooNewError = class extends Error {
487
- constructor(found, supported, backupsDir) {
488
- super(
489
- `This library was written by a newer Scenri (schema ${found}; this build understands ${supported}). Update and retry: npx scenri@latest` + (backupsDir ? ` (a pre-migration snapshot of the library is kept in ${backupsDir})` : "")
490
- );
491
- this.name = "SchemaTooNewError";
492
- }
493
- };
494
- function backupBeforeMigration(db, homeDir, fromVersion) {
495
- const dir = join(homeDir, "backups");
496
- mkdirSync(dir, { recursive: true });
497
- const d = /* @__PURE__ */ new Date();
498
- const p = (n) => String(n).padStart(2, "0");
499
- const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
500
- db.pragma("wal_checkpoint(TRUNCATE)");
501
- db.prepare("VACUUM INTO ?").run(join(dir, `scenri-v${fromVersion}-${stamp}.db`));
502
- const old = readdirSync(dir).filter((f) => /^scenri-v\d+-\d{8}-\d{6}\.db$/.test(f)).sort((a, b) => a.slice(-18).localeCompare(b.slice(-18)));
503
- for (const f of old.slice(0, Math.max(0, old.length - 3))) rmSync(join(dir, f));
504
- }
505
- function openDb(homeDir) {
506
- mkdirSync(homeDir, { recursive: true, mode: 448 });
507
- const dbPath = join(homeDir, "scenri.db");
508
- const preExisting = existsSync(dbPath);
509
- const db = new Database(dbPath);
510
- try {
511
- chmodSync(dbPath, 384);
512
- } catch {
513
- }
514
- db.pragma("journal_mode = WAL");
515
- db.pragma("foreign_keys = ON");
516
- const found = db.pragma("user_version", { simple: true });
517
- if (found > SCHEMA_VERSION) {
518
- db.close();
519
- throw new SchemaTooNewError(found, SCHEMA_VERSION, join(homeDir, "backups"));
520
- }
521
- if (preExisting && found < SCHEMA_VERSION) backupBeforeMigration(db, homeDir, found);
522
- db.exec(MIGRATIONS);
523
- const nodeCols = db.pragma("table_info(nodes)").map((c) => c.name);
524
- if (!nodeCols.includes("overlays")) {
525
- db.exec("ALTER TABLE nodes ADD COLUMN overlays TEXT NOT NULL DEFAULT '{}'");
526
- }
527
- if (!nodeCols.includes("brief")) {
528
- db.exec("ALTER TABLE nodes ADD COLUMN brief TEXT");
529
- }
530
- if (!nodeCols.includes("archived")) {
531
- db.exec("ALTER TABLE nodes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
532
- }
533
- if (!nodeCols.includes("duration_ms")) {
534
- db.exec("ALTER TABLE nodes ADD COLUMN duration_ms INTEGER");
535
- }
536
- if (!nodeCols.includes("batch_id")) {
537
- db.exec("ALTER TABLE nodes ADD COLUMN batch_id TEXT");
538
- }
539
- if (!nodeCols.includes("batch_index")) {
540
- db.exec("ALTER TABLE nodes ADD COLUMN batch_index INTEGER NOT NULL DEFAULT 0");
541
- }
542
- const projectCols = db.pragma("table_info(projects)").map((c) => c.name);
543
- if (!projectCols.includes("slug")) {
544
- db.exec("ALTER TABLE projects ADD COLUMN slug TEXT");
545
- }
546
- const catalogCols = db.pragma("table_info(catalog_products)").map((c) => c.name);
547
- for (const col of ["variant", "material", "dimensions"]) {
548
- if (!catalogCols.includes(col)) db.exec(`ALTER TABLE catalog_products ADD COLUMN ${col} TEXT`);
549
- }
550
- const catalogImgCols = db.pragma("table_info(catalog_images)").map((c) => c.name);
551
- if (!catalogImgCols.includes("angle")) db.exec("ALTER TABLE catalog_images ADD COLUMN angle TEXT");
552
- if (!catalogImgCols.includes("excluded")) {
553
- db.exec("ALTER TABLE catalog_images ADD COLUMN excluded INTEGER NOT NULL DEFAULT 0");
554
- }
555
- widenNodeStatusCheck(db);
556
- ensureIndexes(db);
557
- backfillSlugs(db);
558
- collapseProjects(db);
559
- splitMultiImageNodes(db);
560
- ensureSearch(db);
561
- db.prepare(
562
- "UPDATE nodes SET status='error', error='interrupted: server restarted mid-generation' WHERE status='running'"
563
- ).run();
564
- db.pragma(`user_version = ${SCHEMA_VERSION}`);
565
- return db;
566
- }
567
- function createImageStore(homeDir) {
568
- const dir = join(homeDir, "images");
569
- mkdirSync(dir, { recursive: true, mode: 448 });
570
- const fileFor = (hash) => join(dir, `${hash}.png`);
571
- return {
572
- save(buf) {
573
- const hash = createHash("sha256").update(buf).digest("hex").slice(0, 32);
574
- const file = fileFor(hash);
575
- if (!existsSync(file)) writeFileSync(file, buf);
576
- return hash;
577
- },
578
- pathFor(hash) {
579
- if (!/^[a-f0-9]{32}$/.test(hash)) throw new Error("invalid image hash");
580
- return fileFor(hash);
581
- },
582
- read(hash) {
583
- return readFileSync(this.pathFor(hash));
584
- },
585
- has(hash) {
586
- return /^[a-f0-9]{32}$/.test(hash) && existsSync(fileFor(hash));
587
- }
588
- };
589
- }
590
-
591
- // ../core/src/ledger.ts
592
- var SpendCapError = class extends Error {
593
- constructor(engineId, cap2, spent, estimate) {
594
- super(
595
- `Spend cap for ${engineId}: $${cap2.toFixed(2)}/mo. Spent $${spent.toFixed(2)}, next ~$${estimate.toFixed(2)} would exceed it.`
596
- );
597
- this.name = "SpendCapError";
598
- }
599
- };
600
- function createLedger(db) {
601
- const monthStart = () => {
602
- const d = /* @__PURE__ */ new Date();
603
- return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-01`;
604
- };
605
- return {
606
- recordCost(engineId, nodeId, usd) {
607
- if (usd > 0)
608
- db.prepare("INSERT INTO cost_events (engine_id, node_id, cost_usd) VALUES (?,?,?)").run(engineId, nodeId, usd);
609
- },
610
- monthlySpend(engineId) {
611
- const row = db.prepare("SELECT COALESCE(SUM(cost_usd),0) s FROM cost_events WHERE engine_id=? AND ts >= ?").get(engineId, monthStart());
612
- return row.s;
613
- },
614
- totalSpendByEngine() {
615
- const rows = db.prepare("SELECT engine_id, COALESCE(SUM(cost_usd),0) s FROM cost_events WHERE ts >= ? GROUP BY engine_id").all(monthStart());
616
- return Object.fromEntries(rows.map((r) => [r.engine_id, r.s]));
617
- },
618
- setCap(engineId, capUsd) {
619
- if (capUsd === null) db.prepare("DELETE FROM spend_caps WHERE engine_id=?").run(engineId);
620
- else
621
- db.prepare(
622
- "INSERT INTO spend_caps (engine_id, monthly_cap_usd) VALUES (?,?) ON CONFLICT(engine_id) DO UPDATE SET monthly_cap_usd=excluded.monthly_cap_usd"
623
- ).run(engineId, capUsd);
624
- },
625
- capFor(engineId) {
626
- const row = db.prepare("SELECT monthly_cap_usd c FROM spend_caps WHERE engine_id=?").get(engineId);
627
- return row ? row.c : null;
628
- },
629
- caps() {
630
- const rows = db.prepare("SELECT engine_id, monthly_cap_usd c FROM spend_caps").all();
631
- return Object.fromEntries(rows.map((r) => [r.engine_id, r.c]));
632
- },
633
- assertUnderCap(engineId, nextEstimate) {
634
- const cap2 = this.capFor(engineId);
635
- if (cap2 === null) return;
636
- const spent = this.monthlySpend(engineId);
637
- if (spent + nextEstimate > cap2) throw new SpendCapError(engineId, cap2, spent, nextEstimate);
638
- }
639
- };
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;
666
- function uniqueSlug(db, name, id) {
667
- const stmt = db.prepare("SELECT 1 FROM brands WHERE slug=? AND id IS NOT ?");
668
- return firstFree(slugifyWithId(name, id), (c) => RESERVED_SLUGS.has(c) || !!stmt.get(c, id));
669
- }
670
- function uniqueProjectSlug(db, brandId, name, id) {
671
- const stmt = db.prepare("SELECT 1 FROM projects WHERE brand_id=? AND slug=?");
672
- return firstFree(slugifyWithId(name, id, "project"), (c) => !!stmt.get(brandId, c));
673
- }
674
- function uniqueSetSlug(db, brandId, name, id) {
675
- const stmt = db.prepare("SELECT 1 FROM sets WHERE brand_id=? AND slug=? AND id IS NOT ?");
676
- return firstFree(slugifyWithId(name, id, "set"), (c) => !!stmt.get(brandId, c, id));
677
- }
678
- var SET_NAME_SEP = String.fromCharCode(31);
679
- function rowToSet(r) {
680
- return {
681
- id: r.id,
682
- brandId: r.brand_id,
683
- name: r.name,
684
- slug: r.slug,
685
- createdAt: r.created_at,
686
- updatedAt: r.updated_at
687
- };
688
- }
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) {
697
- return {
698
- id: r.id,
699
- projectId: r.project_id,
700
- parentId: r.parent_id,
701
- kind: r.kind,
702
- promptHead: r.prompt_head ?? headOf(r.prompt),
703
- engineId: r.engine_id,
704
- status: r.status,
705
- images: JSON.parse(r.images),
706
- costUsd: r.cost_usd,
707
- durationMs: r.duration_ms ?? null,
708
- kept: !!r.kept,
709
- error: r.error,
710
- createdAt: r.created_at,
711
- brief: r.brief ? JSON.parse(r.brief) : null,
712
- archived: !!r.archived,
713
- batchId: r.batch_id ?? null,
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 ?? "{}")
723
- };
724
- }
725
- var lastBatchStamp = 0;
726
- function batchStamps(count) {
727
- const base = Math.max(Date.now(), lastBatchStamp + count);
728
- lastBatchStamp = base;
729
- const p = (n, w = 2) => String(n).padStart(w, "0");
730
- return Array.from({ length: count }, (_, i) => {
731
- const d = new Date(base - i);
732
- return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
733
- });
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;
821
- function createStore(db) {
822
- return {
823
- // brands
824
- createBrand(json) {
825
- const id = randomUUID();
826
- db.prepare("INSERT INTO brands (id, slug, json) VALUES (?,?,?)").run(
827
- id,
828
- uniqueSlug(db, json.meta.name, id),
829
- JSON.stringify(json)
830
- );
831
- return this.getBrand(id);
832
- },
833
- getBrand(id) {
834
- const r = db.prepare("SELECT * FROM brands WHERE id=?").get(id);
835
- return r ? { id: r.id, slug: r.slug, json: JSON.parse(r.json), createdAt: r.created_at, updatedAt: r.updated_at } : null;
836
- },
837
- listBrands() {
838
- return db.prepare("SELECT * FROM brands ORDER BY created_at").all().map((r) => ({
839
- id: r.id,
840
- slug: r.slug,
841
- json: JSON.parse(r.json),
842
- createdAt: r.created_at,
843
- updatedAt: r.updated_at
844
- }));
845
- },
846
- updateBrand(id, json) {
847
- db.prepare("UPDATE brands SET json=?, slug=?, updated_at=datetime('now') WHERE id=?").run(
848
- JSON.stringify(json),
849
- uniqueSlug(db, json.meta.name, id),
850
- id
851
- );
852
- return this.getBrand(id);
853
- },
854
- deleteBrand(id) {
855
- db.prepare("DELETE FROM brands WHERE id=?").run(id);
856
- },
857
- // projects
858
- createProject(brandId, name) {
859
- const id = randomUUID();
860
- db.prepare("INSERT INTO projects (id, brand_id, name, slug) VALUES (?,?,?,?)").run(
861
- id,
862
- brandId,
863
- name,
864
- uniqueProjectSlug(db, brandId, name, id)
865
- );
866
- const rootId = randomUUID();
867
- db.prepare("INSERT INTO nodes (id, project_id, parent_id, kind, status) VALUES (?,?,NULL,'root','done')").run(
868
- rootId,
869
- id
870
- );
871
- return { project: this.getProject(id), root: this.getNode(rootId) };
872
- },
873
- deleteProject(id) {
874
- db.prepare("DELETE FROM projects WHERE id=?").run(id);
875
- },
876
- getProject(id) {
877
- const r = db.prepare("SELECT * FROM projects WHERE id=?").get(id);
878
- return r ? { id: r.id, brandId: r.brand_id, name: r.name, slug: r.slug, createdAt: r.created_at } : null;
879
- },
880
- listProjects(brandId) {
881
- return db.prepare("SELECT * FROM projects WHERE brand_id=? ORDER BY created_at").all(brandId).map(
882
- (r) => ({
883
- id: r.id,
884
- brandId: r.brand_id,
885
- name: r.name,
886
- slug: r.slug,
887
- createdAt: r.created_at
888
- })
889
- );
890
- },
891
- /**
892
- * The brand's one project, made on demand.
893
- *
894
- * Every node still hangs from a project root, but that is plumbing now, not
895
- * a place: nothing in the UI names it and nothing but this creates one. The
896
- * five buttons that used to invent a project each call this instead, so a
897
- * brand ends up with exactly one no matter which door you came through.
898
- */
899
- workspaceFor(brandId) {
900
- return this.listProjects(brandId)[0] ?? this.createProject(brandId, "Workspace").project;
901
- },
902
- // sets
903
- createSet(brandId, name) {
904
- const id = randomUUID();
905
- db.prepare("INSERT INTO sets (id, brand_id, name, slug) VALUES (?,?,?,?)").run(
906
- id,
907
- brandId,
908
- name,
909
- uniqueSetSlug(db, brandId, name, id)
910
- );
911
- return this.getSet(id);
912
- },
913
- getSet(id) {
914
- const r = db.prepare("SELECT * FROM sets WHERE id=?").get(id);
915
- return r ? rowToSet(r) : null;
916
- },
917
- /**
918
- * Most recently touched first, everywhere. The old project lists each chose
919
- * their own order — one ascending by creation, one descending, one capped
920
- * before it sorted — so the same six names came back in three different
921
- * sequences depending on which control you opened.
922
- */
923
- listSets(brandId) {
924
- return db.prepare("SELECT * FROM sets WHERE brand_id=? ORDER BY updated_at DESC, created_at DESC").all(brandId).map(rowToSet);
925
- },
926
- renameSet(id, name) {
927
- const current = this.getSet(id);
928
- if (!current) return null;
929
- db.prepare("UPDATE sets SET name=?, slug=?, updated_at=datetime('now') WHERE id=?").run(
930
- name,
931
- uniqueSetSlug(db, current.brandId, name, id),
932
- id
933
- );
934
- return this.getSet(id);
935
- },
936
- /** The set goes; the shots do not. Membership is a label, never ownership. */
937
- deleteSet(id) {
938
- db.prepare("DELETE FROM sets WHERE id=?").run(id);
939
- },
940
- addToSet(setId, nodeIds) {
941
- const add = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
942
- db.transaction(() => {
943
- for (const nodeId of nodeIds) add.run(setId, nodeId);
944
- db.prepare("UPDATE sets SET updated_at=datetime('now') WHERE id=?").run(setId);
945
- })();
946
- },
947
- removeFromSet(setId, nodeId) {
948
- db.transaction(() => {
949
- db.prepare("DELETE FROM set_nodes WHERE set_id=? AND node_id=?").run(setId, nodeId);
950
- db.prepare("UPDATE sets SET updated_at=datetime('now') WHERE id=?").run(setId);
951
- })();
952
- },
953
- /**
954
- * Every membership in the brand, keyed by set. One query rather than one
955
- * per set, because the workspace screen filters on the client: the feed is
956
- * already loaded, and a set is only a subset of it.
957
- */
958
- membershipFor(brandId) {
959
- const rows = db.prepare(
960
- `SELECT sn.set_id, sn.node_id
961
- FROM set_nodes sn JOIN sets s ON s.id = sn.set_id
962
- WHERE s.brand_id = ?
963
- ORDER BY sn.added_at`
964
- ).all(brandId);
965
- const out = {};
966
- for (const r of rows) {
967
- if (!out[r.set_id]) out[r.set_id] = [];
968
- out[r.set_id].push(r.node_id);
969
- }
970
- return out;
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
- },
976
- // nodes / version tree
977
- addNode(input) {
978
- if (input.parentId) {
979
- const parent = this.getNode(input.parentId);
980
- if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
981
- }
982
- const id = randomUUID();
983
- db.prepare(
984
- "INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at) VALUES (?,?,?,?,?,?, strftime('%Y-%m-%d %H:%M:%f','now'))"
985
- ).run(id, input.projectId, input.parentId, input.kind, input.prompt, input.engineId);
986
- return this.getNode(id);
987
- },
988
- /**
989
- * One multi-shot request, N first-class sibling nodes, one transaction.
990
- * Slot 0 gets the newest stamp (see batchStamps) so the newest-first feed
991
- * reads the batch in request order; batch_id is the first node's id, held
992
- * by every sibling including the first, and stays null for a single send
993
- * — one shot is not a batch.
994
- */
995
- addNodes(input) {
996
- if (input.parentId) {
997
- const parent = this.getNode(input.parentId);
998
- if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
999
- }
1000
- const count = Math.max(1, Math.floor(input.count));
1001
- const ids = Array.from({ length: count }, () => randomUUID());
1002
- const stamps = batchStamps(count);
1003
- const batchId = count > 1 ? ids[0] : null;
1004
- const insert = db.prepare(
1005
- "INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at, batch_id, batch_index) VALUES (?,?,?,?,?,?,?,?,?)"
1006
- );
1007
- db.transaction(() => {
1008
- for (let i = 0; i < count; i++) {
1009
- insert.run(
1010
- ids[i],
1011
- input.projectId,
1012
- input.parentId,
1013
- input.kind,
1014
- input.prompt,
1015
- input.engineId,
1016
- stamps[i],
1017
- batchId,
1018
- i
1019
- );
1020
- }
1021
- })();
1022
- return ids.map((id) => this.getNode(id));
1023
- },
1024
- completeNode(id, result) {
1025
- db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=?, duration_ms=? WHERE id=?").run(
1026
- JSON.stringify(result.images),
1027
- result.costUsd,
1028
- result.durationMs ?? null,
1029
- id
1030
- );
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
- },
1042
- failNode(id, error) {
1043
- db.prepare("UPDATE nodes SET status='error', error=? WHERE id=?").run(error, id);
1044
- },
1045
- cancelNode(id) {
1046
- db.prepare("UPDATE nodes SET status='cancelled' WHERE id=?").run(id);
1047
- },
1048
- getNode(id) {
1049
- const r = db.prepare(`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.id=?`).get(id);
1050
- return r ? rowToNode(r) : null;
1051
- },
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))
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;
1186
- },
1187
- /**
1188
- * Every piece of work a brand has in flight, plus whatever finished lately,
1189
- * in one query. The bar outlives the project screen, so the thing that used
1190
- * to be answerable only by polling one tree at a time has to be answerable
1191
- * without knowing which project you are looking at.
1192
- *
1193
- * The cutoff is computed in SQL rather than passed in: created_at is
1194
- * SQLite's own datetime('now') text, and comparing that against a caller's
1195
- * ISO string is a silent, timezone-shaped mis-filter.
1196
- */
1197
- recentActivity(brandId, limit = 60) {
1198
- const cols = `${FEED_COLS}, (
1199
- SELECT group_concat(s.name, char(31))
1200
- FROM set_nodes sn JOIN sets s ON s.id = sn.set_id
1201
- WHERE sn.node_id = n.id
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 });
1216
- return rows.map((r) => ({
1217
- ...rowToFeedNode(r),
1218
- setNames: r.set_names ? String(r.set_names).split(SET_NAME_SEP) : []
1219
- }));
1220
- },
1221
- setKept(id, kept) {
1222
- db.prepare("UPDATE nodes SET kept=? WHERE id=?").run(kept ? 1 : 0, id);
1223
- },
1224
- /**
1225
- * Archiving also clears the keeper mark.
1226
- *
1227
- * The two flags were independent, and the Keepers lens reads the live list,
1228
- * so archiving a keeper removed it from Keepers and from the Keepers count
1229
- * without saying anything: the star stayed lit on a shot that was no longer
1230
- * in the shortlist it claimed to be in. Keepers is a live shortlist and
1231
- * archive means put away, so one clears the other and the two can never
1232
- * disagree. Restoring does not re-star: the judgement was made once and
1233
- * putting the shot back is not the same as making it again.
1234
- */
1235
- setArchived(id, archived) {
1236
- if (archived) db.prepare("UPDATE nodes SET archived=1, kept=0 WHERE id=?").run(id);
1237
- else db.prepare("UPDATE nodes SET archived=0 WHERE id=?").run(id);
1238
- },
1239
- /** Permanent. Orphans any children rather than blocking or cascading —
1240
- * same technique collapseProjects already uses for a surplus root. */
1241
- deleteNode(id) {
1242
- db.prepare("UPDATE nodes SET parent_id=NULL WHERE parent_id=?").run(id);
1243
- db.prepare("DELETE FROM nodes WHERE id=?").run(id);
1244
- },
1245
- setBrief(id, brief) {
1246
- db.prepare("UPDATE nodes SET brief=? WHERE id=?").run(JSON.stringify(brief), id);
1247
- },
1248
- setOverlays(id, overlays) {
1249
- db.prepare("UPDATE nodes SET overlays=? WHERE id=?").run(JSON.stringify(overlays), id);
1250
- },
1251
- // settings
1252
- getSetting(key) {
1253
- const r = db.prepare("SELECT value FROM settings WHERE key=?").get(key);
1254
- return r ? r.value : null;
1255
- },
1256
- setSetting(key, value) {
1257
- db.prepare(
1258
- "INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
1259
- ).run(key, value);
1260
- },
1261
- allSettings() {
1262
- const rows = db.prepare("SELECT key, value FROM settings").all();
1263
- return Object.fromEntries(rows.map((r) => [r.key, r.value]));
1264
- }
1265
- };
1266
- }
1267
-
1268
- // ../core/src/catalog/rows.ts
1269
- function rowSource(r) {
1270
- return {
1271
- id: r.id,
1272
- brandId: r.brand_id,
1273
- url: r.url,
1274
- platform: r.platform,
1275
- status: r.status,
1276
- lastImportAt: r.last_import_at,
1277
- createdAt: r.created_at,
1278
- updatedAt: r.updated_at
1279
- };
1280
- }
1281
- function rowProduct(r) {
1282
- return {
1283
- id: r.id,
1284
- sourceId: r.source_id,
1285
- brandId: r.brand_id,
1286
- externalKey: r.external_key,
1287
- title: r.title,
1288
- descriptionHtml: r.description_html,
1289
- url: r.url,
1290
- handle: r.handle,
1291
- vendor: r.vendor,
1292
- productType: r.product_type,
1293
- tags: JSON.parse(r.tags || "[]"),
1294
- category: r.category,
1295
- price: r.price,
1296
- compareAtPrice: r.compare_at_price,
1297
- currency: r.currency,
1298
- available: r.available == null ? null : !!r.available,
1299
- status: r.status,
1300
- raw: r.raw ? JSON.parse(r.raw) : null,
1301
- variant: r.variant ?? null,
1302
- material: r.material ?? null,
1303
- dimensions: r.dimensions ?? null,
1304
- createdAt: r.created_at,
1305
- updatedAt: r.updated_at
1306
- };
1307
- }
1308
- function rowJob(r) {
1309
- return {
1310
- id: r.id,
1311
- brandId: r.brand_id,
1312
- sourceId: r.source_id,
1313
- url: r.url,
1314
- platform: r.platform,
1315
- stage: r.stage,
1316
- discovered: r.discovered,
1317
- fetched: r.fetched,
1318
- upserted: r.upserted,
1319
- imagesDone: r.images_done,
1320
- imagesTotal: r.images_total,
1321
- errors: JSON.parse(r.errors || "[]"),
1322
- warnings: JSON.parse(r.warnings || "[]"),
1323
- message: r.message,
1324
- createdAt: r.created_at,
1325
- updatedAt: r.updated_at,
1326
- finishedAt: r.finished_at
1327
- };
1328
- }
1329
- var jobById = (db, id) => {
1330
- const r = db.prepare("SELECT * FROM import_jobs WHERE id=?").get(id);
1331
- return r ? rowJob(r) : null;
1332
- };
1333
- var productById = (db, id) => {
1334
- const r = db.prepare("SELECT * FROM catalog_products WHERE id=?").get(id);
1335
- return r ? rowProduct(r) : null;
1336
- };
1337
- var productsFor = (db, brandId) => db.prepare(
1338
- "SELECT * FROM catalog_products WHERE brand_id=? AND status!='unavailable' ORDER BY title COLLATE NOCASE"
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
- };
1390
- var variantsFor = (db, productId) => db.prepare("SELECT * FROM catalog_variants WHERE product_id=?").all(productId).map((r) => ({
1391
- id: r.id,
1392
- productId: r.product_id,
1393
- externalKey: r.external_key,
1394
- title: r.title,
1395
- sku: r.sku,
1396
- price: r.price,
1397
- compareAtPrice: r.compare_at_price,
1398
- currency: r.currency,
1399
- available: r.available == null ? null : !!r.available,
1400
- options: JSON.parse(r.options || "{}")
1401
- }));
1402
- var imagesFor = (db, productId) => db.prepare("SELECT * FROM catalog_images WHERE product_id=? ORDER BY position").all(productId).map(
1403
- (r) => ({
1404
- id: r.id,
1405
- productId: r.product_id,
1406
- sourceUrl: r.source_url,
1407
- assetRef: r.asset_ref,
1408
- width: r.width,
1409
- height: r.height,
1410
- position: r.position,
1411
- alt: r.alt,
1412
- angle: r.angle ?? null,
1413
- excluded: !!r.excluded
1414
- })
1415
- );
1416
-
1417
- // ../core/src/catalog/sources.ts
1418
- function sourceMethods(db) {
1419
- return {
1420
- upsertSource(brandId, url, platform) {
1421
- const existing = db.prepare("SELECT * FROM catalog_sources WHERE brand_id=? AND url=?").get(brandId, url);
1422
- if (existing) {
1423
- db.prepare("UPDATE catalog_sources SET platform=?, updated_at=datetime('now') WHERE id=?").run(
1424
- platform,
1425
- existing.id
1426
- );
1427
- return rowSource(db.prepare("SELECT * FROM catalog_sources WHERE id=?").get(existing.id));
1428
- }
1429
- const id = randomUUID();
1430
- db.prepare("INSERT INTO catalog_sources (id, brand_id, url, platform, status) VALUES (?,?,?,?, 'idle')").run(
1431
- id,
1432
- brandId,
1433
- url,
1434
- platform
1435
- );
1436
- return rowSource(db.prepare("SELECT * FROM catalog_sources WHERE id=?").get(id));
1437
- },
1438
- getSourceForBrand(brandId) {
1439
- const r = db.prepare("SELECT * FROM catalog_sources WHERE brand_id=? ORDER BY updated_at DESC LIMIT 1").get(brandId);
1440
- return r ? rowSource(r) : null;
1441
- },
1442
- getSource(id) {
1443
- const r = db.prepare("SELECT * FROM catalog_sources WHERE id=?").get(id);
1444
- return r ? rowSource(r) : null;
1445
- },
1446
- setSourceStatus(id, status, touchImport = false) {
1447
- if (touchImport) {
1448
- db.prepare(
1449
- "UPDATE catalog_sources SET status=?, last_import_at=datetime('now'), updated_at=datetime('now') WHERE id=?"
1450
- ).run(status, id);
1451
- } else {
1452
- db.prepare("UPDATE catalog_sources SET status=?, updated_at=datetime('now') WHERE id=?").run(status, id);
1453
- }
1454
- }
1455
- };
1456
- }
1457
- function jobMethods(db) {
1458
- return {
1459
- createJob(input) {
1460
- const id = randomUUID();
1461
- db.prepare(
1462
- `INSERT INTO import_jobs (id, brand_id, source_id, url, platform, stage)
1463
- VALUES (?,?,?,?,?,'queued')`
1464
- ).run(id, input.brandId, input.sourceId ?? null, input.url, input.platform ?? "unknown");
1465
- return jobById(db, id);
1466
- },
1467
- getJob(id) {
1468
- const r = db.prepare("SELECT * FROM import_jobs WHERE id=?").get(id);
1469
- return r ? rowJob(r) : null;
1470
- },
1471
- listJobs(brandId) {
1472
- return db.prepare("SELECT * FROM import_jobs WHERE brand_id=? ORDER BY created_at DESC").all(brandId).map(rowJob);
1473
- },
1474
- updateJob(id, patch2) {
1475
- const cur = jobById(db, id);
1476
- if (!cur) return null;
1477
- const stage = patch2.stage ?? cur.stage;
1478
- const finished = patch2.finished || stage === "completed" || stage === "partial" || stage === "failed";
1479
- db.prepare(
1480
- `UPDATE import_jobs SET
1481
- source_id=?, platform=?, stage=?, discovered=?, fetched=?, upserted=?,
1482
- images_done=?, images_total=?, errors=?, warnings=?, message=?,
1483
- updated_at=datetime('now'),
1484
- finished_at=CASE WHEN ? THEN COALESCE(finished_at, datetime('now')) ELSE finished_at END
1485
- WHERE id=?`
1486
- ).run(
1487
- patch2.sourceId !== void 0 ? patch2.sourceId : cur.sourceId,
1488
- patch2.platform ?? cur.platform,
1489
- stage,
1490
- patch2.discovered ?? cur.discovered,
1491
- patch2.fetched ?? cur.fetched,
1492
- patch2.upserted ?? cur.upserted,
1493
- patch2.imagesDone ?? cur.imagesDone,
1494
- patch2.imagesTotal ?? cur.imagesTotal,
1495
- JSON.stringify(patch2.errors ?? cur.errors),
1496
- JSON.stringify(patch2.warnings ?? cur.warnings),
1497
- patch2.message !== void 0 ? patch2.message : cur.message,
1498
- finished ? 1 : 0,
1499
- id
1500
- );
1501
- return jobById(db, id);
1502
- }
1503
- };
1504
- }
1505
- function productImportMethods(db) {
1506
- return {
1507
- upsertProduct(input) {
1508
- const existing = db.prepare("SELECT id FROM catalog_products WHERE source_id=? AND external_key=?").get(input.sourceId, input.externalKey);
1509
- const id = existing?.id ?? randomUUID();
1510
- if (existing) {
1511
- db.prepare(
1512
- // `category` is deliberately absent. The store's own taxonomy is
1513
- // `product_type`; `category` is this app's field, set by the user on
1514
- // the product page, and a re-import used to silently revert it.
1515
- `UPDATE catalog_products SET
1516
- title=?, description_html=?, url=?, handle=?, vendor=?, product_type=?, tags=?,
1517
- price=?, compare_at_price=?, currency=?, available=?, status='active', raw=?,
1518
- updated_at=datetime('now')
1519
- WHERE id=?`
1520
- ).run(
1521
- input.title,
1522
- input.descriptionHtml ?? null,
1523
- input.url,
1524
- input.handle ?? null,
1525
- input.vendor ?? null,
1526
- input.productType ?? null,
1527
- JSON.stringify(input.tags ?? []),
1528
- input.price ?? null,
1529
- input.compareAtPrice ?? null,
1530
- input.currency ?? null,
1531
- input.available == null ? null : input.available ? 1 : 0,
1532
- JSON.stringify(input.raw ?? null),
1533
- id
1534
- );
1535
- } else {
1536
- db.prepare(
1537
- `INSERT INTO catalog_products (
1538
- id, source_id, brand_id, external_key, title, description_html, url, handle, vendor,
1539
- product_type, tags, category, price, compare_at_price, currency, available, status, raw
1540
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'active', ?)`
1541
- ).run(
1542
- id,
1543
- input.sourceId,
1544
- input.brandId,
1545
- input.externalKey,
1546
- input.title,
1547
- input.descriptionHtml ?? null,
1548
- input.url,
1549
- input.handle ?? null,
1550
- input.vendor ?? null,
1551
- input.productType ?? null,
1552
- JSON.stringify(input.tags ?? []),
1553
- input.category ?? null,
1554
- input.price ?? null,
1555
- input.compareAtPrice ?? null,
1556
- input.currency ?? null,
1557
- input.available == null ? null : input.available ? 1 : 0,
1558
- JSON.stringify(input.raw ?? null)
1559
- );
1560
- }
1561
- db.prepare("DELETE FROM catalog_variants WHERE product_id=?").run(id);
1562
- for (const v of input.variants ?? []) {
1563
- db.prepare(
1564
- `INSERT INTO catalog_variants (id, product_id, external_key, title, sku, price, compare_at_price, currency, available, options)
1565
- VALUES (?,?,?,?,?,?,?,?,?,?)`
1566
- ).run(
1567
- randomUUID(),
1568
- id,
1569
- v.externalKey,
1570
- v.title ?? null,
1571
- v.sku ?? null,
1572
- v.price ?? null,
1573
- v.compareAtPrice ?? null,
1574
- v.currency ?? null,
1575
- v.available == null ? null : v.available ? 1 : 0,
1576
- JSON.stringify(v.options ?? {})
1577
- );
1578
- }
1579
- const existingImgs = db.prepare("SELECT * FROM catalog_images WHERE product_id=?").all(id);
1580
- const byUrl = new Map(existingImgs.map((r) => [r.source_url, r]));
1581
- const maxPos = existingImgs.reduce((m, r) => Math.max(m, r.position ?? 0), -1);
1582
- const local = existingImgs.filter((r) => String(r.source_url ?? "").startsWith("local:"));
1583
- let appended = 0;
1584
- const merged = [
1585
- ...local.map((r) => ({
1586
- id: r.id,
1587
- sourceUrl: r.source_url,
1588
- assetRef: r.asset_ref,
1589
- width: r.width,
1590
- height: r.height,
1591
- alt: r.alt,
1592
- angle: r.angle ?? null,
1593
- excluded: r.excluded ?? 0,
1594
- sort: r.position ?? 0
1595
- })),
1596
- ...(input.images ?? []).map((img) => {
1597
- const prev = byUrl.get(img.sourceUrl);
1598
- return {
1599
- id: prev?.id ?? randomUUID(),
1600
- sourceUrl: img.sourceUrl,
1601
- assetRef: img.assetRef ?? prev?.asset_ref ?? null,
1602
- width: img.width ?? prev?.width ?? null,
1603
- height: img.height ?? prev?.height ?? null,
1604
- alt: img.alt ?? prev?.alt ?? null,
1605
- angle: prev?.angle ?? null,
1606
- // A crawl re-reporting an image is not the user changing their
1607
- // mind about it.
1608
- excluded: prev?.excluded ?? 0,
1609
- sort: prev ? prev.position ?? 0 : maxPos + 1 + appended++
1610
- };
1611
- })
1612
- ].sort((a, b) => a.sort - b.sort);
1613
- db.prepare("DELETE FROM catalog_images WHERE product_id=?").run(id);
1614
- merged.forEach((img, position) => {
1615
- db.prepare(
1616
- `INSERT INTO catalog_images (id, product_id, source_url, asset_ref, width, height, position, alt, angle, excluded)
1617
- VALUES (?,?,?,?,?,?,?,?,?,?)`
1618
- ).run(
1619
- img.id,
1620
- id,
1621
- img.sourceUrl,
1622
- img.assetRef,
1623
- img.width,
1624
- img.height,
1625
- position,
1626
- img.alt,
1627
- img.angle,
1628
- img.excluded
1629
- );
1630
- });
1631
- for (const col of input.collections ?? []) {
1632
- let colRow = db.prepare("SELECT id FROM catalog_collections WHERE source_id=? AND external_key=?").get(input.sourceId, col.externalKey);
1633
- if (!colRow) {
1634
- const colId = randomUUID();
1635
- db.prepare(
1636
- "INSERT INTO catalog_collections (id, source_id, external_key, title, url) VALUES (?,?,?,?,?)"
1637
- ).run(colId, input.sourceId, col.externalKey, col.title, col.url ?? null);
1638
- colRow = { id: colId };
1639
- } else {
1640
- db.prepare("UPDATE catalog_collections SET title=?, url=? WHERE id=?").run(
1641
- col.title,
1642
- col.url ?? null,
1643
- colRow.id
1644
- );
1645
- }
1646
- db.prepare("INSERT OR IGNORE INTO catalog_collection_products (collection_id, product_id) VALUES (?,?)").run(
1647
- colRow.id,
1648
- id
1649
- );
1650
- }
1651
- return productById(db, id);
1652
- },
1653
- setImageAsset(productId, sourceUrl, assetRef2, meta) {
1654
- db.prepare(
1655
- `UPDATE catalog_images SET asset_ref=?, width=COALESCE(?, width), height=COALESCE(?, height)
1656
- WHERE product_id=? AND source_url=?`
1657
- ).run(assetRef2, meta?.width ?? null, meta?.height ?? null, productId, sourceUrl);
1658
- },
1659
- listImagesNeedingAssets(brandId, limit = 500) {
1660
- return db.prepare(
1661
- `SELECT i.* FROM catalog_images i
1662
- JOIN catalog_products p ON p.id = i.product_id
1663
- WHERE p.brand_id=? AND (i.asset_ref IS NULL OR i.asset_ref='')
1664
- ORDER BY i.position ASC LIMIT ?`
1665
- ).all(brandId, limit).map((r) => ({
1666
- id: r.id,
1667
- productId: r.product_id,
1668
- sourceUrl: r.source_url,
1669
- assetRef: r.asset_ref,
1670
- width: r.width,
1671
- height: r.height,
1672
- position: r.position,
1673
- alt: r.alt,
1674
- angle: r.angle ?? null,
1675
- excluded: !!r.excluded
1676
- }));
1677
- },
1678
- markMissingUnavailable(sourceId, seenExternalKeys) {
1679
- if (!seenExternalKeys.length) {
1680
- const r2 = db.prepare(
1681
- "UPDATE catalog_products SET status='unavailable', updated_at=datetime('now') WHERE source_id=? AND status='active'"
1682
- ).run(sourceId);
1683
- return r2.changes;
1684
- }
1685
- const placeholders = seenExternalKeys.map(() => "?").join(",");
1686
- const r = db.prepare(
1687
- `UPDATE catalog_products SET status='unavailable', updated_at=datetime('now')
1688
- WHERE source_id=? AND status='active' AND external_key NOT IN (${placeholders})`
1689
- ).run(sourceId, ...seenExternalKeys);
1690
- return r.changes;
1691
- }
1692
- };
1693
- }
1694
-
1695
- // ../core/src/catalog/reads.ts
1696
- function readMethods(db) {
1697
- return {
1698
- getProduct(id) {
1699
- return productById(db, id);
1700
- },
1701
- getProductByLibraryId(libraryId) {
1702
- if (!libraryId.startsWith("cat-")) return null;
1703
- return productById(db, libraryId.slice(4));
1704
- },
1705
- listProducts(brandId) {
1706
- return productsFor(db, brandId);
1707
- },
1708
- listVariants(productId) {
1709
- return variantsFor(db, productId);
1710
- },
1711
- listImages(productId) {
1712
- return imagesFor(db, productId);
1713
- }
1714
- };
1715
- }
1716
- function mutationMethods(db) {
1717
- return {
1718
- deleteCatalogProduct(id) {
1719
- db.prepare("DELETE FROM catalog_products WHERE id=?").run(id);
1720
- },
1721
- /**
1722
- * The fields this app invents on top of an imported product. Everything a
1723
- * store supplies — title, price, vendor, variants — stays the store's and
1724
- * is refreshed by every import; these four have no counterpart there, so
1725
- * they are the user's and an import never touches them.
1726
- */
1727
- updateProduct(id, patch2) {
1728
- const cols = ["category", "variant", "material", "dimensions"].filter((k) => k in patch2);
1729
- if (cols.length) {
1730
- db.prepare(
1731
- `UPDATE catalog_products SET ${cols.map((c) => `${c}=?`).join(", ")}, updated_at=datetime('now') WHERE id=?`
1732
- ).run(...cols.map((c) => patch2[c] ?? null), id);
1733
- }
1734
- return productById(db, id);
1735
- },
1736
- /**
1737
- * An angle the user shot themselves, added to an imported product. The
1738
- * `local:` prefix is what marks it as not-from-the-store, which is how the
1739
- * import merge knows to carry it across instead of deleting it.
1740
- */
1741
- addLocalImage(productId, assetRef2, angle) {
1742
- const next = (db.prepare("SELECT MAX(position) AS p FROM catalog_images WHERE product_id=?").get(productId)?.p ?? -1) + 1;
1743
- db.prepare(
1744
- `INSERT INTO catalog_images (id, product_id, source_url, asset_ref, position, angle)
1745
- VALUES (?,?,?,?,?,?)`
1746
- ).run(randomUUID(), productId, `local:${assetRef2}`, assetRef2, next, angle ?? null);
1747
- },
1748
- /**
1749
- * Say which of a product's images make up its reference set, and in what
1750
- * order. `assetRefs` is the whole set: anything left out stops being used.
1751
- *
1752
- * An image the user uploaded here is theirs, so leaving it out deletes it.
1753
- * A store image is not — the next import would fetch it straight back — so
1754
- * leaving one out marks it excluded instead. That is the difference between
1755
- * a delete this can honour and one it cannot, and it is also what lets a
1756
- * store image be put back: pass it in again.
1757
- */
1758
- setImageOrder(productId, assetRefs) {
1759
- const rows = imagesFor(db, productId).filter((i) => i.assetRef);
1760
- const keep = new Set(assetRefs);
1761
- const ordered = assetRefs.map((ref) => rows.find((r) => r.assetRef === ref)).filter(Boolean);
1762
- const dropped = rows.filter((r) => !keep.has(r.assetRef));
1763
- const setAside = dropped.filter((r) => !String(r.sourceUrl).startsWith("local:"));
1764
- db.transaction(() => {
1765
- for (const r of dropped) {
1766
- if (String(r.sourceUrl).startsWith("local:")) db.prepare("DELETE FROM catalog_images WHERE id=?").run(r.id);
1767
- }
1768
- for (const r of setAside) db.prepare("UPDATE catalog_images SET excluded=1 WHERE id=?").run(r.id);
1769
- for (const r of ordered) db.prepare("UPDATE catalog_images SET excluded=0 WHERE id=?").run(r.id);
1770
- const tail = imagesFor(db, productId).filter((i) => !i.assetRef);
1771
- [...ordered, ...setAside, ...tail].forEach((r, i) => {
1772
- db.prepare("UPDATE catalog_images SET position=? WHERE id=?").run(i, r.id);
1773
- });
1774
- })();
1775
- }
1776
- /** Merge manual kit products + catalog into one library list. */
1777
- };
1778
- }
1779
-
1780
- // ../core/src/catalog/library.ts
1781
- function libraryMethods(db) {
1782
- return {
1783
- listLibraryProducts(brandId, brandJson) {
1784
- const manual = (brandJson?.products ?? []).map((p) => ({
1785
- id: p.id,
1786
- name: p.name,
1787
- origin: "manual",
1788
- category: p.category ?? null,
1789
- variant: p.variant ?? null,
1790
- material: p.material ?? null,
1791
- dimensions: p.dimensions ?? null,
1792
- shots: (p.shots ?? []).map((s) => ({
1793
- file: s.file,
1794
- locked: s.locked ?? true,
1795
- angle: s.angle ?? null,
1796
- alt: s.alt ?? s.angle ?? null
1797
- }))
1798
- }));
1799
- const imagesBy = imagesForBrand(db, brandId);
1800
- const variantsBy = variantsForBrand(db, brandId);
1801
- const catalog = productsFor(db, brandId).map((p) => {
1802
- const images = imagesBy.get(p.id) ?? [];
1803
- const shot = (i) => ({
1804
- file: i.assetRef,
1805
- locked: true,
1806
- angle: i.angle,
1807
- alt: i.alt,
1808
- local: String(i.sourceUrl).startsWith("local:")
1809
- });
1810
- const seen = /* @__PURE__ */ new Set();
1811
- const usable2 = images.filter((i) => i.assetRef && !seen.has(i.assetRef) && seen.add(i.assetRef));
1812
- const shots = usable2.filter((i) => !i.excluded).map(shot);
1813
- const hiddenShots = usable2.filter((i) => i.excluded).map(shot);
1814
- return {
1815
- id: `cat-${p.id}`,
1816
- name: p.title,
1817
- origin: "catalog",
1818
- url: p.url,
1819
- descriptionHtml: p.descriptionHtml,
1820
- vendor: p.vendor,
1821
- productType: p.productType,
1822
- tags: p.tags,
1823
- category: p.category,
1824
- variant: p.variant,
1825
- material: p.material,
1826
- dimensions: p.dimensions,
1827
- price: p.price,
1828
- compareAtPrice: p.compareAtPrice,
1829
- currency: p.currency,
1830
- available: p.available,
1831
- status: p.status,
1832
- shots,
1833
- hiddenShots,
1834
- variants: variantsBy.get(p.id) ?? []
1835
- };
1836
- });
1837
- return [...manual, ...catalog];
1838
- }
1839
- };
1840
- }
1841
-
1842
- // ../core/src/catalogStore.ts
1843
- function createCatalogStore(db) {
1844
- return {
1845
- ...sourceMethods(db),
1846
- ...jobMethods(db),
1847
- ...productImportMethods(db),
1848
- ...readMethods(db),
1849
- ...mutationMethods(db),
1850
- ...libraryMethods(db)
1851
- };
1852
- }
1853
-
1854
- // ../core/src/engine.ts
1855
- var REFERENCE_ROLE_DIRECTIVE = {
1856
- product: "the exact product \u2014 preserve its label, shape, colors and design faithfully; do not redesign it",
1857
- character: "the exact person \u2014 match their face, facial structure, skin, hair and build exactly; their clothing, pose and background are capture context, not styling to reproduce",
1858
- brand: "the brand's own mark \u2014 if the direction calls for the mark to appear, reproduce it exactly as drawn, same colours, letterforms and proportions, every character down to the smallest secondary lettering, in its original script and reading direction, never translated, transliterated or re-spelled; otherwise take only its colour and treatment, and never its subject, geometry or composition",
1859
- // Only a figure-led scene attaches one of these now, so this says what that
1860
- // case actually needs. It used to read "environment and light only - take no
1861
- // subject or person from it", which handed the model a photograph of a face
1862
- // treatment and told it to ignore the treatment. A hand-attached reference
1863
- // says "match ... treatment" and works; this now says the same thing, with the
1864
- // identity carve-out a scene needs and a lone reference does not. The tail
1865
- // names staged objects as stand-ins rather than just "no product": a bare
1866
- // prohibition still left the demo object in the frame, because the model had
1867
- // nowhere to put what the photograph so vividly showed.
1868
- //
1869
- // The carve-out leads. It used to sit forty words in, one subordinate
1870
- // clause after a paragraph of "match this" - and the tester case (a close
1871
- // portrait as the scene image beside a selected presenter) showed which
1872
- // half the model heard. Every treatment clause is retained word for word;
1873
- // only the order and the register of the identity refusal changed.
1874
- scene: "a reference for this world, never for a person: take no identity from the person in it \u2014 not their face, not their likeness \u2014 they are an anonymous stand-in whose place the attached subject takes. Match the environment, the light, and the material, density, scale, finish and spread of the treatment applied to the figure, including which parts of the form it covers and how far it reaches; treat any product, garment or prop staged in it as the same kind of stand-in, demonstrating placement and scale \u2014 never an object to reproduce",
1875
- composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
1876
- style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
1877
- reference: "a reference to match in composition, lighting and treatment"
1878
- };
1879
- var EDIT_REFERENCE_ROLE_DIRECTIVE = {
1880
- product: "the exact product: keep or restore its label, shape and design faithfully",
1881
- character: "the exact person: keep their face, facial structure, skin, hair and build faithfully; take no clothing, pose or background from this reference, and keep the source image's existing outfit unless the instruction changes it",
1882
- brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears \u2014 every character down to the smallest secondary lettering, in its original script and reading direction \u2014 never redrawn, re-lettered, translated or transliterated",
1883
- scene: "a reference for environment, light and treatment only \u2014 take no identity from any person in it",
1884
- composition: "a reference for framing and pose only",
1885
- style: "a reference for treatment and mood only",
1886
- reference: "a reference for composition, lighting and treatment only"
1887
- };
1888
- var BUDGET_EXHAUSTED = "scenri:budget-exhausted";
1889
- var ASPECT_TOLERANCE = 0.15;
1890
- var NAMED_RATIOS = [
1891
- ["1:1", 1],
1892
- ["4:5", 4 / 5],
1893
- ["5:4", 5 / 4],
1894
- ["2:3", 2 / 3],
1895
- ["3:2", 3 / 2],
1896
- ["3:4", 3 / 4],
1897
- ["4:3", 4 / 3],
1898
- ["9:16", 9 / 16],
1899
- ["16:9", 16 / 9],
1900
- ["2:1", 2],
1901
- ["1:2", 0.5]
1902
- ];
1903
- function ratioLabel(width, height) {
1904
- const ratio = width / height;
1905
- for (const [label, value] of NAMED_RATIOS) {
1906
- if (Math.abs(ratio - value) / value < 0.02) return label;
1907
- }
1908
- const gcd = (a, b) => b ? gcd(b, a % b) : a;
1909
- const d = gcd(width, height) || 1;
1910
- return `${Math.round(width / d)}:${Math.round(height / d)}`;
1911
- }
1912
- function budgetSize(width, height, pixelBudget) {
1913
- const ratio = width / height;
1914
- if (!(ratio > 0) || !Number.isFinite(ratio) || !(pixelBudget > 0)) return { width, height };
1915
- return {
1916
- width: Math.round(Math.sqrt(pixelBudget * ratio)),
1917
- height: Math.round(Math.sqrt(pixelBudget / ratio))
1918
- };
1919
- }
2
+ import { portBusyLines } from './chunk-WGIZJXNE.js';
3
+ import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, budgetSize, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, termMatches, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-OJAG3FRX.js';
4
+ import { detectInstallKind } from './chunk-PAIAXRAC.js';
5
+ export { detectInstallKind } from './chunk-PAIAXRAC.js';
6
+ import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
7
+ import { compareSemver, newestStaged } from './chunk-4MAFHYAD.js';
8
+ import { dirname, join, normalize } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import fs, { existsSync, readFileSync, mkdirSync, createReadStream, rmSync, readdirSync, statSync, renameSync } from 'fs';
11
+ import { homedir, networkInterfaces, tmpdir } from 'os';
12
+ import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'crypto';
13
+ import { readFile, copyFile, stat, readdir, mkdtemp, rm, access, rename, unlink, writeFile } from 'fs/promises';
14
+ import { spawn } from 'child_process';
15
+ import sharp20 from 'sharp';
16
+ import Fastify from 'fastify';
17
+ import fastifyStatic from '@fastify/static';
18
+ import fastifyMultipart from '@fastify/multipart';
19
+ import JSZip from 'jszip';
20
+ import { Ajv2020 } from 'ajv/dist/2020.js';
21
+ import addFormats from 'ajv-formats';
22
+ import * as cheerio from 'cheerio';
23
+ import { parse } from 'node-html-parser';
24
+ import pixelmatch from 'pixelmatch';
25
+ import { PNG } from 'pngjs';
1920
26
 
1921
- // ../core/src/index.ts
1922
- function defaultHome() {
1923
- return process.env.SCENRI_HOME || join(homedir(), ".scenri");
1924
- }
1925
- function createCore(homeDir = defaultHome()) {
1926
- const db = openDb(homeDir);
1927
- return {
1928
- home: homeDir,
1929
- store: createStore(db),
1930
- catalog: createCatalogStore(db),
1931
- images: createImageStore(homeDir),
1932
- ledger: createLedger(db),
1933
- close: () => db.close()
1934
- };
1935
- }
1936
27
  var ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
1937
28
  var PER_IMAGE_TIMEOUT_MS = 3e5;
1938
29
  var DEFAULT_MODEL = "google/gemini-2.5-flash-image";
@@ -3508,7 +1599,7 @@ function createDemoEngine(saveImage, opts = {}) {
3508
1599
  <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>
3509
1600
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
3510
1601
  </svg>`;
3511
- return sharp21(Buffer.from(svg)).png().toBuffer();
1602
+ return sharp20(Buffer.from(svg)).png().toBuffer();
3512
1603
  }
3513
1604
  return {
3514
1605
  capabilities() {
@@ -3699,7 +1790,7 @@ var resolvedRefs = /* @__PURE__ */ new Map();
3699
1790
  async function refHash(core, path) {
3700
1791
  const hit = resolvedRefs.get(path);
3701
1792
  if (hit && core.images.has(hit)) return hit;
3702
- const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
1793
+ const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
3703
1794
  resolvedRefs.set(path, hash);
3704
1795
  return hash;
3705
1796
  }
@@ -3803,7 +1894,7 @@ var resolvedRefs2 = /* @__PURE__ */ new Map();
3803
1894
  async function refHash2(core, path) {
3804
1895
  const hit = resolvedRefs2.get(path);
3805
1896
  if (hit && core.images.has(hit)) return hit;
3806
- const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
1897
+ const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
3807
1898
  resolvedRefs2.set(path, hash);
3808
1899
  return hash;
3809
1900
  }
@@ -3940,6 +2031,109 @@ function mergeEditAttachments(own, inherited, cap2) {
3940
2031
  const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
3941
2032
  return allocateAttachments([...own, ...borrowed], cap2);
3942
2033
  }
2034
+ var THUMB_WIDTHS = [640, 320, 160];
2035
+ var WARM_WIDTHS = [640, 160];
2036
+ var isThumbWidth = (w) => THUMB_WIDTHS.includes(w);
2037
+ var THUMB_WIDTH_LIST = THUMB_WIDTHS.join(", ");
2038
+ var QUALITY = { 640: 82, 320: 80, 160: 75 };
2039
+ var FILE_KEY = /^[a-z0-9-]{1,120}$/;
2040
+ function createThumbStore(core, opts = {}) {
2041
+ const dir = join(core.home, "thumbs");
2042
+ let enabled = true;
2043
+ try {
2044
+ mkdirSync(dir, { recursive: true, mode: 448 });
2045
+ } catch {
2046
+ enabled = false;
2047
+ }
2048
+ const pathFor = (key, w) => join(dir, `${key}-w${w}.webp`);
2049
+ const inflight = /* @__PURE__ */ new Map();
2050
+ const failed = /* @__PURE__ */ new Set();
2051
+ const concurrency = Math.max(1, opts.concurrency ?? 2);
2052
+ let active = 0;
2053
+ const waiting = [];
2054
+ const acquire = () => new Promise((resolve) => {
2055
+ if (active < concurrency) {
2056
+ active++;
2057
+ resolve();
2058
+ } else waiting.push(resolve);
2059
+ });
2060
+ const release = () => {
2061
+ const next = waiting.shift();
2062
+ if (next) next();
2063
+ else active--;
2064
+ };
2065
+ async function make(key, source, w) {
2066
+ const final = pathFor(key, w);
2067
+ const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
2068
+ await acquire();
2069
+ try {
2070
+ await sharp20(source).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
2071
+ await rename(tmp, final);
2072
+ return final;
2073
+ } catch {
2074
+ await unlink(tmp).catch(() => {
2075
+ });
2076
+ failed.add(`${key}-w${w}`);
2077
+ return null;
2078
+ } finally {
2079
+ release();
2080
+ }
2081
+ }
2082
+ async function ensureKey(key, source, w) {
2083
+ if (!enabled) return null;
2084
+ const memo = `${key}-w${w}`;
2085
+ if (failed.has(memo)) return null;
2086
+ const final = pathFor(key, w);
2087
+ try {
2088
+ await access(final);
2089
+ return final;
2090
+ } catch {
2091
+ }
2092
+ let job = inflight.get(memo);
2093
+ if (!job) {
2094
+ job = make(key, source, w).finally(() => inflight.delete(memo));
2095
+ inflight.set(memo, job);
2096
+ }
2097
+ return job;
2098
+ }
2099
+ return {
2100
+ dir,
2101
+ async ensure(hash, w) {
2102
+ if (!/^[a-f0-9]{32}$/.test(hash)) return null;
2103
+ return ensureKey(hash, core.images.pathFor(hash), w);
2104
+ },
2105
+ async ensureFile(key, sourcePath, w) {
2106
+ if (!FILE_KEY.test(key)) return null;
2107
+ return ensureKey(`f-${key}`, sourcePath, w);
2108
+ },
2109
+ warm(hash) {
2110
+ for (const w of WARM_WIDTHS) void this.ensure(hash, w);
2111
+ },
2112
+ async settle() {
2113
+ await Promise.allSettled([...inflight.values()]);
2114
+ },
2115
+ clear() {
2116
+ failed.clear();
2117
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
2118
+ try {
2119
+ mkdirSync(dir, { recursive: true, mode: 448 });
2120
+ } catch {
2121
+ enabled = false;
2122
+ }
2123
+ },
2124
+ stream: (path) => createReadStream(path)
2125
+ };
2126
+ }
2127
+ async function fileSize(path) {
2128
+ try {
2129
+ const s = await stat(path);
2130
+ return s.isFile() ? s.size : null;
2131
+ } catch {
2132
+ return null;
2133
+ }
2134
+ }
2135
+
2136
+ // src/routes/shared.ts
3943
2137
  function joinNames(labels) {
3944
2138
  const uniq = [...new Set(labels)];
3945
2139
  if (uniq.length <= 1) return uniq[0] ?? "";
@@ -3963,7 +2157,7 @@ var assetHash = (ref) => {
3963
2157
  };
3964
2158
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3965
2159
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3966
- var toPng = (buf) => sharp21(buf).rotate().png().toBuffer();
2160
+ var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
3967
2161
  var COST_PROBE = {
3968
2162
  prompt: "",
3969
2163
  brand: { brand: {}, assetPaths: {} },
@@ -3976,11 +2170,11 @@ var MARK_MIN_EDGE = 1024;
3976
2170
  var MARK_TINY_EDGE = 256;
3977
2171
  var MARK_WARN_EDGE = 512;
3978
2172
  var toMarkPng = async (buf) => {
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();
2173
+ const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
2174
+ const meta = await sharp20(out).metadata();
3981
2175
  const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3982
2176
  if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3983
- return sharp21(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
2177
+ return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3984
2178
  }
3985
2179
  return out;
3986
2180
  };
@@ -3991,9 +2185,9 @@ async function capReferenceEdge(core, path, maxEdge) {
3991
2185
  if (hit) return hit;
3992
2186
  let out = path;
3993
2187
  try {
3994
- const meta = await sharp21(path).metadata();
2188
+ const meta = await sharp20(path).metadata();
3995
2189
  if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3996
- const buf = await sharp21(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
2190
+ const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3997
2191
  out = core.images.pathFor(core.images.save(buf));
3998
2192
  }
3999
2193
  } catch {
@@ -4020,6 +2214,25 @@ var serveJpeg = (req, reply, path) => {
4020
2214
  if (req.headers["if-none-match"] === etag) return reply.status(304).send();
4021
2215
  return reply.header("content-type", "image/jpeg").send(readFileSync(path));
4022
2216
  };
2217
+ var fileKey = (prefix, id, path) => `${prefix}-${id}-${Math.round(statSync(path).mtimeMs)}`;
2218
+ var serveJpegSized = async (req, reply, path, thumbs, key) => {
2219
+ const raw = req.query?.w;
2220
+ if (raw === void 0 || raw === "") return serveJpeg(req, reply, path);
2221
+ const w = Number(raw);
2222
+ if (!isThumbWidth(w)) return reply.status(400).send({ error: `w must be one of ${THUMB_WIDTH_LIST}` });
2223
+ const immutable = "public, max-age=31536000, immutable";
2224
+ const etag = `"${key}-w${w}"`;
2225
+ if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", immutable).send();
2226
+ const made = await thumbs.ensureFile(key, path, w);
2227
+ const size = made ? await fileSize(made) : null;
2228
+ if (!made || size === null) {
2229
+ const back = new URL(req.url, "http://scenri.local");
2230
+ back.searchParams.delete("w");
2231
+ return reply.header("cache-control", "no-store").redirect(`${back.pathname}${back.search}`, 307);
2232
+ }
2233
+ reply.header("content-type", "image/webp").header("cache-control", immutable).header("etag", etag).header("content-length", String(size));
2234
+ return reply.send(thumbs.stream(made));
2235
+ };
4023
2236
 
4024
2237
  // src/briefDirectives.ts
4025
2238
  function productFidelityDirective(attached) {
@@ -4100,6 +2313,12 @@ function shotSpecifiesCamera(text) {
4100
2313
  function namesAreNotLetteringDirective() {
4101
2314
  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
2315
  }
2316
+ var PERSON_SCENE_FIGURE = "the one person this set is shot around";
2317
+ function shotAsksForAPerson(text) {
2318
+ return /\b(?:person|people|man|men|woman|women|models?|figures?|someone|somebody|anyone|hands?(?!-)|arms?(?!-)|portrait|girls?|boys?|guys?|lady|ladies|couple|family|child|children|kids?|baby|crowd|presenter|character|athlete|dancer|customer|shopper|wearer|wearing|holding|holds)\b/i.test(
2319
+ text
2320
+ );
2321
+ }
4103
2322
  function sceneFigureDirectives(opts) {
4104
2323
  const figure = opts.figure.trim().replace(/[.\s]+$/, "");
4105
2324
  if (!figure) return [];
@@ -4113,15 +2332,23 @@ function sceneFigureDirectives(opts) {
4113
2332
  out.push(
4114
2333
  `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.`
4115
2334
  );
2335
+ } else if (opts.asked) {
2336
+ out.push(
2337
+ `This world is built around one figure: ${figure}. The brief asks for a person and nobody is attached, so someone fills that role in the frame, and they are nobody in particular: an anonymous person invented for this photograph only, with no recognisable identity to preserve and nothing about them carried anywhere else.`
2338
+ );
4116
2339
  } else {
4117
2340
  out.push(
4118
- `This world is built around one figure: ${figure}. Someone fills that role in the frame, and they are nobody in particular: an anonymous person invented for this photograph only, with no recognisable identity to preserve and nothing about them carried anywhere else. Show them unless the direction above asks for no people.`
2341
+ `This world is built around one figure: ${figure}. Nobody is attached to take that role, so the role stays empty and nobody is in this image. The frame holds the set, the light and any treatment this world applies.`
4119
2342
  );
4120
2343
  }
4121
2344
  if (treatment) {
4122
2345
  const who = opts.hasPerson ? "The face and body underneath are still exactly theirs - same structure, same proportions, same build - and any earlier instruction that their features must survive unchanged is a rule about who they are, which this does not alter. " : "";
4123
2346
  out.push(
4124
- `The art direction of this world is what has been done to that figure: ${treatment}. Render it as a real physical treatment, following the shape of the face and body it sits on rather than floating in the frame. Spread it across the whole form the way the reference does, reaching every part it covers there - brow, forehead, nose, both cheeks, jaw - instead of massing it in one area and leaving the rest untouched. Reaching wide is not the same as covering more: keep the number of pieces and the bare surface between them exactly as the description says, so a sparse treatment stays sparse while still touching every part of the form. Each piece sits on the plane beneath it, curving and catching light with the surface it is stuck to. ${who}The figure is bodily present and in shot: where the treatment covers or hides them, that is the photograph working as intended and never a reason to leave them out, crop them away, or reduce them to a shadow. If no person appears in this shot, the treatment does not go with them: it is what this world looks like, so it applies to whatever the frame does hold - the product, the surfaces, the set - as real pieces resting on those things. Applied on top, never redesigning them: the product keeps the exact form, colour, material and its own printed label that its reference shows, with the treatment sitting over it. Where the treatment carries printing, render it as genuinely designed print: real letterforms, readable words, numerals, illustration and colour, at the quality of commercial label artwork. Invent the companies - every name, logotype and piece of packaging artwork must be plausible but fictional, resembling no existing brand. That includes near-misses: do not borrow, extend or re-spell a name that appears in any attached reference, and use ordinary words for the produce itself rather than any company that sells it.` + // The fictional-brands rule and an attached brand mark are in direct
2347
+ `The art direction of this world is what has been done to that figure: ${treatment}. Render it as a real physical treatment, following the shape of the face and body it sits on rather than floating in the frame. Spread it across the whole form the way the reference does, reaching every part it covers there - brow, forehead, nose, both cheeks, jaw - instead of massing it in one area and leaving the rest untouched. Reaching wide is not the same as covering more: keep the number of pieces and the bare surface between them exactly as the description says, so a sparse treatment stays sparse while still touching every part of the form. Each piece sits on the plane beneath it, curving and catching light with the surface it is stuck to. ${who}` + (opts.hasPerson ? "The figure is bodily present and in shot: where the treatment covers or hides them, that is the photograph working as intended and never a reason to leave them out, crop them away, or reduce them to a shadow. " : "") + // The treatment is the art direction, not a property of the person. Ask
2348
+ // for this world with no people in it and the stickers should still be
2349
+ // there, on whatever the frame does hold - that IS the scene. Suppressing
2350
+ // them left a plain product on a plinth with nothing of the scene in it.
2351
+ "If no person appears in this shot, the treatment does not go with them: it is what this world looks like, so it applies to whatever the frame does hold - the product, the surfaces, the set - as real pieces resting on those things. Applied on top, never redesigning them: the product keeps the exact form, colour, material and its own printed label that its reference shows, with the treatment sitting over it. Where the treatment carries printing, render it as genuinely designed print: real letterforms, readable words, numerals, illustration and colour, at the quality of commercial label artwork. Invent the companies - every name, logotype and piece of packaging artwork must be plausible but fictional, resembling no existing brand. That includes near-misses: do not borrow, extend or re-spell a name that appears in any attached reference, and use ordinary words for the produce itself rather than any company that sells it." + // The fictional-brands rule and an attached brand mark are in direct
4125
2352
  // conflict without this: "resembling no existing brand" reads as an
4126
2353
  // instruction to mutate the one real mark the user deliberately
4127
2354
  // attached. Same shape as pairDirectives' packshot override - name the
@@ -4161,6 +2388,12 @@ function sceneGuardDirectives(opts) {
4161
2388
  );
4162
2389
  }
4163
2390
  }
2391
+ const emptyRole = (opts.emptyRole ?? "").trim().replace(/[.\s]+$/, "");
2392
+ if (emptyRole) {
2393
+ out.push(
2394
+ `Disregard any person, figure, hand, face or silhouette described in the scene direction or the camera note above: that is the role this world is built around (${emptyRole}), nobody is attached to take it, and it stays empty. Nobody is in this image: no person, no hands, no reflection or shadow of anyone. The set, the light and any treatment this world applies fill the frame on their own.`
2395
+ );
2396
+ }
4164
2397
  return out;
4165
2398
  }
4166
2399
  function brandRuleDirectives(brand) {
@@ -4188,7 +2421,6 @@ function markLabel(brand, logo) {
4188
2421
  // src/brief.ts
4189
2422
  var PRODUCT_REF_MAX = 3;
4190
2423
  var CHARACTER_REF_MAX = 3;
4191
- var SCENE_REF_MAX = 1;
4192
2424
  var FORMATS = [
4193
2425
  { id: "square", label: "Square 1:1", w: 1024, h: 1024 },
4194
2426
  { id: "story", label: "Story 9:16", w: 1080, h: 1920 },
@@ -4247,7 +2479,6 @@ function compileBrief(brief, ctx) {
4247
2479
  const warnings = [];
4248
2480
  const attachments = [];
4249
2481
  const unattachable = [];
4250
- const rawSceneFallback = [];
4251
2482
  const productDirectives = [];
4252
2483
  const personDirectives = [];
4253
2484
  const otherDirectives = [];
@@ -4260,6 +2491,7 @@ function compileBrief(brief, ctx) {
4260
2491
  let hasPerson = false;
4261
2492
  let people = 0;
4262
2493
  let sentence = "";
2494
+ let userWords = "";
4263
2495
  const append = (s) => {
4264
2496
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
4265
2497
  };
@@ -4270,6 +2502,7 @@ function compileBrief(brief, ctx) {
4270
2502
  switch (tok.t) {
4271
2503
  case "text":
4272
2504
  append(tok.v);
2505
+ userWords += ` ${tok.v}`;
4273
2506
  break;
4274
2507
  case "product": {
4275
2508
  const p = products.find((x) => x.id === tok.id);
@@ -4426,14 +2659,8 @@ function compileBrief(brief, ctx) {
4426
2659
  append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
4427
2660
  if (ctx.mode !== "edit" && t.figure) {
4428
2661
  const plate = assetHash2(t.preview);
4429
- const hasPlate = !!plate && ctx.images.has(plate);
4430
- const candidates = hasPlate ? [plate] : (t.refs ?? []).slice(0, SCENE_REF_MAX).map((r) => assetHash2(r?.file));
4431
- for (const h of candidates) {
4432
- if (h && ctx.images.has(h)) {
4433
- const a = { role: "scene", id: t.id, label: t.name, hash: h, essential: false };
4434
- attachments.push(a);
4435
- if (!hasPlate) rawSceneFallback.push(a);
4436
- }
2662
+ if (plate && ctx.images.has(plate)) {
2663
+ attachments.push({ role: "scene", id: t.id, label: t.name, hash: plate, essential: false });
4437
2664
  }
4438
2665
  }
4439
2666
  break;
@@ -4479,15 +2706,12 @@ function compileBrief(brief, ctx) {
4479
2706
  if (scene?.subject === "product" && !productId) {
4480
2707
  warnings.push(`${scene.name} is built around a product. Add one to this brief.`);
4481
2708
  } else if (scene?.subject === "person" && !hasPerson) {
4482
- warnings.push(`${scene.name} is built around a person. Add a presenter.`);
2709
+ warnings.push(`${scene.name} is built around a person. With nobody attached, the set renders on its own.`);
4483
2710
  }
4484
2711
  const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
4485
2712
  const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
4486
- if (hasPerson && rawSceneFallback.length) {
4487
- for (const a of rawSceneFallback) {
4488
- const i = attachments.indexOf(a);
4489
- if (i !== -1) attachments.splice(i, 1);
4490
- }
2713
+ if (!hasPerson) {
2714
+ for (let i = attachments.length - 1; i >= 0; i--) if (attachments[i].role === "scene") attachments.splice(i, 1);
4491
2715
  }
4492
2716
  const identityHashes = /* @__PURE__ */ new Map();
4493
2717
  for (const a of attachments)
@@ -4533,10 +2757,14 @@ function compileBrief(brief, ctx) {
4533
2757
  );
4534
2758
  }
4535
2759
  }
2760
+ const figureRole = scene?.figure ?? (scene?.subject === "person" && !hasPerson ? PERSON_SCENE_FIGURE : void 0);
2761
+ const asked = shotAsksForAPerson(userWords);
2762
+ const emptyRole = figureRole && !hasPerson && !asked && ctx.mode !== "edit" ? figureRole : void 0;
4536
2763
  const guard = scene ? sceneGuardDirectives({
4537
2764
  hasProduct: !!productId,
4538
2765
  hasPerson,
4539
- hasScenePhoto: kept.some((a) => a.role === "scene")
2766
+ hasScenePhoto: kept.some((a) => a.role === "scene"),
2767
+ emptyRole
4540
2768
  }) : [];
4541
2769
  const pairDirectives = productId && hasPerson ? [
4542
2770
  "If the attached product is something a person wears, the presenter wears that exact product, with the rest of the outfit styled around it; otherwise the presenter presents or uses the product naturally.",
@@ -4550,10 +2778,11 @@ function compileBrief(brief, ctx) {
4550
2778
  productHandlingDirective()
4551
2779
  ] : [];
4552
2780
  const nameDirectives = productId || hasPerson ? [namesAreNotLetteringDirective()] : [];
4553
- const figureDirectives = scene?.figure ? sceneFigureDirectives({
4554
- figure: scene.figure,
4555
- treatment: scene.figureTreatment,
2781
+ const figureDirectives = figureRole && (hasPerson || ctx.mode !== "edit") ? sceneFigureDirectives({
2782
+ figure: figureRole,
2783
+ treatment: scene?.figureTreatment,
4556
2784
  hasPerson,
2785
+ asked,
4557
2786
  people,
4558
2787
  // The treatment's fictional-brands rule needs to know a real mark is
4559
2788
  // deliberately in play - and only one that actually rides counts,
@@ -6639,8 +4868,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
6639
4868
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
6640
4869
  return;
6641
4870
  }
6642
- const png = await sharp21(buf).rotate().png().toBuffer();
6643
- const meta = await sharp21(png).metadata();
4871
+ const png = await sharp20(buf).rotate().png().toBuffer();
4872
+ const meta = await sharp20(png).metadata();
6644
4873
  const hash = core.images.save(png);
6645
4874
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
6646
4875
  width: meta.width,
@@ -7117,7 +5346,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
7117
5346
  return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
7118
5347
  }
7119
5348
  async function edgeBarGeometry(buf) {
7120
- const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5349
+ const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7121
5350
  const W = info.width;
7122
5351
  const H = info.height;
7123
5352
  const scan = (len, cross, at) => {
@@ -7171,7 +5400,7 @@ async function trimEdgeBars(core, hash) {
7171
5400
  const width = g.right - g.left + 1;
7172
5401
  const height = g.bottom - g.top + 1;
7173
5402
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
7174
- const png = await sharp21(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5403
+ const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
7175
5404
  return core.images.save(png);
7176
5405
  } catch {
7177
5406
  return hash;
@@ -7242,7 +5471,7 @@ async function identityCrop(core, hash) {
7242
5471
  if (!out) return void 0;
7243
5472
  try {
7244
5473
  const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
7245
- const png = await sharp21(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
5474
+ const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
7246
5475
  const scaled = core.images.save(png);
7247
5476
  identityCrops.set(hash, scaled);
7248
5477
  return scaled;
@@ -7271,12 +5500,12 @@ async function brandJsonWithIdentityCrops(core, json, characterIds) {
7271
5500
  return changed ? { ...json, characters } : json;
7272
5501
  }
7273
5502
  async function figureBox(buf) {
7274
- const meta = await sharp21(buf).metadata();
5503
+ const meta = await sharp20(buf).metadata();
7275
5504
  const W = meta.width ?? 0;
7276
5505
  const H = meta.height ?? 0;
7277
5506
  if (!W || !H) return null;
7278
5507
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
7279
- const { info } = await sharp21(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
5508
+ const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
7280
5509
  const left = Math.abs(info.trimOffsetLeft ?? 0);
7281
5510
  const top = Math.abs(info.trimOffsetTop ?? 0);
7282
5511
  const width = info.width ?? 0;
@@ -7309,13 +5538,13 @@ async function smartCover(core, hash, box) {
7309
5538
  if (!hash || !core.images.has(hash)) return void 0;
7310
5539
  try {
7311
5540
  const buf = core.images.read(hash);
7312
- const meta = await sharp21(buf).metadata();
5541
+ const meta = await sharp20(buf).metadata();
7313
5542
  const w = meta.width ?? 0;
7314
5543
  const h = meta.height ?? 0;
7315
5544
  if (!w || !h) return void 0;
7316
5545
  const raw = box(w, h);
7317
5546
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
7318
- const png = await sharp21(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
5547
+ const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
7319
5548
  return core.images.save(png);
7320
5549
  } catch {
7321
5550
  return void 0;
@@ -7324,11 +5553,11 @@ async function smartCover(core, hash, box) {
7324
5553
  async function crop(core, hash, region, cap2) {
7325
5554
  if (!hash || !core.images.has(hash)) return void 0;
7326
5555
  try {
7327
- const meta = await sharp21(core.images.read(hash)).metadata();
5556
+ const meta = await sharp20(core.images.read(hash)).metadata();
7328
5557
  const w = meta.width ?? 0;
7329
5558
  const h = meta.height ?? 0;
7330
5559
  if (!w || !h) return void 0;
7331
- let pipeline = sharp21(core.images.read(hash)).extract(region(w, h));
5560
+ let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
7332
5561
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
7333
5562
  const png = await pipeline.png().toBuffer();
7334
5563
  return core.images.save(png);
@@ -7675,7 +5904,7 @@ var fromLab = (l, a, bb) => {
7675
5904
  return [clamp(R), clamp(G), clamp(B)];
7676
5905
  };
7677
5906
  var rawAt = async (png, edge) => {
7678
- let img = sharp21(png);
5907
+ let img = sharp20(png);
7679
5908
  if (edge) img = img.resize(edge, edge, { fit: "fill" });
7680
5909
  const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
7681
5910
  return { data, width: info.width, height: info.height };
@@ -7738,7 +5967,7 @@ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
7738
5967
  if (residual > GRADE_GATE_MEAN_DELTA) return null;
7739
5968
  const full = await rawAt(originalPng);
7740
5969
  applyAffine(full, T);
7741
- const image = await sharp21(full.data, {
5970
+ const image = await sharp20(full.data, {
7742
5971
  raw: { width: full.width, height: full.height, channels: 3 }
7743
5972
  }).png().toBuffer();
7744
5973
  return { image, residual };
@@ -7912,7 +6141,7 @@ function fitExpandToBudget(plan, source, pixelBudget) {
7912
6141
  }
7913
6142
  async function attentionCropOrigin(srcBuf, source, plan) {
7914
6143
  try {
7915
- const { info } = await sharp21(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6144
+ const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7916
6145
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
7917
6146
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
7918
6147
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -8065,23 +6294,23 @@ function relax(grid, seam, fixedSweeps) {
8065
6294
 
8066
6295
  // src/expand.ts
8067
6296
  async function expandCanvas(source, plan) {
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();
6297
+ 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();
6298
+ return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8070
6299
  }
8071
6300
  async function compositeExpand(engineImage, source, plan) {
8072
- const meta = await sharp21(engineImage).metadata();
6301
+ const meta = await sharp20(engineImage).metadata();
8073
6302
  const want = plan.width / plan.height;
8074
6303
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
8075
6304
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
8076
6305
  const aligned = sameOrientation;
8077
6306
  const exact = meta.width === plan.width && meta.height === plan.height;
8078
- const surround = aligned ? exact ? engineImage : await sharp21(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6307
+ const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
8079
6308
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
8080
- const image = await sharp21(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6309
+ const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8081
6310
  return { image, aligned };
8082
6311
  }
8083
6312
  async function matchMarginsToSeam(surround, source, plan) {
8084
- const src = await sharp21(source).metadata();
6313
+ const src = await sharp20(source).metadata();
8085
6314
  if (!src.width || !src.height) return surround;
8086
6315
  const SW = src.width;
8087
6316
  const SH = src.height;
@@ -8128,8 +6357,8 @@ var MAX_CORRECTION = 60;
8128
6357
  async function reconcile(surround, source, side, axis) {
8129
6358
  const { margin } = side;
8130
6359
  if (margin.width < 1 || margin.height < 1) return surround;
8131
- const marginRaw = await sharp21(surround).extract(margin).removeAlpha().raw().toBuffer();
8132
- const edgeRaw = await sharp21(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6360
+ const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
6361
+ const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
8133
6362
  const W = margin.width;
8134
6363
  const H = margin.height;
8135
6364
  const along = axis === "width" ? H : W;
@@ -8183,11 +6412,11 @@ async function reconcile(surround, source, side, axis) {
8183
6412
  }
8184
6413
  }
8185
6414
  }
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();
6415
+ const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6416
+ return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
8188
6417
  }
8189
6418
  async function expandCanvasBedOnly(source, plan) {
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();
6419
+ 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();
8191
6420
  }
8192
6421
  function medianOf(rgb, channel, from, to) {
8193
6422
  const n = to - from;
@@ -8198,17 +6427,17 @@ function medianOf(rgb, channel, from, to) {
8198
6427
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
8199
6428
  }
8200
6429
  async function reframeExpand(engineImage, plan) {
8201
- const meta = await sharp21(engineImage).metadata();
6430
+ const meta = await sharp20(engineImage).metadata();
8202
6431
  if (!(meta.width && meta.height)) return null;
8203
6432
  const want = plan.width / plan.height;
8204
6433
  const got = meta.width / meta.height;
8205
6434
  if (got >= 1 !== want >= 1) return null;
8206
6435
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
8207
6436
  const straight = Math.abs(got - want) / want <= 0.02;
8208
- return sharp21(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
6437
+ return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
8209
6438
  }
8210
6439
  async function seamScore(image, plan, source) {
8211
- const { data, info } = await sharp21(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6440
+ const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
8212
6441
  const W = info.width;
8213
6442
  const H = info.height;
8214
6443
  const horizontal = plan.axis === "width";
@@ -8241,7 +6470,7 @@ var SEAM_VISIBLE = 2.2;
8241
6470
  var OFFSET = 4;
8242
6471
  var RESIDUAL_VISIBLE = 15;
8243
6472
  async function seamResidual(image, plan, source) {
8244
- const { data, info } = await sharp21(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
6473
+ const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
8245
6474
  const W = info.width;
8246
6475
  const H = info.height;
8247
6476
  const ch = info.channels;
@@ -8276,7 +6505,7 @@ var MAX_SHARE = 0.8;
8276
6505
  async function subjectFraction(src, source, axis) {
8277
6506
  try {
8278
6507
  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)) };
8279
- const { info } = await sharp21(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6508
+ const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
8280
6509
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
8281
6510
  const span = axis === "width" ? source.width : source.height;
8282
6511
  const extent = axis === "width" ? window.width : window.height;
@@ -8299,14 +6528,14 @@ function placeExpand(plan, source, fraction) {
8299
6528
  }
8300
6529
  var NEUTRAL = { r: 128, g: 128, b: 128 };
8301
6530
  async function conditioningCanvas(source, plan, fill = "edge") {
8302
- const meta = await sharp21(source).metadata();
6531
+ const meta = await sharp20(source).metadata();
8303
6532
  const sw = meta.width ?? 0;
8304
6533
  const sh = meta.height ?? 0;
8305
6534
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
8306
6535
  const layers = [];
8307
6536
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
8308
6537
  layers.push({ input: source, left: plan.left, top: plan.top });
8309
- const canvas = sharp21({
6538
+ const canvas = sharp20({
8310
6539
  create: {
8311
6540
  width: plan.width,
8312
6541
  height: plan.height,
@@ -8318,7 +6547,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
8318
6547
  }
8319
6548
  async function edgeMargins(source, plan, size) {
8320
6549
  const out = [];
8321
- const strip = async (extract, width, height) => sharp21(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
6550
+ const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
8322
6551
  if (plan.axis === "width") {
8323
6552
  const before = plan.left;
8324
6553
  const after = plan.width - plan.left - size.width;
@@ -8409,12 +6638,12 @@ async function resolveOutpaintRoute(all, shot) {
8409
6638
  return { engine: shot, method: "reframe", crossed: false };
8410
6639
  }
8411
6640
  async function driftDiff(a, b) {
8412
- const metaA = await sharp21(a).metadata();
8413
- const metaB = await sharp21(b).metadata();
6641
+ const metaA = await sharp20(a).metadata();
6642
+ const metaB = await sharp20(b).metadata();
8414
6643
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
8415
6644
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
8416
6645
  const [rawA, rawB] = await Promise.all(
8417
- [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
6646
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
8418
6647
  );
8419
6648
  const out = new PNG({ width, height });
8420
6649
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -8426,11 +6655,11 @@ async function driftDiff(a, b) {
8426
6655
  };
8427
6656
  }
8428
6657
  async function changeMask(a, b, cap2 = 1024) {
8429
- const metaA = await sharp21(a).metadata();
6658
+ const metaA = await sharp20(a).metadata();
8430
6659
  const width = Math.min(metaA.width ?? 1, cap2);
8431
6660
  const height = Math.min(metaA.height ?? 1, cap2);
8432
6661
  const [rawA, rawB] = await Promise.all(
8433
- [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
6662
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
8434
6663
  );
8435
6664
  const out = new PNG({ width, height });
8436
6665
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -8479,8 +6708,8 @@ function dilationFor(longEdge) {
8479
6708
  // src/localEdit.ts
8480
6709
  async function preserveOutsideChange(source, edited) {
8481
6710
  try {
8482
- const srcMeta = await sharp21(source).metadata();
8483
- const outMeta = await sharp21(edited).metadata();
6711
+ const srcMeta = await sharp20(source).metadata();
6712
+ const outMeta = await sharp20(edited).metadata();
8484
6713
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
8485
6714
  return { image: edited, outcome: "error", changed: 0 };
8486
6715
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -8490,15 +6719,15 @@ async function preserveOutsideChange(source, edited) {
8490
6719
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
8491
6720
  const r = dilationFor(Math.max(shape.width, shape.height));
8492
6721
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
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, {
6722
+ const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
6723
+ const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
6724
+ const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
6725
+ const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
6726
+ const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
6727
+ const masked = await sharp20(editedRgb, {
8499
6728
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
8500
6729
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
8501
- const image = await sharp21(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
6730
+ const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
8502
6731
  return { image, outcome: "composited", changed: shape.changed };
8503
6732
  } catch {
8504
6733
  return { image: edited, outcome: "error", changed: 0 };
@@ -8536,7 +6765,7 @@ function registerLogoRoutes(app, deps) {
8536
6765
  const v = validateBrand(json);
8537
6766
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
8538
6767
  const row = core.store.updateBrand(brand.id, json);
8539
- const meta = await sharp21(core.images.read(part.hash)).metadata().catch(() => null);
6768
+ const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
8540
6769
  const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
8541
6770
  return { ...row, logoHash: part.hash, logoEdge };
8542
6771
  });
@@ -8658,7 +6887,7 @@ async function vibrantColor(input) {
8658
6887
  let data;
8659
6888
  let channels;
8660
6889
  try {
8661
- const out = await sharp21(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6890
+ const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
8662
6891
  data = out.data;
8663
6892
  channels = out.info.channels;
8664
6893
  } catch {
@@ -8681,7 +6910,7 @@ async function vibrantColor(input) {
8681
6910
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
8682
6911
  if (best.score <= 0) {
8683
6912
  try {
8684
- const { dominant } = await sharp21(input).stats();
6913
+ const { dominant } = await sharp20(input).stats();
8685
6914
  return toHex(dominant.r, dominant.g, dominant.b);
8686
6915
  } catch {
8687
6916
  return null;
@@ -8708,7 +6937,7 @@ var toHex = (r, g, b) => "#" + [r, g, b].map(
8708
6937
 
8709
6938
  // src/routes/scenes.ts
8710
6939
  function registerSceneRoutes(app, deps) {
8711
- const { templatesRoot, scenes } = deps;
6940
+ const { templatesRoot, scenes, thumbs } = deps;
8712
6941
  const previewPath = (id) => contentFile(templatesRoot, "previews", `${id}.jpg`);
8713
6942
  const previewColors = /* @__PURE__ */ new Map();
8714
6943
  const previewColor = async (id) => {
@@ -8731,7 +6960,8 @@ function registerSceneRoutes(app, deps) {
8731
6960
  app.get("/api/scene-thumbnails/:file", async (req, reply) => {
8732
6961
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
8733
6962
  if (!m || !existsSync(previewPath(m[1]))) return reply.status(404).send({ error: "no preview" });
8734
- return serveJpeg(req, reply, previewPath(m[1]));
6963
+ const path = previewPath(m[1]);
6964
+ return serveJpegSized(req, reply, path, thumbs, fileKey("scene", m[1], path));
8735
6965
  });
8736
6966
  const refPath = (id, slot) => contentFile(templatesRoot, "previews", id, `${slot}.jpg`);
8737
6967
  app.get("/api/scene-previews/:id", async (req, reply) => {
@@ -8749,7 +6979,7 @@ function registerSceneRoutes(app, deps) {
8749
6979
  });
8750
6980
  }
8751
6981
  function registerPresenterRoutes(app, deps) {
8752
- const { templatesRoot, presenters } = deps;
6982
+ const { templatesRoot, presenters, thumbs } = deps;
8753
6983
  const presenterThumbPath = (id) => contentFile(templatesRoot, "previews", "presenters", `${id}.jpg`);
8754
6984
  const avatarPath = (id) => presenterAvatarPath(templatesRoot, id);
8755
6985
  const decoratePresenter = (p) => ({
@@ -8766,12 +6996,14 @@ function registerPresenterRoutes(app, deps) {
8766
6996
  app.get("/api/presenter-thumbnails/:file", async (req, reply) => {
8767
6997
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
8768
6998
  if (!m || !existsSync(presenterThumbPath(m[1]))) return reply.status(404).send({ error: "no preview" });
8769
- return serveJpeg(req, reply, presenterThumbPath(m[1]));
6999
+ const path = presenterThumbPath(m[1]);
7000
+ return serveJpegSized(req, reply, path, thumbs, fileKey("presenter", m[1], path));
8770
7001
  });
8771
7002
  app.get("/api/presenter-avatars/:file", async (req, reply) => {
8772
7003
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
8773
7004
  if (!m || !existsSync(avatarPath(m[1]))) return reply.status(404).send({ error: "no avatar" });
8774
- return serveJpeg(req, reply, avatarPath(m[1]));
7005
+ const path = avatarPath(m[1]);
7006
+ return serveJpegSized(req, reply, path, thumbs, fileKey("avatar", m[1], path));
8775
7007
  });
8776
7008
  app.get("/api/presenter-previews/:id", async (req, reply) => {
8777
7009
  const id = /^[a-z0-9-]+$/.exec(String(req.params.id))?.[0];
@@ -9085,7 +7317,7 @@ async function withDerivedCrops(core, body, base) {
9085
7317
  };
9086
7318
  }
9087
7319
  function registerDemoProductRoutes(app, deps) {
9088
- const { templatesRoot, demoProducts, demoProductById } = deps;
7320
+ const { templatesRoot, demoProducts, demoProductById, thumbs } = deps;
9089
7321
  const demoProductThumbPath = (id) => {
9090
7322
  const p = demoProductById(id);
9091
7323
  if (!p) return null;
@@ -9111,9 +7343,10 @@ function registerDemoProductRoutes(app, deps) {
9111
7343
  }));
9112
7344
  app.get("/api/demo-product-thumbnails/:file", async (req, reply) => {
9113
7345
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
9114
- const path = m ? demoProductThumbPath(m[1]) : null;
7346
+ if (!m) return reply.status(404).send({ error: "no preview" });
7347
+ const path = demoProductThumbPath(m[1]);
9115
7348
  if (!path || !existsSync(path)) return reply.status(404).send({ error: "no preview" });
9116
- return serveJpeg(req, reply, path);
7349
+ return serveJpegSized(req, reply, path, thumbs, fileKey("demo", m[1], path));
9117
7350
  });
9118
7351
  app.get("/api/demo-product-previews/:id", async (req, reply) => {
9119
7352
  const id = /^[a-z0-9-]+$/.exec(String(req.params.id))?.[0];
@@ -9354,22 +7587,6 @@ function registerCodexSetupRoutes(app, deps) {
9354
7587
  }
9355
7588
  });
9356
7589
  }
9357
- var EXPORT_PRESETS = [
9358
- { id: "original", label: "Original", width: null, height: null },
9359
- { id: "ig-post", label: "Instagram post 1080\xD71080", width: 1080, height: 1080 },
9360
- { id: "ig-story", label: "Story 1080\xD71920", width: 1080, height: 1920 },
9361
- { id: "banner", label: "Banner 1200\xD7628", width: 1200, height: 628 }
9362
- ];
9363
- async function buildExportZip(image, baseName, presetIds) {
9364
- const zip = new JSZip();
9365
- const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
9366
- if (chosen.length === 0) throw new Error("No valid export presets selected");
9367
- for (const p of chosen) {
9368
- const buf = p.width && p.height ? await sharp21(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
9369
- zip.file(`${baseName}-${p.id}.png`, buf);
9370
- }
9371
- return zip.generateAsync({ type: "nodebuffer" });
9372
- }
9373
7590
  var slug = (v, fallback) => {
9374
7591
  const s = String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
9375
7592
  return s || fallback;
@@ -9511,96 +7728,6 @@ function readme(json, missing) {
9511
7728
  ${missing} referenced image${missing === 1 ? " was" : "s were"} missing and left out.` : ""
9512
7729
  ].join("\n");
9513
7730
  }
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
- }
9604
7731
 
9605
7732
  // src/routes/images.ts
9606
7733
  var IMMUTABLE = "public, max-age=31536000, immutable";
@@ -9621,7 +7748,7 @@ function registerImageRoutes(app, deps) {
9621
7748
  app.get("/api/images/:hash/thumb", async (req, reply) => {
9622
7749
  const hash = String(req.params.hash);
9623
7750
  const w = Number(req.query?.w);
9624
- if (!isThumbWidth(w)) return reply.status(400).send({ error: "w must be 640 or 160" });
7751
+ if (!isThumbWidth(w)) return reply.status(400).send({ error: `w must be one of ${THUMB_WIDTH_LIST}` });
9625
7752
  if (!/^[a-f0-9]{32}$/.test(hash)) return reply.status(404).send({ error: "image not found" });
9626
7753
  const etag = `"${hash}-w${w}"`;
9627
7754
  if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", IMMUTABLE).send();
@@ -9639,8 +7766,8 @@ function registerImageRoutes(app, deps) {
9639
7766
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
9640
7767
  const buf = await part.toBuffer();
9641
7768
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
9642
- const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
9643
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
7769
+ const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
7770
+ const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
9644
7771
  return { hash: core.images.save(png) };
9645
7772
  });
9646
7773
  app.post("/api/diff", async (req, reply) => {
@@ -9651,7 +7778,6 @@ function registerImageRoutes(app, deps) {
9651
7778
  const heatmapHash = core.images.save(d.heatmap);
9652
7779
  return { score: d.score, heatmapHash, width: d.width, height: d.height };
9653
7780
  });
9654
- app.get("/api/export/presets", async () => EXPORT_PRESETS);
9655
7781
  app.get("/api/brands/:id/export", async (req, reply) => {
9656
7782
  const brandId = String(req.params.id);
9657
7783
  if (!core.store.getBrand(brandId)) return reply.status(404).send({ error: "brand not found" });
@@ -9659,22 +7785,51 @@ function registerImageRoutes(app, deps) {
9659
7785
  reply.header("content-type", "application/zip").header("content-disposition", `attachment; filename="${filename}"`);
9660
7786
  return reply.send(zip);
9661
7787
  });
9662
- app.post("/api/export", async (req, reply) => {
9663
- const { imageHash, presets, baseName = "scenri-export" } = req.body;
9664
- if (!core.images.has(String(imageHash))) return reply.status(404).send({ error: "image not found" });
9665
- const safeBase = String(baseName).replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 60) || "export";
9666
- const zip = await buildExportZip(
9667
- core.images.read(String(imageHash)),
9668
- safeBase,
9669
- Array.isArray(presets) ? presets.map(String) : []
9670
- );
9671
- reply.header("content-type", "application/zip").header("content-disposition", `attachment; filename="${safeBase}.zip"`);
9672
- return reply.send(zip);
9673
- });
9674
7788
  }
9675
7789
 
9676
7790
  // src/release/notes.data.ts
9677
7791
  var RELEASES = [
7792
+ {
7793
+ version: "0.9.0",
7794
+ date: "2026-09-06",
7795
+ title: "Scenri on your desktop",
7796
+ sections: [
7797
+ {
7798
+ heading: "Desktop",
7799
+ body: "On macOS and Windows, Scenri offers once to put an icon on your desktop the first time it starts from a terminal, and Settings can add it any time later. A double-click opens the browser on a running Scenri or starts one without a terminal, with a Starting page until it answers, and Shut down Scenri at the bottom of the brand menu stops it."
7800
+ },
7801
+ {
7802
+ heading: "Shots",
7803
+ body: "Reuse setup is back under the record of an open shot, beside Try again: a new shot from the same prompt, chips, shape and count, ready to change. A long record ends on a whole row with Show more, a colour chip is the same circle a picture chip is, and a chip menu opens against its chip."
7804
+ },
7805
+ {
7806
+ heading: "Fixes",
7807
+ body: "The composer, dividers and copy control in the shot sidebar share one edge. The update and shutdown overlays sit over an open Settings dialog instead of behind it, and a browser that cannot be opened is told the address in a dialog."
7808
+ }
7809
+ ]
7810
+ },
7811
+ {
7812
+ version: "0.8.3",
7813
+ date: "2026-09-06",
7814
+ sections: [
7815
+ {
7816
+ heading: "Create",
7817
+ body: "The + beside the prompt is a picker for adding to the shot: products, presenters, scenes, brand colours, your logo and finished shots in one grid, with search, Upload image and paste. A tile pressed again takes its chip out, and every tab can make a new one of its own."
7818
+ },
7819
+ {
7820
+ heading: "Shots",
7821
+ body: "An open shot has the rest of the feed beside it as a rail, and its own history under the picture as a trail of tiles, the original and each refinement. A right click on the picture holds its actions, Download is one click, and Compare is gone."
7822
+ },
7823
+ {
7824
+ heading: "Refine",
7825
+ body: "Refining is the ask alone. The field names the picture it is about and follows the stage as you step, and a refinement is recorded as what you asked, not the references that rode along."
7826
+ },
7827
+ {
7828
+ heading: "Scenes",
7829
+ body: "A scene with nobody attached renders its set alone. No stand-in person appears."
7830
+ }
7831
+ ]
7832
+ },
9678
7833
  {
9679
7834
  version: "0.8.2",
9680
7835
  date: "2026-09-03",
@@ -10570,6 +8725,76 @@ function registerSystemRoutes(app, deps) {
10570
8725
  });
10571
8726
  }
10572
8727
 
8728
+ // src/routes/desktop.ts
8729
+ function registerDesktopRoutes(app, deps) {
8730
+ const { core, runtime } = deps;
8731
+ const status = deps.statusImpl ?? (async () => {
8732
+ if (!runtime.entry) {
8733
+ return {
8734
+ supported: false,
8735
+ platform: process.platform,
8736
+ installed: false,
8737
+ path: null,
8738
+ current: false,
8739
+ record: null
8740
+ };
8741
+ }
8742
+ const { installDeps } = await import('./cli-BVPVS4V5.js');
8743
+ const { desktopStatus } = await import('./install-V6DAA7TT.js');
8744
+ return desktopStatus(installDeps(runtime.entry));
8745
+ });
8746
+ const install = deps.installImpl ?? (async () => {
8747
+ if (!runtime.entry) {
8748
+ return { ok: false, reason: "unsupported", message: "Desktop shortcuts are not available on this system yet." };
8749
+ }
8750
+ const { addToDesktop } = await import('./cli-BVPVS4V5.js');
8751
+ return addToDesktop(runtime.entry);
8752
+ });
8753
+ app.get("/api/desktop", async () => {
8754
+ const s = await status();
8755
+ return {
8756
+ supported: s.supported,
8757
+ platform: s.platform,
8758
+ installed: s.installed,
8759
+ path: s.path,
8760
+ declined: core.store.getSetting("desktop.prompt") === "declined",
8761
+ installKind: runtime.installKind
8762
+ };
8763
+ });
8764
+ app.post("/api/desktop/install", async (_req, reply) => {
8765
+ if (runtime.installKind === "dev") {
8766
+ return reply.status(409).send({
8767
+ error: "Running from a source checkout; there is no installed build to put on a desktop.",
8768
+ reason: "dev"
8769
+ });
8770
+ }
8771
+ const res = await install();
8772
+ if (!res.ok) return reply.status(409).send({ error: res.message, reason: res.reason });
8773
+ return { ok: true, path: res.path };
8774
+ });
8775
+ app.post("/api/system/quit", async (_req, reply) => {
8776
+ const busy = deps.busyCount();
8777
+ if (busy > 0) {
8778
+ return reply.status(409).send({ error: `work is still running (${busy} task${busy === 1 ? "" : "s"})` });
8779
+ }
8780
+ reply.send({ ok: true });
8781
+ const exit = deps.exitImpl ?? ((code) => process.exit(code));
8782
+ setTimeout(() => {
8783
+ let done = false;
8784
+ setTimeout(() => {
8785
+ if (!done) exit(0);
8786
+ }, 5e3).unref();
8787
+ void app.drain().then(() => {
8788
+ done = true;
8789
+ exit(0);
8790
+ }).catch(() => {
8791
+ done = true;
8792
+ exit(0);
8793
+ });
8794
+ }, 50);
8795
+ });
8796
+ }
8797
+
10573
8798
  // src/server.ts
10574
8799
  function seedFor(sourceHash, width, height) {
10575
8800
  let h = 2166136261;
@@ -10618,7 +8843,7 @@ function buildServer(opts) {
10618
8843
  // Measured as stored (post-toMarkPng), so the scrape judges the same
10619
8844
  // pixels the compiler will one day attach.
10620
8845
  probeLongEdge: async (buf) => {
10621
- const m = await sharp21(await toMarkPng(buf)).metadata();
8846
+ const m = await sharp20(await toMarkPng(buf)).metadata();
10622
8847
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
10623
8848
  },
10624
8849
  createdWith: `${meta.name}/${meta.version}`
@@ -10707,7 +8932,7 @@ function buildServer(opts) {
10707
8932
  fetchImpl: opts.fetchImpl,
10708
8933
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
10709
8934
  probeLongEdge: async (buf) => {
10710
- const m = await sharp21(await toMarkPng(buf)).metadata();
8935
+ const m = await sharp20(await toMarkPng(buf)).metadata();
10711
8936
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
10712
8937
  },
10713
8938
  createdWith: `${meta.name}/${meta.version}`
@@ -10802,14 +9027,14 @@ function buildServer(opts) {
10802
9027
  });
10803
9028
  registerCatalogImportRoutes(app, { core, fetchImpl: opts.fetchImpl });
10804
9029
  const templatesRoot = opts.templatesDir ?? defaultScenesDir();
10805
- registerSceneRoutes(app, { templatesRoot, scenes });
9030
+ registerSceneRoutes(app, { templatesRoot, scenes, thumbs });
10806
9031
  const presentersDir = join(templatesRoot, "presenters");
10807
9032
  const { presenters } = loadPresenters(presentersDir);
10808
- registerPresenterRoutes(app, { templatesRoot, presenters });
9033
+ registerPresenterRoutes(app, { templatesRoot, presenters, thumbs });
10809
9034
  registerAssetBuildRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters });
10810
9035
  const { demoProducts } = loadDemoProducts(join(templatesRoot, "demo-products"));
10811
9036
  const demoProductById = demoProductResolver(demoProducts);
10812
- registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
9037
+ registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById, thumbs });
10813
9038
  registerShowcaseRoutes(app, { templatesRoot });
10814
9039
  app.get("/api/formats", async () => FORMATS);
10815
9040
  function briefInputsOnly(brief) {
@@ -11080,11 +9305,11 @@ function buildServer(opts) {
11080
9305
  const out = [];
11081
9306
  for (const h of images) {
11082
9307
  const buf = core.images.read(h);
11083
- const meta2 = await sharp21(buf).metadata().catch(() => null);
9308
+ const meta2 = await sharp20(buf).metadata().catch(() => null);
11084
9309
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
11085
9310
  const oriented = (meta2.orientation ?? 1) !== 1;
11086
9311
  out.push(
11087
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp21(buf).rotate().png().toBuffer())
9312
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
11088
9313
  );
11089
9314
  }
11090
9315
  return out;
@@ -11096,7 +9321,7 @@ function buildServer(opts) {
11096
9321
  const out = [];
11097
9322
  for (const h of images) {
11098
9323
  const buf = core.images.read(h);
11099
- const meta2 = await sharp21(buf).metadata();
9324
+ const meta2 = await sharp20(buf).metadata();
11100
9325
  if (!meta2.width || !meta2.height) {
11101
9326
  out.push(h);
11102
9327
  continue;
@@ -11109,7 +9334,7 @@ function buildServer(opts) {
11109
9334
  }
11110
9335
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
11111
9336
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
11112
- const cropped = await sharp21(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9337
+ const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
11113
9338
  app.log.info(
11114
9339
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
11115
9340
  "canvas: cropped a drifted frame to the asked ratio"
@@ -11128,7 +9353,7 @@ function buildServer(opts) {
11128
9353
  async function assertAspect(images, expect) {
11129
9354
  const want = expect.width / expect.height;
11130
9355
  for (const h of images) {
11131
- const meta2 = await sharp21(core.images.read(h)).metadata();
9356
+ const meta2 = await sharp20(core.images.read(h)).metadata();
11132
9357
  if (!meta2.width || !meta2.height) continue;
11133
9358
  const got = meta2.width / meta2.height;
11134
9359
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -11159,7 +9384,7 @@ function buildServer(opts) {
11159
9384
  if (post) own = await post(own);
11160
9385
  if (expect) await assertAspect(own, expect);
11161
9386
  try {
11162
- const meta2 = await sharp21(core.images.read(own[0])).metadata();
9387
+ const meta2 = await sharp20(core.images.read(own[0])).metadata();
11163
9388
  const node = core.store.getNode(id);
11164
9389
  if (node && meta2.width && meta2.height) {
11165
9390
  const brief = node.brief ?? {};
@@ -11268,7 +9493,7 @@ function buildServer(opts) {
11268
9493
  crop: window
11269
9494
  });
11270
9495
  const work2 = async () => ({
11271
- images: [core.images.save(await sharp21(args.srcBuf).extract(window).png().toBuffer())],
9496
+ images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
11272
9497
  costUsd: 0
11273
9498
  });
11274
9499
  void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -11293,7 +9518,7 @@ function buildServer(opts) {
11293
9518
  if (!srcHash || !core.images.has(String(srcHash)))
11294
9519
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
11295
9520
  const srcBuf = core.images.read(String(srcHash));
11296
- const srcMeta = await sharp21(srcBuf).metadata();
9521
+ const srcMeta = await sharp20(srcBuf).metadata();
11297
9522
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
11298
9523
  return runCropNode({
11299
9524
  parentId: cropParentId,
@@ -11342,7 +9567,7 @@ function buildServer(opts) {
11342
9567
  );
11343
9568
  extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
11344
9569
  if (!compiled2.prompt.trim() && reshape !== "extend")
11345
- return reply.status(400).send({ error: "the brief is empty" });
9570
+ return reply.status(400).send({ error: "the prompt is empty" });
11346
9571
  } else {
11347
9572
  const brandJson = await brandJsonWithIdentityCrops(
11348
9573
  core,
@@ -11370,7 +9595,7 @@ function buildServer(opts) {
11370
9595
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
11371
9596
  templateById: sceneById
11372
9597
  });
11373
- if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
9598
+ if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the prompt is empty" });
11374
9599
  }
11375
9600
  }
11376
9601
  let finalPrompt = String(prompt ?? "");
@@ -11409,7 +9634,7 @@ function buildServer(opts) {
11409
9634
  });
11410
9635
  if (productId && !compiled2.attachments.some((a) => a.role === "product"))
11411
9636
  return reply.status(400).send({ error: "product has no usable shots" });
11412
- if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
9637
+ if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the prompt is empty" });
11413
9638
  }
11414
9639
  let estimate;
11415
9640
  let work;
@@ -11508,7 +9733,7 @@ function buildServer(opts) {
11508
9733
  );
11509
9734
  }
11510
9735
  const srcBuf = core.images.read(String(srcHash));
11511
- const srcMeta = await sharp21(srcBuf).metadata();
9736
+ const srcMeta = await sharp20(srcBuf).metadata();
11512
9737
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
11513
9738
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
11514
9739
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
@@ -11542,7 +9767,7 @@ function buildServer(opts) {
11542
9767
  } else if (decision.op === "extend") {
11543
9768
  if (decision.assist) {
11544
9769
  expandAssist = { width: decision.assist.width, height: decision.assist.height };
11545
- workBuf = await sharp21(srcBuf).extract(decision.assist).png().toBuffer();
9770
+ workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
11546
9771
  workSize = { width: decision.assist.width, height: decision.assist.height };
11547
9772
  }
11548
9773
  expandPlan = planExpand(workSize, targetRatio);
@@ -11563,7 +9788,7 @@ function buildServer(opts) {
11563
9788
  const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
11564
9789
  if (fit.scale < 1) {
11565
9790
  expandPlan = fit.plan;
11566
- workBuf = await sharp21(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
9791
+ workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
11567
9792
  workSize = fit.source;
11568
9793
  extraWarnings.push(
11569
9794
  `${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.`
@@ -11586,7 +9811,7 @@ function buildServer(opts) {
11586
9811
  if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
11587
9812
  sentSize = stepped;
11588
9813
  budgetSourceHash = core.images.save(
11589
- await sharp21(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
9814
+ await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
11590
9815
  );
11591
9816
  if (!gradeOnlyAsk)
11592
9817
  extraWarnings.push(
@@ -11726,11 +9951,11 @@ function buildServer(opts) {
11726
9951
  const original = editedFrom ? core.images.read(editedFrom) : null;
11727
9952
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
11728
9953
  const enforceEditCanvas = async (images) => {
11729
- const srcMeta = await sharp21(original).metadata();
9954
+ const srcMeta = await sharp20(original).metadata();
11730
9955
  if (!srcMeta.width || !srcMeta.height) return images;
11731
9956
  const out = [];
11732
9957
  for (const h of images) {
11733
- const meta2 = await sharp21(core.images.read(h)).metadata();
9958
+ const meta2 = await sharp20(core.images.read(h)).metadata();
11734
9959
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
11735
9960
  const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
11736
9961
  pixelBudget: runEngine.capabilities().editPixelBudget
@@ -11760,7 +9985,7 @@ function buildServer(opts) {
11760
9985
  );
11761
9986
  out.push(
11762
9987
  core.images.save(
11763
- await sharp21(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
9988
+ await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
11764
9989
  )
11765
9990
  );
11766
9991
  try {
@@ -11782,7 +10007,7 @@ function buildServer(opts) {
11782
10007
  const out = [];
11783
10008
  for (const h of images) {
11784
10009
  const answer = core.images.read(h);
11785
- const got = await sharp21(answer).metadata();
10010
+ const got = await sharp20(answer).metadata();
11786
10011
  if (got.width !== plan.width || got.height !== plan.height)
11787
10012
  app.log.info(
11788
10013
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -11950,6 +10175,12 @@ function buildServer(opts) {
11950
10175
  return drained;
11951
10176
  });
11952
10177
  registerSystemRoutes(app, { core, thumbs });
10178
+ registerDesktopRoutes(app, {
10179
+ core,
10180
+ runtime,
10181
+ exitImpl: opts.exitImpl,
10182
+ busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
10183
+ });
11953
10184
  if (opts.studioDist && existsSync(opts.studioDist)) {
11954
10185
  const dist = opts.studioDist;
11955
10186
  app.register(fastifyStatic, { root: dist });
@@ -11976,18 +10207,6 @@ function lanAddresses() {
11976
10207
  }
11977
10208
  return out;
11978
10209
  }
11979
- function detectInstallKind(entryPath, home) {
11980
- if (!entryPath.includes(`${sep}dist${sep}`) && !entryPath.endsWith(`${sep}dist`)) return "dev";
11981
- let homeReal = home;
11982
- try {
11983
- homeReal = realpathSync(home);
11984
- } catch {
11985
- }
11986
- if (entryPath.startsWith(join(homeReal, "app", "versions") + sep)) return "managed";
11987
- if (entryPath.includes(`${sep}_npx${sep}`)) return "npx";
11988
- if (entryPath.includes(`${sep}node_modules${sep}`)) return "global";
11989
- return "unknown";
11990
- }
11991
10210
  async function serve() {
11992
10211
  try {
11993
10212
  await run();
@@ -12021,7 +10240,8 @@ async function run() {
12021
10240
  runtime: {
12022
10241
  installKind,
12023
10242
  supervised,
12024
- launcherProtocol: Number(process.env.SCENRI_LAUNCHER_PROTOCOL ?? "1") || 1
10243
+ launcherProtocol: Number(process.env.SCENRI_LAUNCHER_PROTOCOL ?? "1") || 1,
10244
+ entry: fileURLToPath(import.meta.url)
12025
10245
  }
12026
10246
  });
12027
10247
  for (let attempt = 0; ; attempt++) {
@@ -12111,15 +10331,43 @@ async function run() {
12111
10331
  tellUrl();
12112
10332
  }
12113
10333
  }
10334
+ const ownEntry = fileURLToPath(import.meta.url);
10335
+ const { addToDesktop, installDeps } = await import('./cli-BVPVS4V5.js');
10336
+ const { refreshLauncher } = await import('./refresh-7HXPUHLQ.js');
10337
+ const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-IVTNHIDJ.js');
10338
+ const { launcherInstalled } = await import('./paths-36Y73GGY.js');
10339
+ const meta = readMeta();
10340
+ void refreshLauncher({ ...installDeps(ownEntry), ownEntry, installKind, pkg: meta.name }).then((r) => {
10341
+ if (r.adopted)
10342
+ console.log(` keeping a copy of Scenri ${meta.version} in ${join(core.home, "app")} for the desktop icon`);
10343
+ }).catch(() => void 0);
10344
+ const offer = shouldOfferDesktop({
10345
+ env: process.env,
10346
+ stdinTTY: Boolean(process.stdin.isTTY),
10347
+ stdoutTTY: Boolean(process.stdout.isTTY),
10348
+ platform: process.platform,
10349
+ installKind,
10350
+ launcherInstalled: launcherInstalled(homedir()),
10351
+ declined: core.store.getSetting("desktop.prompt") === "declined"
10352
+ });
10353
+ if (offer) {
10354
+ await offerDesktop({
10355
+ ask: askOnTerminal,
10356
+ add: () => addToDesktop(ownEntry),
10357
+ decline: () => core.store.setSetting("desktop.prompt", "declined"),
10358
+ say: (line) => console.log(line)
10359
+ });
10360
+ console.log("");
10361
+ }
12114
10362
  }
12115
10363
  async function verify() {
12116
10364
  try {
12117
- const { default: Database2 } = await import('better-sqlite3');
12118
- const db = new Database2(":memory:");
10365
+ const { default: Database } = await import('better-sqlite3');
10366
+ const db = new Database(":memory:");
12119
10367
  db.pragma("user_version");
12120
10368
  db.close();
12121
- const { default: sharp22 } = await import('sharp');
12122
- await sharp22({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10369
+ const { default: sharp21 } = await import('sharp');
10370
+ await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
12123
10371
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
12124
10372
  } catch (err) {
12125
10373
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
@@ -12127,6 +10375,6 @@ async function verify() {
12127
10375
  }
12128
10376
  }
12129
10377
 
12130
- export { detectInstallKind, serve, verify };
10378
+ export { serve, verify };
12131
10379
  //# sourceMappingURL=serve.js.map
12132
10380
  //# sourceMappingURL=serve.js.map