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/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/dist/chunk-4NQKRVO2.js +55 -0
- package/dist/chunk-HLMR33DT.js +246 -0
- package/dist/chunk-OJAG3FRX.js +1940 -0
- package/dist/chunk-PAIAXRAC.js +20 -0
- package/dist/chunk-QDODTMSN.js +62 -0
- package/dist/cli-BVPVS4V5.js +376 -0
- package/dist/index.js +23 -2
- package/dist/install-V6DAA7TT.js +4 -0
- package/dist/{launcher-DIZ4MQRF.js → launcher-IVFIYHPN.js} +6 -2
- package/dist/offer-IVTNHIDJ.js +47 -0
- package/dist/paths-36Y73GGY.js +3 -0
- package/dist/refresh-7HXPUHLQ.js +91 -0
- package/dist/serve.js +434 -2186
- package/dist/src-CSR34EBZ.js +3 -0
- package/launcher/Scenri.icns +0 -0
- package/launcher/launch.mjs +144 -0
- package/launcher/scenri.ico +0 -0
- package/launcher/starting.html +78 -0
- package/package.json +2 -1
- package/studio-dist/assets/index-DSSGRUMi.js +104 -0
- package/studio-dist/assets/{index-DwfWp4jH.css → index-DfAHZRYM.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-9Y9YnJq6.js +0 -103
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import { fileURLToPath } from 'url';
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
3980
|
-
const meta = await
|
|
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
|
|
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
|
|
2188
|
+
const meta = await sharp20(path).metadata();
|
|
3995
2189
|
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
3996
|
-
const buf = await
|
|
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}.
|
|
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.
|
|
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
|
-
|
|
4430
|
-
|
|
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.
|
|
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
|
|
4487
|
-
for (
|
|
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 =
|
|
4554
|
-
figure:
|
|
4555
|
-
treatment: scene
|
|
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
|
|
6643
|
-
const meta = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
8069
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
8132
|
-
const edgeRaw = await
|
|
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
|
|
8187
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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) =>
|
|
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
|
|
8413
|
-
const metaB = await
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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
|
|
8483
|
-
const outMeta = await
|
|
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
|
|
8494
|
-
const dilated = await
|
|
8495
|
-
const feathered = await
|
|
8496
|
-
const grown = await
|
|
8497
|
-
const editedRgb = await
|
|
8498
|
-
const masked = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
9643
|
-
const png = fmt === "svg" ? await toMarkPng(buf) : await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
12118
|
-
const db = new
|
|
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:
|
|
12122
|
-
await
|
|
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 {
|
|
10378
|
+
export { serve, verify };
|
|
12131
10379
|
//# sourceMappingURL=serve.js.map
|
|
12132
10380
|
//# sourceMappingURL=serve.js.map
|