scenri 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/dist/serve.js +778 -148
- package/package.json +2 -2
- package/studio-dist/assets/index-9Y9YnJq6.js +103 -0
- package/studio-dist/assets/{index-CLW2Ca_7.css → index-DwfWp4jH.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-D0hEbSDx.js +0 -104
package/dist/serve.js
CHANGED
|
@@ -6,11 +6,11 @@ import { networkInterfaces, homedir, tmpdir } from 'os';
|
|
|
6
6
|
import { sep, join, dirname, normalize } from 'path';
|
|
7
7
|
import Database from 'better-sqlite3';
|
|
8
8
|
import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
|
|
9
|
-
import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, readdirSync, statSync,
|
|
9
|
+
import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, createReadStream, rmSync, readdirSync, statSync, renameSync } from 'fs';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
-
import { readFile, copyFile, stat, mkdtemp, rm,
|
|
11
|
+
import { readFile, copyFile, stat, access, readdir, mkdtemp, rm, rename, unlink, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
|
-
import
|
|
13
|
+
import sharp21 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
15
15
|
import fastifyStatic from '@fastify/static';
|
|
16
16
|
import fastifyMultipart from '@fastify/multipart';
|
|
@@ -297,8 +297,14 @@ function collapseProjects(db) {
|
|
|
297
297
|
})();
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
+
var IMAGES_SPLIT_MARK = "v1";
|
|
300
301
|
function splitMultiImageNodes(db) {
|
|
301
|
-
const
|
|
302
|
+
const done = db.prepare("SELECT value FROM settings WHERE key='images_split'").get()?.value;
|
|
303
|
+
if (done === IMAGES_SPLIT_MARK) return;
|
|
304
|
+
const mark = () => db.prepare(
|
|
305
|
+
"INSERT INTO settings (key, value) VALUES ('images_split', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
306
|
+
).run(IMAGES_SPLIT_MARK);
|
|
307
|
+
const rows = db.prepare("SELECT * FROM nodes WHERE images LIKE '%,%'").all();
|
|
302
308
|
const multi = rows.filter((r) => {
|
|
303
309
|
try {
|
|
304
310
|
return JSON.parse(r.images).length > 1;
|
|
@@ -306,7 +312,10 @@ function splitMultiImageNodes(db) {
|
|
|
306
312
|
return false;
|
|
307
313
|
}
|
|
308
314
|
});
|
|
309
|
-
if (!multi.length)
|
|
315
|
+
if (!multi.length) {
|
|
316
|
+
mark();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
310
319
|
const parse2 = (s) => {
|
|
311
320
|
if (!s) return null;
|
|
312
321
|
try {
|
|
@@ -389,6 +398,89 @@ function splitMultiImageNodes(db) {
|
|
|
389
398
|
}
|
|
390
399
|
}
|
|
391
400
|
})();
|
|
401
|
+
mark();
|
|
402
|
+
}
|
|
403
|
+
function ensureIndexes(db) {
|
|
404
|
+
db.exec(`
|
|
405
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_created ON nodes(project_id, created_at, id);
|
|
406
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_kept ON nodes(project_id, kept, created_at, id);
|
|
407
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_cost ON nodes(project_id, cost_usd, created_at, id);
|
|
408
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_state ON nodes(project_id, kind, archived, kept);
|
|
409
|
+
DROP INDEX IF EXISTS idx_nodes_parent;
|
|
410
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_parent_created ON nodes(parent_id, created_at, id);
|
|
411
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
|
|
412
|
+
CREATE INDEX IF NOT EXISTS idx_catalog_variants_product ON catalog_variants(product_id);
|
|
413
|
+
CREATE INDEX IF NOT EXISTS idx_cost_events_engine_ts ON cost_events(engine_id, ts);
|
|
414
|
+
`);
|
|
415
|
+
}
|
|
416
|
+
function searchTextSql(alias) {
|
|
417
|
+
const brief = `CASE WHEN json_valid(${alias}.brief) THEN ${alias}.brief ELSE '{}' END`;
|
|
418
|
+
return `trim(coalesce(${alias}.prompt, '') || ' ' ||
|
|
419
|
+
coalesce((SELECT group_concat(je.value, ' ') FROM json_each(${brief}, '$.templateFields') AS je), '') || ' ' ||
|
|
420
|
+
coalesce((SELECT group_concat(coalesce(json_extract(je.value, '$.name'), '') || ' ' || coalesce(json_extract(je.value, '$.hex'), ''), ' ')
|
|
421
|
+
FROM json_each(${brief}, '$.tokens') AS je WHERE json_extract(je.value, '$.t') = 'color'), ''))`;
|
|
422
|
+
}
|
|
423
|
+
function tokenRowsSql(alias) {
|
|
424
|
+
const brief = `CASE WHEN json_valid(${alias}.brief) THEN ${alias}.brief ELSE '{}' END`;
|
|
425
|
+
return `SELECT ${alias}.id, json_extract(je.value, '$.t'), json_extract(je.value, '$.id')
|
|
426
|
+
FROM json_each(${brief}, '$.tokens') AS je
|
|
427
|
+
WHERE json_extract(je.value, '$.t') IN ('product', 'character', 'template')
|
|
428
|
+
AND json_extract(je.value, '$.id') IS NOT NULL
|
|
429
|
+
UNION ALL
|
|
430
|
+
SELECT ${alias}.id, 'template', json_extract(${brief}, '$.templateId')
|
|
431
|
+
WHERE json_extract(${brief}, '$.templateId') IS NOT NULL`;
|
|
432
|
+
}
|
|
433
|
+
function tokenRowsFromNodesSql() {
|
|
434
|
+
const brief = "CASE WHEN json_valid(n.brief) THEN n.brief ELSE '{}' END";
|
|
435
|
+
return `SELECT n.id, json_extract(je.value, '$.t'), json_extract(je.value, '$.id')
|
|
436
|
+
FROM nodes n, json_each(${brief}, '$.tokens') AS je
|
|
437
|
+
WHERE json_extract(je.value, '$.t') IN ('product', 'character', 'template')
|
|
438
|
+
AND json_extract(je.value, '$.id') IS NOT NULL
|
|
439
|
+
UNION ALL
|
|
440
|
+
SELECT n.id, 'template', json_extract(${brief}, '$.templateId')
|
|
441
|
+
FROM nodes n
|
|
442
|
+
WHERE json_extract(${brief}, '$.templateId') IS NOT NULL`;
|
|
443
|
+
}
|
|
444
|
+
var SEARCH_INDEX_VERSION = "v1";
|
|
445
|
+
function ensureSearch(db) {
|
|
446
|
+
db.exec(`
|
|
447
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(text, tokenize='trigram case_sensitive 0 remove_diacritics 1');
|
|
448
|
+
CREATE TABLE IF NOT EXISTS node_tokens (
|
|
449
|
+
node_id TEXT NOT NULL,
|
|
450
|
+
kind TEXT NOT NULL,
|
|
451
|
+
token_id TEXT NOT NULL,
|
|
452
|
+
PRIMARY KEY (node_id, kind, token_id)
|
|
453
|
+
) WITHOUT ROWID;
|
|
454
|
+
CREATE INDEX IF NOT EXISTS idx_node_tokens_token ON node_tokens(token_id, node_id);
|
|
455
|
+
CREATE TRIGGER IF NOT EXISTS nodes_search_ai AFTER INSERT ON nodes BEGIN
|
|
456
|
+
INSERT INTO nodes_fts(rowid, text) VALUES (new.rowid, ${searchTextSql("new")});
|
|
457
|
+
INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsSql("new")};
|
|
458
|
+
END;
|
|
459
|
+
CREATE TRIGGER IF NOT EXISTS nodes_search_au AFTER UPDATE OF prompt, brief ON nodes BEGIN
|
|
460
|
+
DELETE FROM nodes_fts WHERE rowid = old.rowid;
|
|
461
|
+
DELETE FROM node_tokens WHERE node_id = old.id;
|
|
462
|
+
INSERT INTO nodes_fts(rowid, text) VALUES (new.rowid, ${searchTextSql("new")});
|
|
463
|
+
INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsSql("new")};
|
|
464
|
+
END;
|
|
465
|
+
CREATE TRIGGER IF NOT EXISTS nodes_search_ad AFTER DELETE ON nodes BEGIN
|
|
466
|
+
DELETE FROM nodes_fts WHERE rowid = old.rowid;
|
|
467
|
+
DELETE FROM node_tokens WHERE node_id = old.id;
|
|
468
|
+
END;
|
|
469
|
+
`);
|
|
470
|
+
const marker = db.prepare("SELECT value FROM settings WHERE key='search_index'").get()?.value;
|
|
471
|
+
const bounds = db.prepare("SELECT min(rowid) AS lo, max(rowid) AS hi, count(*) AS c FROM nodes").get();
|
|
472
|
+
const indexed = (rowid) => rowid !== null && !!db.prepare("SELECT 1 FROM nodes_fts WHERE rowid = ?").get(rowid);
|
|
473
|
+
const whole = bounds.c === 0 || indexed(bounds.lo) && indexed(bounds.hi);
|
|
474
|
+
if (marker === SEARCH_INDEX_VERSION && whole) return;
|
|
475
|
+
db.transaction(() => {
|
|
476
|
+
db.exec("DELETE FROM nodes_fts");
|
|
477
|
+
db.exec("DELETE FROM node_tokens");
|
|
478
|
+
db.exec(`INSERT INTO nodes_fts(rowid, text) SELECT n.rowid, ${searchTextSql("n")} FROM nodes n`);
|
|
479
|
+
db.exec(`INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsFromNodesSql()}`);
|
|
480
|
+
db.prepare(
|
|
481
|
+
"INSERT INTO settings (key, value) VALUES ('search_index', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
482
|
+
).run(SEARCH_INDEX_VERSION);
|
|
483
|
+
})();
|
|
392
484
|
}
|
|
393
485
|
var SCHEMA_VERSION = 2;
|
|
394
486
|
var SchemaTooNewError = class extends Error {
|
|
@@ -461,9 +553,11 @@ function openDb(homeDir) {
|
|
|
461
553
|
db.exec("ALTER TABLE catalog_images ADD COLUMN excluded INTEGER NOT NULL DEFAULT 0");
|
|
462
554
|
}
|
|
463
555
|
widenNodeStatusCheck(db);
|
|
556
|
+
ensureIndexes(db);
|
|
464
557
|
backfillSlugs(db);
|
|
465
558
|
collapseProjects(db);
|
|
466
559
|
splitMultiImageNodes(db);
|
|
560
|
+
ensureSearch(db);
|
|
467
561
|
db.prepare(
|
|
468
562
|
"UPDATE nodes SET status='error', error='interrupted: server restarted mid-generation' WHERE status='running'"
|
|
469
563
|
).run();
|
|
@@ -544,6 +638,31 @@ function createLedger(db) {
|
|
|
544
638
|
}
|
|
545
639
|
};
|
|
546
640
|
}
|
|
641
|
+
|
|
642
|
+
// ../core/src/searchRules.ts
|
|
643
|
+
function fold(s) {
|
|
644
|
+
return s.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").replace(/[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g, "").toLowerCase();
|
|
645
|
+
}
|
|
646
|
+
var STEM_MIN = 4;
|
|
647
|
+
var TRIGRAM_MIN = 3;
|
|
648
|
+
function searchTerms(q) {
|
|
649
|
+
return fold(q).trim().split(/\s+/).filter(Boolean).map((text) => ({
|
|
650
|
+
text,
|
|
651
|
+
stem: text.length >= STEM_MIN && text.endsWith("s") ? text.slice(0, -1) : null
|
|
652
|
+
}));
|
|
653
|
+
}
|
|
654
|
+
function termMatches(haystack, term) {
|
|
655
|
+
const h = fold(haystack);
|
|
656
|
+
return h.includes(term.text) || term.stem !== null && h.includes(term.stem);
|
|
657
|
+
}
|
|
658
|
+
var quote = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
659
|
+
function ftsMatch(term) {
|
|
660
|
+
if (term.text.length < TRIGRAM_MIN) return null;
|
|
661
|
+
return term.stem ? `(${quote(term.text)} OR ${quote(term.stem)})` : quote(term.text);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ../core/src/store.ts
|
|
665
|
+
var PROMPT_HEAD_CHARS = 240;
|
|
547
666
|
function uniqueSlug(db, name, id) {
|
|
548
667
|
const stmt = db.prepare("SELECT 1 FROM brands WHERE slug=? AND id IS NOT ?");
|
|
549
668
|
return firstFree(slugifyWithId(name, id), (c) => RESERVED_SLUGS.has(c) || !!stmt.get(c, id));
|
|
@@ -567,13 +686,20 @@ function rowToSet(r) {
|
|
|
567
686
|
updatedAt: r.updated_at
|
|
568
687
|
};
|
|
569
688
|
}
|
|
570
|
-
|
|
689
|
+
var headOf = (prompt) => Array.from(String(prompt ?? "")).slice(0, PROMPT_HEAD_CHARS).join("");
|
|
690
|
+
var CHILD_COUNT_SQL = "(CASE WHEN n.kind = 'root' THEN 0 ELSE (SELECT count(*) FROM nodes c WHERE c.parent_id = n.id AND c.archived = 0) END)";
|
|
691
|
+
var LINEAGE_SIBLINGS_RADIUS = 25;
|
|
692
|
+
var LINEAGE_CHILDREN_MAX = 60;
|
|
693
|
+
var FEED_COLS = `n.id, n.project_id, n.parent_id, n.kind, substr(n.prompt, 1, ${PROMPT_HEAD_CHARS}) AS prompt_head,
|
|
694
|
+
n.engine_id, n.status, n.images, n.cost_usd, n.duration_ms, n.kept, n.error, n.created_at, n.brief, n.archived,
|
|
695
|
+
n.batch_id, n.batch_index, ${CHILD_COUNT_SQL} AS child_count`;
|
|
696
|
+
function rowToFeedNode(r) {
|
|
571
697
|
return {
|
|
572
698
|
id: r.id,
|
|
573
699
|
projectId: r.project_id,
|
|
574
700
|
parentId: r.parent_id,
|
|
575
701
|
kind: r.kind,
|
|
576
|
-
|
|
702
|
+
promptHead: r.prompt_head ?? headOf(r.prompt),
|
|
577
703
|
engineId: r.engine_id,
|
|
578
704
|
status: r.status,
|
|
579
705
|
images: JSON.parse(r.images),
|
|
@@ -582,11 +708,18 @@ function rowToNode(r) {
|
|
|
582
708
|
kept: !!r.kept,
|
|
583
709
|
error: r.error,
|
|
584
710
|
createdAt: r.created_at,
|
|
585
|
-
overlays: JSON.parse(r.overlays ?? "{}"),
|
|
586
711
|
brief: r.brief ? JSON.parse(r.brief) : null,
|
|
587
712
|
archived: !!r.archived,
|
|
588
713
|
batchId: r.batch_id ?? null,
|
|
589
|
-
batchIndex: r.batch_index ?? 0
|
|
714
|
+
batchIndex: r.batch_index ?? 0,
|
|
715
|
+
childCount: r.child_count ?? 0
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function rowToNode(r) {
|
|
719
|
+
return {
|
|
720
|
+
...rowToFeedNode(r),
|
|
721
|
+
prompt: r.prompt,
|
|
722
|
+
overlays: JSON.parse(r.overlays ?? "{}")
|
|
590
723
|
};
|
|
591
724
|
}
|
|
592
725
|
var lastBatchStamp = 0;
|
|
@@ -599,6 +732,92 @@ function batchStamps(count) {
|
|
|
599
732
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
|
|
600
733
|
});
|
|
601
734
|
}
|
|
735
|
+
var encodeCursor = (k) => Buffer.from(JSON.stringify(k)).toString("base64url");
|
|
736
|
+
function decodeCursor(cursor) {
|
|
737
|
+
try {
|
|
738
|
+
const k = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
739
|
+
if (typeof k?.c === "string" && typeof k?.i === "string") return k;
|
|
740
|
+
} catch {
|
|
741
|
+
}
|
|
742
|
+
throw new Error("invalid cursor");
|
|
743
|
+
}
|
|
744
|
+
function filterSql(f, params, withLens) {
|
|
745
|
+
const where = ["n.kind != 'root'"];
|
|
746
|
+
if (withLens) {
|
|
747
|
+
if (f.lens === "archived") where.push("n.archived = 1");
|
|
748
|
+
else if (f.lens === "keepers") where.push("n.archived = 0 AND n.kept = 1");
|
|
749
|
+
else where.push("n.archived = 0");
|
|
750
|
+
}
|
|
751
|
+
if (f.lineage) {
|
|
752
|
+
params.lineage = f.lineage;
|
|
753
|
+
where.push(
|
|
754
|
+
"n.id IN (WITH RECURSIVE d(id) AS (SELECT @lineage UNION ALL SELECT c.id FROM nodes c JOIN d ON c.parent_id = d.id) SELECT id FROM d)"
|
|
755
|
+
);
|
|
756
|
+
} else if (f.set) {
|
|
757
|
+
params.set = f.set;
|
|
758
|
+
where.push("n.id IN (SELECT node_id FROM set_nodes WHERE set_id = @set)");
|
|
759
|
+
} else if (f.ungrouped) {
|
|
760
|
+
where.push("NOT EXISTS (SELECT 1 FROM set_nodes sn WHERE sn.node_id = n.id)");
|
|
761
|
+
}
|
|
762
|
+
if (f.tokens?.length) {
|
|
763
|
+
const names = f.tokens.map((t, i) => {
|
|
764
|
+
params[`tok${i}`] = t;
|
|
765
|
+
return `@tok${i}`;
|
|
766
|
+
});
|
|
767
|
+
where.push(`n.id IN (SELECT node_id FROM node_tokens WHERE token_id IN (${names.join(", ")}))`);
|
|
768
|
+
}
|
|
769
|
+
(f.terms ?? []).forEach((term, i) => {
|
|
770
|
+
const any = [];
|
|
771
|
+
const match = ftsMatch(term);
|
|
772
|
+
if (match) {
|
|
773
|
+
params[`m${i}`] = match;
|
|
774
|
+
any.push(`n.rowid IN (SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH @m${i})`);
|
|
775
|
+
}
|
|
776
|
+
if (term.tokenIds.length) {
|
|
777
|
+
const names = term.tokenIds.map((t, j) => {
|
|
778
|
+
params[`t${i}_${j}`] = t;
|
|
779
|
+
return `@t${i}_${j}`;
|
|
780
|
+
});
|
|
781
|
+
any.push(`n.id IN (SELECT node_id FROM node_tokens WHERE token_id IN (${names.join(", ")}))`);
|
|
782
|
+
}
|
|
783
|
+
if (term.engineIds.length) {
|
|
784
|
+
const names = term.engineIds.map((e, j) => {
|
|
785
|
+
params[`e${i}_${j}`] = e;
|
|
786
|
+
return `@e${i}_${j}`;
|
|
787
|
+
});
|
|
788
|
+
any.push(`n.engine_id IN (${names.join(", ")})`);
|
|
789
|
+
}
|
|
790
|
+
if (any.length) where.push(`(${any.join(" OR ")})`);
|
|
791
|
+
});
|
|
792
|
+
return where;
|
|
793
|
+
}
|
|
794
|
+
function sortSql(sort, cursor, params) {
|
|
795
|
+
if (cursor) {
|
|
796
|
+
params.c = cursor.c;
|
|
797
|
+
params.i = cursor.i;
|
|
798
|
+
params.v = cursor.v ?? 0;
|
|
799
|
+
}
|
|
800
|
+
const newest = "(n.created_at < @c OR (n.created_at = @c AND n.id < @i))";
|
|
801
|
+
const oldest = "(n.created_at > @c OR (n.created_at = @c AND n.id > @i))";
|
|
802
|
+
switch (sort) {
|
|
803
|
+
case "oldest":
|
|
804
|
+
return { order: "n.created_at ASC, n.id ASC", after: cursor ? oldest : null };
|
|
805
|
+
case "cost":
|
|
806
|
+
return {
|
|
807
|
+
order: "n.cost_usd DESC, n.created_at DESC, n.id DESC",
|
|
808
|
+
after: cursor ? `(n.cost_usd < @v OR (n.cost_usd = @v AND ${newest}))` : null
|
|
809
|
+
};
|
|
810
|
+
case "keepers":
|
|
811
|
+
return {
|
|
812
|
+
order: "n.kept DESC, n.created_at DESC, n.id DESC",
|
|
813
|
+
after: cursor ? `(n.kept < @v OR (n.kept = @v AND ${newest}))` : null
|
|
814
|
+
};
|
|
815
|
+
default:
|
|
816
|
+
return { order: "n.created_at DESC, n.id DESC", after: cursor ? newest : null };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
var keysetOf = (n, sort) => sort === "cost" ? { c: n.createdAt, i: n.id, v: n.costUsd } : sort === "keepers" ? { c: n.createdAt, i: n.id, v: n.kept ? 1 : 0 } : { c: n.createdAt, i: n.id };
|
|
820
|
+
var FEED_PAGE_MAX = 200;
|
|
602
821
|
function createStore(db) {
|
|
603
822
|
return {
|
|
604
823
|
// brands
|
|
@@ -750,6 +969,10 @@ function createStore(db) {
|
|
|
750
969
|
}
|
|
751
970
|
return out;
|
|
752
971
|
},
|
|
972
|
+
/** One set's members, in the order they were filed. */
|
|
973
|
+
membersOf(setId) {
|
|
974
|
+
return db.prepare("SELECT node_id FROM set_nodes WHERE set_id=? ORDER BY added_at, node_id").all(setId).map((r) => r.node_id);
|
|
975
|
+
},
|
|
753
976
|
// nodes / version tree
|
|
754
977
|
addNode(input) {
|
|
755
978
|
if (input.parentId) {
|
|
@@ -823,13 +1046,143 @@ function createStore(db) {
|
|
|
823
1046
|
db.prepare("UPDATE nodes SET status='cancelled' WHERE id=?").run(id);
|
|
824
1047
|
},
|
|
825
1048
|
getNode(id) {
|
|
826
|
-
const r = db.prepare(
|
|
1049
|
+
const r = db.prepare(`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.id=?`).get(id);
|
|
827
1050
|
return r ? rowToNode(r) : null;
|
|
828
1051
|
},
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1052
|
+
/** The list shape of one shot: what a keep or an archive answers with. */
|
|
1053
|
+
getFeedNode(id) {
|
|
1054
|
+
const r = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.id=?`).get(id);
|
|
1055
|
+
return r ? rowToFeedNode(r) : null;
|
|
1056
|
+
},
|
|
1057
|
+
/** The project's root, by index, rather than the whole tree read to find it. */
|
|
1058
|
+
rootFor(projectId) {
|
|
1059
|
+
const rows = db.prepare(`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.project_id=? AND n.kind='root'`).all(projectId);
|
|
1060
|
+
rows.sort(
|
|
1061
|
+
(a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id))
|
|
832
1062
|
);
|
|
1063
|
+
return rows.length ? rowToNode(rows[0]) : null;
|
|
1064
|
+
},
|
|
1065
|
+
treeFor(projectId) {
|
|
1066
|
+
return db.prepare(
|
|
1067
|
+
`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.project_id=? ORDER BY n.created_at, n.id`
|
|
1068
|
+
).all(projectId).map(rowToNode);
|
|
1069
|
+
},
|
|
1070
|
+
/**
|
|
1071
|
+
* One page of a project's shots for a place, lens, search and sort.
|
|
1072
|
+
*
|
|
1073
|
+
* Keyset paging on the sort's own columns, never OFFSET: the cost of page
|
|
1074
|
+
* forty is the cost of page one, and a shot landing between two pages
|
|
1075
|
+
* shifts nothing already read. Every clause is served by an index or by
|
|
1076
|
+
* the search index; the whole workspace is never read.
|
|
1077
|
+
*/
|
|
1078
|
+
feedPage(projectId, q) {
|
|
1079
|
+
const limit = Math.max(1, Math.min(FEED_PAGE_MAX, Math.floor(q.limit ?? 60)));
|
|
1080
|
+
const sort = q.sort ?? "newest";
|
|
1081
|
+
const params = { project: projectId, limit: limit + 1 };
|
|
1082
|
+
const where = ["n.project_id = @project", ...filterSql(q, params, true)];
|
|
1083
|
+
const { order, after } = sortSql(sort, q.cursor ? decodeCursor(q.cursor) : null, params);
|
|
1084
|
+
if (after) where.push(after);
|
|
1085
|
+
const rows = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT @limit`).all(params);
|
|
1086
|
+
const items = rows.slice(0, limit).map(rowToFeedNode);
|
|
1087
|
+
const more = rows.length > limit;
|
|
1088
|
+
return { items, next: more && items.length ? encodeCursor(keysetOf(items[items.length - 1], sort)) : null };
|
|
1089
|
+
},
|
|
1090
|
+
/**
|
|
1091
|
+
* What each lens would show from a place and search, plus the two
|
|
1092
|
+
* unscoped totals. The scoped sums and the total read the state index
|
|
1093
|
+
* alone (project, kind, archived, kept: nothing that needs the row), and
|
|
1094
|
+
* the grouped count walks the brand's memberships rather than asking
|
|
1095
|
+
* every shot whether it is in a set.
|
|
1096
|
+
*/
|
|
1097
|
+
feedCounts(projectId, f) {
|
|
1098
|
+
const params = { project: projectId };
|
|
1099
|
+
const where = ["n.project_id = @project", ...filterSql({ ...f, lens: void 0 }, params, false)];
|
|
1100
|
+
const scoped = db.prepare(
|
|
1101
|
+
`SELECT coalesce(sum(n.archived = 0), 0) AS live, coalesce(sum(n.archived = 0 AND n.kept = 1), 0) AS kept,
|
|
1102
|
+
coalesce(sum(n.archived = 1), 0) AS archived
|
|
1103
|
+
FROM nodes n WHERE ${where.join(" AND ")}`
|
|
1104
|
+
).get(params);
|
|
1105
|
+
const totals = db.prepare(
|
|
1106
|
+
`SELECT count(*) AS total, coalesce(sum(n.archived = 0), 0) AS live
|
|
1107
|
+
FROM nodes n WHERE n.project_id = ? AND n.kind != 'root'`
|
|
1108
|
+
).get(projectId);
|
|
1109
|
+
const grouped = db.prepare(
|
|
1110
|
+
`SELECT count(DISTINCT sn.node_id) AS c
|
|
1111
|
+
FROM sets s
|
|
1112
|
+
CROSS JOIN set_nodes sn ON sn.set_id = s.id
|
|
1113
|
+
CROSS JOIN nodes n ON n.id = sn.node_id
|
|
1114
|
+
WHERE s.brand_id = (SELECT brand_id FROM projects WHERE id = ?)
|
|
1115
|
+
AND n.project_id = ? AND n.archived = 0`
|
|
1116
|
+
).get(projectId, projectId).c;
|
|
1117
|
+
return {
|
|
1118
|
+
total: totals.total,
|
|
1119
|
+
all: scoped.live,
|
|
1120
|
+
keepers: scoped.kept,
|
|
1121
|
+
archived: scoped.archived,
|
|
1122
|
+
ungrouped: totals.live - grouped
|
|
1123
|
+
};
|
|
1124
|
+
},
|
|
1125
|
+
/**
|
|
1126
|
+
* Where one shot sits in its tree, from the parent index: its ancestors
|
|
1127
|
+
* up to (never including) the root, the siblings around it, and what
|
|
1128
|
+
* hangs off it. Archived versions stay in the strip, as they did when the
|
|
1129
|
+
* overlay walked the whole workspace.
|
|
1130
|
+
*
|
|
1131
|
+
* The siblings are a window: this shot, and up to twenty-five on either
|
|
1132
|
+
* side in filing order. A top-level shot's siblings are every top-level
|
|
1133
|
+
* shot in the brand, and the whole list was eighteen megabytes on a
|
|
1134
|
+
* brand of twenty thousand; the overlay only ever steps to a neighbour,
|
|
1135
|
+
* and each step asks again, so the window re-centres as it goes.
|
|
1136
|
+
*/
|
|
1137
|
+
lineageOf(id) {
|
|
1138
|
+
const node = this.getFeedNode(id);
|
|
1139
|
+
if (!node) return null;
|
|
1140
|
+
if (node.kind === "root") return { ancestors: [], siblings: [], children: [] };
|
|
1141
|
+
const ancestors = [];
|
|
1142
|
+
let cur = node.parentId ? this.getFeedNode(node.parentId) : null;
|
|
1143
|
+
for (let hops = 0; cur && cur.kind !== "root" && hops < 64; hops++) {
|
|
1144
|
+
ancestors.unshift(cur);
|
|
1145
|
+
cur = cur.parentId ? this.getFeedNode(cur.parentId) : null;
|
|
1146
|
+
}
|
|
1147
|
+
const before = db.prepare(
|
|
1148
|
+
`SELECT count(*) AS c FROM nodes n
|
|
1149
|
+
WHERE n.parent_id IS ? AND (n.created_at < ? OR (n.created_at = ? AND n.id < ?))`
|
|
1150
|
+
).get(node.parentId, node.createdAt, node.createdAt, node.id).c;
|
|
1151
|
+
const skip = Math.max(0, before - LINEAGE_SIBLINGS_RADIUS);
|
|
1152
|
+
const take = before - skip + 1 + LINEAGE_SIBLINGS_RADIUS;
|
|
1153
|
+
const siblings = db.prepare(
|
|
1154
|
+
`SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id IS ?
|
|
1155
|
+
ORDER BY n.created_at, n.id LIMIT ? OFFSET ?`
|
|
1156
|
+
).all(node.parentId, take, skip).map(rowToFeedNode);
|
|
1157
|
+
const children = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id = ? ORDER BY n.created_at, n.id LIMIT ?`).all(node.id, LINEAGE_CHILDREN_MAX).map(rowToFeedNode);
|
|
1158
|
+
return { ancestors, siblings, children };
|
|
1159
|
+
},
|
|
1160
|
+
/** The newest finished shots, newest first, for the rail and the attach panel. */
|
|
1161
|
+
recentShots(projectId, limit = 48) {
|
|
1162
|
+
return db.prepare(
|
|
1163
|
+
`SELECT ${FEED_COLS} FROM nodes n
|
|
1164
|
+
WHERE n.project_id = ? AND n.kind != 'root' AND n.status = 'done' AND n.images != '[]'
|
|
1165
|
+
ORDER BY n.created_at DESC, n.id DESC LIMIT ?`
|
|
1166
|
+
).all(projectId, Math.max(1, Math.min(FEED_PAGE_MAX, limit))).map(rowToFeedNode);
|
|
1167
|
+
},
|
|
1168
|
+
/** A year of runs by day, counted where the rows are. */
|
|
1169
|
+
usageByDay(brandId) {
|
|
1170
|
+
return db.prepare(
|
|
1171
|
+
`SELECT substr(n.created_at, 1, 10) AS day,
|
|
1172
|
+
coalesce(sum(n.kind = 'generation'), 0) AS generations,
|
|
1173
|
+
coalesce(sum(n.kind = 'edit'), 0) AS edits
|
|
1174
|
+
FROM nodes n JOIN projects p ON p.id = n.project_id
|
|
1175
|
+
WHERE p.brand_id = ? AND n.kind != 'root' AND n.created_at >= date('now', '-400 days')
|
|
1176
|
+
GROUP BY day ORDER BY day`
|
|
1177
|
+
).all(brandId).map((r) => ({ day: String(r.day), generations: Number(r.generations), edits: Number(r.edits) }));
|
|
1178
|
+
},
|
|
1179
|
+
/** The compiled prompt of the shot that produced an image, for a reference described in words. */
|
|
1180
|
+
promptForImage(brandId, hash) {
|
|
1181
|
+
const r = db.prepare(
|
|
1182
|
+
`SELECT n.prompt FROM nodes n JOIN projects p ON p.id = n.project_id
|
|
1183
|
+
WHERE p.brand_id = ? AND n.images LIKE ? ORDER BY n.created_at, n.id LIMIT 1`
|
|
1184
|
+
).get(brandId, `%"${hash}"%`);
|
|
1185
|
+
return r?.prompt ?? null;
|
|
833
1186
|
},
|
|
834
1187
|
/**
|
|
835
1188
|
* Every piece of work a brand has in flight, plus whatever finished lately,
|
|
@@ -842,21 +1195,26 @@ function createStore(db) {
|
|
|
842
1195
|
* ISO string is a silent, timezone-shaped mis-filter.
|
|
843
1196
|
*/
|
|
844
1197
|
recentActivity(brandId, limit = 60) {
|
|
845
|
-
const
|
|
846
|
-
`SELECT n.*, (
|
|
1198
|
+
const cols = `${FEED_COLS}, (
|
|
847
1199
|
SELECT group_concat(s.name, char(31))
|
|
848
1200
|
FROM set_nodes sn JOIN sets s ON s.id = sn.set_id
|
|
849
1201
|
WHERE sn.node_id = n.id
|
|
850
|
-
) AS set_names
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1202
|
+
) AS set_names`;
|
|
1203
|
+
const inBrand = "n.project_id IN (SELECT id FROM projects WHERE brand_id = @brand)";
|
|
1204
|
+
const rows = db.prepare(
|
|
1205
|
+
`SELECT * FROM (
|
|
1206
|
+
SELECT ${cols} FROM nodes n
|
|
1207
|
+
WHERE ${inBrand} AND n.kind != 'root' AND n.status = 'running'
|
|
1208
|
+
UNION ALL
|
|
1209
|
+
SELECT ${cols} FROM nodes n
|
|
1210
|
+
WHERE ${inBrand} AND n.kind != 'root' AND n.status != 'running'
|
|
1211
|
+
AND n.created_at >= datetime('now', '-2 days')
|
|
1212
|
+
)
|
|
1213
|
+
ORDER BY created_at DESC, id DESC
|
|
1214
|
+
LIMIT @limit`
|
|
1215
|
+
).all({ brand: brandId, limit });
|
|
858
1216
|
return rows.map((r) => ({
|
|
859
|
-
...
|
|
1217
|
+
...rowToFeedNode(r),
|
|
860
1218
|
setNames: r.set_names ? String(r.set_names).split(SET_NAME_SEP) : []
|
|
861
1219
|
}));
|
|
862
1220
|
},
|
|
@@ -979,6 +1337,56 @@ var productById = (db, id) => {
|
|
|
979
1337
|
var productsFor = (db, brandId) => db.prepare(
|
|
980
1338
|
"SELECT * FROM catalog_products WHERE brand_id=? AND status!='unavailable' ORDER BY title COLLATE NOCASE"
|
|
981
1339
|
).all(brandId).map(rowProduct);
|
|
1340
|
+
var rowVariant = (r) => ({
|
|
1341
|
+
id: r.id,
|
|
1342
|
+
productId: r.product_id,
|
|
1343
|
+
externalKey: r.external_key,
|
|
1344
|
+
title: r.title,
|
|
1345
|
+
sku: r.sku,
|
|
1346
|
+
price: r.price,
|
|
1347
|
+
compareAtPrice: r.compare_at_price,
|
|
1348
|
+
currency: r.currency,
|
|
1349
|
+
available: r.available == null ? null : !!r.available,
|
|
1350
|
+
options: JSON.parse(r.options || "{}")
|
|
1351
|
+
});
|
|
1352
|
+
var rowImage = (r) => ({
|
|
1353
|
+
id: r.id,
|
|
1354
|
+
productId: r.product_id,
|
|
1355
|
+
sourceUrl: r.source_url,
|
|
1356
|
+
assetRef: r.asset_ref,
|
|
1357
|
+
width: r.width,
|
|
1358
|
+
height: r.height,
|
|
1359
|
+
position: r.position,
|
|
1360
|
+
alt: r.alt,
|
|
1361
|
+
angle: r.angle ?? null,
|
|
1362
|
+
excluded: !!r.excluded
|
|
1363
|
+
});
|
|
1364
|
+
var variantsForBrand = (db, brandId) => {
|
|
1365
|
+
const out = /* @__PURE__ */ new Map();
|
|
1366
|
+
const rows = db.prepare(
|
|
1367
|
+
`SELECT v.* FROM catalog_variants v JOIN catalog_products p ON p.id = v.product_id
|
|
1368
|
+
WHERE p.brand_id=? AND p.status!='unavailable' ORDER BY v.product_id, v.rowid`
|
|
1369
|
+
).all(brandId);
|
|
1370
|
+
for (const r of rows) {
|
|
1371
|
+
const list2 = out.get(r.product_id) ?? [];
|
|
1372
|
+
list2.push(rowVariant(r));
|
|
1373
|
+
out.set(r.product_id, list2);
|
|
1374
|
+
}
|
|
1375
|
+
return out;
|
|
1376
|
+
};
|
|
1377
|
+
var imagesForBrand = (db, brandId) => {
|
|
1378
|
+
const out = /* @__PURE__ */ new Map();
|
|
1379
|
+
const rows = db.prepare(
|
|
1380
|
+
`SELECT i.* FROM catalog_images i JOIN catalog_products p ON p.id = i.product_id
|
|
1381
|
+
WHERE p.brand_id=? AND p.status!='unavailable' ORDER BY i.product_id, i.position`
|
|
1382
|
+
).all(brandId);
|
|
1383
|
+
for (const r of rows) {
|
|
1384
|
+
const list2 = out.get(r.product_id) ?? [];
|
|
1385
|
+
list2.push(rowImage(r));
|
|
1386
|
+
out.set(r.product_id, list2);
|
|
1387
|
+
}
|
|
1388
|
+
return out;
|
|
1389
|
+
};
|
|
982
1390
|
var variantsFor = (db, productId) => db.prepare("SELECT * FROM catalog_variants WHERE product_id=?").all(productId).map((r) => ({
|
|
983
1391
|
id: r.id,
|
|
984
1392
|
productId: r.product_id,
|
|
@@ -1388,8 +1796,10 @@ function libraryMethods(db) {
|
|
|
1388
1796
|
alt: s.alt ?? s.angle ?? null
|
|
1389
1797
|
}))
|
|
1390
1798
|
}));
|
|
1799
|
+
const imagesBy = imagesForBrand(db, brandId);
|
|
1800
|
+
const variantsBy = variantsForBrand(db, brandId);
|
|
1391
1801
|
const catalog = productsFor(db, brandId).map((p) => {
|
|
1392
|
-
const images =
|
|
1802
|
+
const images = imagesBy.get(p.id) ?? [];
|
|
1393
1803
|
const shot = (i) => ({
|
|
1394
1804
|
file: i.assetRef,
|
|
1395
1805
|
locked: true,
|
|
@@ -1421,7 +1831,7 @@ function libraryMethods(db) {
|
|
|
1421
1831
|
status: p.status,
|
|
1422
1832
|
shots,
|
|
1423
1833
|
hiddenShots,
|
|
1424
|
-
variants:
|
|
1834
|
+
variants: variantsBy.get(p.id) ?? []
|
|
1425
1835
|
};
|
|
1426
1836
|
});
|
|
1427
1837
|
return [...manual, ...catalog];
|
|
@@ -3098,7 +3508,7 @@ function createDemoEngine(saveImage, opts = {}) {
|
|
|
3098
3508
|
<text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
|
|
3099
3509
|
<text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
|
|
3100
3510
|
</svg>`;
|
|
3101
|
-
return
|
|
3511
|
+
return sharp21(Buffer.from(svg)).png().toBuffer();
|
|
3102
3512
|
}
|
|
3103
3513
|
return {
|
|
3104
3514
|
capabilities() {
|
|
@@ -3289,7 +3699,7 @@ var resolvedRefs = /* @__PURE__ */ new Map();
|
|
|
3289
3699
|
async function refHash(core, path) {
|
|
3290
3700
|
const hit = resolvedRefs.get(path);
|
|
3291
3701
|
if (hit && core.images.has(hit)) return hit;
|
|
3292
|
-
const hash = core.images.save(await
|
|
3702
|
+
const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
|
|
3293
3703
|
resolvedRefs.set(path, hash);
|
|
3294
3704
|
return hash;
|
|
3295
3705
|
}
|
|
@@ -3389,14 +3799,21 @@ function demoProductFacetsOf(demoProducts) {
|
|
|
3389
3799
|
function demoProductRefPath(templatesRoot, id, angle) {
|
|
3390
3800
|
return contentFile(templatesRoot, "previews", "demo-products", id, `${angle}.jpg`);
|
|
3391
3801
|
}
|
|
3802
|
+
var resolvedRefs2 = /* @__PURE__ */ new Map();
|
|
3803
|
+
async function refHash2(core, path) {
|
|
3804
|
+
const hit = resolvedRefs2.get(path);
|
|
3805
|
+
if (hit && core.images.has(hit)) return hit;
|
|
3806
|
+
const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
|
|
3807
|
+
resolvedRefs2.set(path, hash);
|
|
3808
|
+
return hash;
|
|
3809
|
+
}
|
|
3392
3810
|
async function resolveDemoProductImages(core, templatesRoot, product) {
|
|
3393
3811
|
const angles = PRODUCT_ANGLES_BY_CATEGORY[product.category] ?? PRODUCT_ANGLES_BY_CATEGORY.other;
|
|
3394
3812
|
const shots = [];
|
|
3395
3813
|
for (const angle of angles) {
|
|
3396
3814
|
const path = demoProductRefPath(templatesRoot, product.id, angle);
|
|
3397
3815
|
if (!existsSync(path)) continue;
|
|
3398
|
-
const
|
|
3399
|
-
const hash = core.images.save(png);
|
|
3816
|
+
const hash = await refHash2(core, path);
|
|
3400
3817
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
3401
3818
|
}
|
|
3402
3819
|
if (!shots.length) return null;
|
|
@@ -3546,7 +3963,7 @@ var assetHash = (ref) => {
|
|
|
3546
3963
|
};
|
|
3547
3964
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
3548
3965
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
3549
|
-
var toPng = (buf) =>
|
|
3966
|
+
var toPng = (buf) => sharp21(buf).rotate().png().toBuffer();
|
|
3550
3967
|
var COST_PROBE = {
|
|
3551
3968
|
prompt: "",
|
|
3552
3969
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -3559,11 +3976,11 @@ var MARK_MIN_EDGE = 1024;
|
|
|
3559
3976
|
var MARK_TINY_EDGE = 256;
|
|
3560
3977
|
var MARK_WARN_EDGE = 512;
|
|
3561
3978
|
var toMarkPng = async (buf) => {
|
|
3562
|
-
const out = await
|
|
3563
|
-
const meta = await
|
|
3979
|
+
const out = await sharp21(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3980
|
+
const meta = await sharp21(out).metadata();
|
|
3564
3981
|
const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
|
|
3565
3982
|
if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
|
|
3566
|
-
return
|
|
3983
|
+
return sharp21(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
|
|
3567
3984
|
}
|
|
3568
3985
|
return out;
|
|
3569
3986
|
};
|
|
@@ -3574,9 +3991,9 @@ async function capReferenceEdge(core, path, maxEdge) {
|
|
|
3574
3991
|
if (hit) return hit;
|
|
3575
3992
|
let out = path;
|
|
3576
3993
|
try {
|
|
3577
|
-
const meta = await
|
|
3994
|
+
const meta = await sharp21(path).metadata();
|
|
3578
3995
|
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
3579
|
-
const buf = await
|
|
3996
|
+
const buf = await sharp21(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3580
3997
|
out = core.images.pathFor(core.images.save(buf));
|
|
3581
3998
|
}
|
|
3582
3999
|
} catch {
|
|
@@ -4221,18 +4638,9 @@ function shotWords(prompt, max = 160) {
|
|
|
4221
4638
|
return clipped.replace(/[.\s]+$/, "");
|
|
4222
4639
|
}
|
|
4223
4640
|
function shotWordsFor(core, brandId) {
|
|
4224
|
-
|
|
4641
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
4225
4642
|
return (hash) => {
|
|
4226
|
-
if (!byHash)
|
|
4227
|
-
byHash = /* @__PURE__ */ new Map();
|
|
4228
|
-
for (const p of core.store.listProjects(brandId)) {
|
|
4229
|
-
for (const n of core.store.treeFor(p.id)) {
|
|
4230
|
-
const words = shotWords(n.prompt);
|
|
4231
|
-
if (!words) continue;
|
|
4232
|
-
for (const h of n.images ?? []) if (!byHash.has(h)) byHash.set(h, words);
|
|
4233
|
-
}
|
|
4234
|
-
}
|
|
4235
|
-
}
|
|
4643
|
+
if (!byHash.has(hash)) byHash.set(hash, shotWords(core.store.promptForImage(brandId, hash)));
|
|
4236
4644
|
return byHash.get(hash) ?? null;
|
|
4237
4645
|
};
|
|
4238
4646
|
}
|
|
@@ -6231,8 +6639,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
6231
6639
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
6232
6640
|
return;
|
|
6233
6641
|
}
|
|
6234
|
-
const png = await
|
|
6235
|
-
const meta = await
|
|
6642
|
+
const png = await sharp21(buf).rotate().png().toBuffer();
|
|
6643
|
+
const meta = await sharp21(png).metadata();
|
|
6236
6644
|
const hash = core.images.save(png);
|
|
6237
6645
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
6238
6646
|
width: meta.width,
|
|
@@ -6709,7 +7117,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
6709
7117
|
return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
|
|
6710
7118
|
}
|
|
6711
7119
|
async function edgeBarGeometry(buf) {
|
|
6712
|
-
const { data, info } = await
|
|
7120
|
+
const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
6713
7121
|
const W = info.width;
|
|
6714
7122
|
const H = info.height;
|
|
6715
7123
|
const scan = (len, cross, at) => {
|
|
@@ -6763,7 +7171,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
6763
7171
|
const width = g.right - g.left + 1;
|
|
6764
7172
|
const height = g.bottom - g.top + 1;
|
|
6765
7173
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
6766
|
-
const png = await
|
|
7174
|
+
const png = await sharp21(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
6767
7175
|
return core.images.save(png);
|
|
6768
7176
|
} catch {
|
|
6769
7177
|
return hash;
|
|
@@ -6834,7 +7242,7 @@ async function identityCrop(core, hash) {
|
|
|
6834
7242
|
if (!out) return void 0;
|
|
6835
7243
|
try {
|
|
6836
7244
|
const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
|
|
6837
|
-
const png = await
|
|
7245
|
+
const png = await sharp21(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
|
|
6838
7246
|
const scaled = core.images.save(png);
|
|
6839
7247
|
identityCrops.set(hash, scaled);
|
|
6840
7248
|
return scaled;
|
|
@@ -6863,12 +7271,12 @@ async function brandJsonWithIdentityCrops(core, json, characterIds) {
|
|
|
6863
7271
|
return changed ? { ...json, characters } : json;
|
|
6864
7272
|
}
|
|
6865
7273
|
async function figureBox(buf) {
|
|
6866
|
-
const meta = await
|
|
7274
|
+
const meta = await sharp21(buf).metadata();
|
|
6867
7275
|
const W = meta.width ?? 0;
|
|
6868
7276
|
const H = meta.height ?? 0;
|
|
6869
7277
|
if (!W || !H) return null;
|
|
6870
7278
|
for (const threshold of FIGURE_TRIM_THRESHOLDS) {
|
|
6871
|
-
const { info } = await
|
|
7279
|
+
const { info } = await sharp21(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
|
|
6872
7280
|
const left = Math.abs(info.trimOffsetLeft ?? 0);
|
|
6873
7281
|
const top = Math.abs(info.trimOffsetTop ?? 0);
|
|
6874
7282
|
const width = info.width ?? 0;
|
|
@@ -6901,13 +7309,13 @@ async function smartCover(core, hash, box) {
|
|
|
6901
7309
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6902
7310
|
try {
|
|
6903
7311
|
const buf = core.images.read(hash);
|
|
6904
|
-
const meta = await
|
|
7312
|
+
const meta = await sharp21(buf).metadata();
|
|
6905
7313
|
const w = meta.width ?? 0;
|
|
6906
7314
|
const h = meta.height ?? 0;
|
|
6907
7315
|
if (!w || !h) return void 0;
|
|
6908
7316
|
const raw = box(w, h);
|
|
6909
7317
|
const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
|
|
6910
|
-
const png = await
|
|
7318
|
+
const png = await sharp21(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
6911
7319
|
return core.images.save(png);
|
|
6912
7320
|
} catch {
|
|
6913
7321
|
return void 0;
|
|
@@ -6916,11 +7324,11 @@ async function smartCover(core, hash, box) {
|
|
|
6916
7324
|
async function crop(core, hash, region, cap2) {
|
|
6917
7325
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6918
7326
|
try {
|
|
6919
|
-
const meta = await
|
|
7327
|
+
const meta = await sharp21(core.images.read(hash)).metadata();
|
|
6920
7328
|
const w = meta.width ?? 0;
|
|
6921
7329
|
const h = meta.height ?? 0;
|
|
6922
7330
|
if (!w || !h) return void 0;
|
|
6923
|
-
let pipeline =
|
|
7331
|
+
let pipeline = sharp21(core.images.read(hash)).extract(region(w, h));
|
|
6924
7332
|
if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
|
|
6925
7333
|
const png = await pipeline.png().toBuffer();
|
|
6926
7334
|
return core.images.save(png);
|
|
@@ -7267,7 +7675,7 @@ var fromLab = (l, a, bb) => {
|
|
|
7267
7675
|
return [clamp(R), clamp(G), clamp(B)];
|
|
7268
7676
|
};
|
|
7269
7677
|
var rawAt = async (png, edge) => {
|
|
7270
|
-
let img =
|
|
7678
|
+
let img = sharp21(png);
|
|
7271
7679
|
if (edge) img = img.resize(edge, edge, { fit: "fill" });
|
|
7272
7680
|
const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
7273
7681
|
return { data, width: info.width, height: info.height };
|
|
@@ -7330,7 +7738,7 @@ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
|
|
|
7330
7738
|
if (residual > GRADE_GATE_MEAN_DELTA) return null;
|
|
7331
7739
|
const full = await rawAt(originalPng);
|
|
7332
7740
|
applyAffine(full, T);
|
|
7333
|
-
const image = await
|
|
7741
|
+
const image = await sharp21(full.data, {
|
|
7334
7742
|
raw: { width: full.width, height: full.height, channels: 3 }
|
|
7335
7743
|
}).png().toBuffer();
|
|
7336
7744
|
return { image, residual };
|
|
@@ -7504,7 +7912,7 @@ function fitExpandToBudget(plan, source, pixelBudget) {
|
|
|
7504
7912
|
}
|
|
7505
7913
|
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
7506
7914
|
try {
|
|
7507
|
-
const { info } = await
|
|
7915
|
+
const { info } = await sharp21(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
7508
7916
|
const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
|
|
7509
7917
|
const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
|
|
7510
7918
|
const left = Math.round((attnLeft + plan.left) / 2);
|
|
@@ -7657,23 +8065,23 @@ function relax(grid, seam, fixedSweeps) {
|
|
|
7657
8065
|
|
|
7658
8066
|
// src/expand.ts
|
|
7659
8067
|
async function expandCanvas(source, plan) {
|
|
7660
|
-
const bed = await
|
|
7661
|
-
return
|
|
8068
|
+
const bed = await sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
8069
|
+
return sharp21(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
7662
8070
|
}
|
|
7663
8071
|
async function compositeExpand(engineImage, source, plan) {
|
|
7664
|
-
const meta = await
|
|
8072
|
+
const meta = await sharp21(engineImage).metadata();
|
|
7665
8073
|
const want = plan.width / plan.height;
|
|
7666
8074
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
7667
8075
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
7668
8076
|
const aligned = sameOrientation;
|
|
7669
8077
|
const exact = meta.width === plan.width && meta.height === plan.height;
|
|
7670
|
-
const surround = aligned ? exact ? engineImage : await
|
|
8078
|
+
const surround = aligned ? exact ? engineImage : await sharp21(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
7671
8079
|
const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
|
|
7672
|
-
const image = await
|
|
8080
|
+
const image = await sharp21(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
7673
8081
|
return { image, aligned };
|
|
7674
8082
|
}
|
|
7675
8083
|
async function matchMarginsToSeam(surround, source, plan) {
|
|
7676
|
-
const src = await
|
|
8084
|
+
const src = await sharp21(source).metadata();
|
|
7677
8085
|
if (!src.width || !src.height) return surround;
|
|
7678
8086
|
const SW = src.width;
|
|
7679
8087
|
const SH = src.height;
|
|
@@ -7720,8 +8128,8 @@ var MAX_CORRECTION = 60;
|
|
|
7720
8128
|
async function reconcile(surround, source, side, axis) {
|
|
7721
8129
|
const { margin } = side;
|
|
7722
8130
|
if (margin.width < 1 || margin.height < 1) return surround;
|
|
7723
|
-
const marginRaw = await
|
|
7724
|
-
const edgeRaw = await
|
|
8131
|
+
const marginRaw = await sharp21(surround).extract(margin).removeAlpha().raw().toBuffer();
|
|
8132
|
+
const edgeRaw = await sharp21(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
|
|
7725
8133
|
const W = margin.width;
|
|
7726
8134
|
const H = margin.height;
|
|
7727
8135
|
const along = axis === "width" ? H : W;
|
|
@@ -7775,11 +8183,11 @@ async function reconcile(surround, source, side, axis) {
|
|
|
7775
8183
|
}
|
|
7776
8184
|
}
|
|
7777
8185
|
}
|
|
7778
|
-
const patch2 = await
|
|
7779
|
-
return
|
|
8186
|
+
const patch2 = await sharp21(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
8187
|
+
return sharp21(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
|
|
7780
8188
|
}
|
|
7781
8189
|
async function expandCanvasBedOnly(source, plan) {
|
|
7782
|
-
return
|
|
8190
|
+
return sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
7783
8191
|
}
|
|
7784
8192
|
function medianOf(rgb, channel, from, to) {
|
|
7785
8193
|
const n = to - from;
|
|
@@ -7790,17 +8198,17 @@ function medianOf(rgb, channel, from, to) {
|
|
|
7790
8198
|
return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
|
|
7791
8199
|
}
|
|
7792
8200
|
async function reframeExpand(engineImage, plan) {
|
|
7793
|
-
const meta = await
|
|
8201
|
+
const meta = await sharp21(engineImage).metadata();
|
|
7794
8202
|
if (!(meta.width && meta.height)) return null;
|
|
7795
8203
|
const want = plan.width / plan.height;
|
|
7796
8204
|
const got = meta.width / meta.height;
|
|
7797
8205
|
if (got >= 1 !== want >= 1) return null;
|
|
7798
8206
|
if (meta.width === plan.width && meta.height === plan.height) return engineImage;
|
|
7799
8207
|
const straight = Math.abs(got - want) / want <= 0.02;
|
|
7800
|
-
return
|
|
8208
|
+
return sharp21(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
|
|
7801
8209
|
}
|
|
7802
8210
|
async function seamScore(image, plan, source) {
|
|
7803
|
-
const { data, info } = await
|
|
8211
|
+
const { data, info } = await sharp21(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
7804
8212
|
const W = info.width;
|
|
7805
8213
|
const H = info.height;
|
|
7806
8214
|
const horizontal = plan.axis === "width";
|
|
@@ -7833,7 +8241,7 @@ var SEAM_VISIBLE = 2.2;
|
|
|
7833
8241
|
var OFFSET = 4;
|
|
7834
8242
|
var RESIDUAL_VISIBLE = 15;
|
|
7835
8243
|
async function seamResidual(image, plan, source) {
|
|
7836
|
-
const { data, info } = await
|
|
8244
|
+
const { data, info } = await sharp21(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
7837
8245
|
const W = info.width;
|
|
7838
8246
|
const H = info.height;
|
|
7839
8247
|
const ch = info.channels;
|
|
@@ -7868,7 +8276,7 @@ var MAX_SHARE = 0.8;
|
|
|
7868
8276
|
async function subjectFraction(src, source, axis) {
|
|
7869
8277
|
try {
|
|
7870
8278
|
const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
|
|
7871
|
-
const { info } = await
|
|
8279
|
+
const { info } = await sharp21(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
7872
8280
|
const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
|
|
7873
8281
|
const span = axis === "width" ? source.width : source.height;
|
|
7874
8282
|
const extent = axis === "width" ? window.width : window.height;
|
|
@@ -7891,14 +8299,14 @@ function placeExpand(plan, source, fraction) {
|
|
|
7891
8299
|
}
|
|
7892
8300
|
var NEUTRAL = { r: 128, g: 128, b: 128 };
|
|
7893
8301
|
async function conditioningCanvas(source, plan, fill = "edge") {
|
|
7894
|
-
const meta = await
|
|
8302
|
+
const meta = await sharp21(source).metadata();
|
|
7895
8303
|
const sw = meta.width ?? 0;
|
|
7896
8304
|
const sh = meta.height ?? 0;
|
|
7897
8305
|
if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
|
|
7898
8306
|
const layers = [];
|
|
7899
8307
|
if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
|
|
7900
8308
|
layers.push({ input: source, left: plan.left, top: plan.top });
|
|
7901
|
-
const canvas =
|
|
8309
|
+
const canvas = sharp21({
|
|
7902
8310
|
create: {
|
|
7903
8311
|
width: plan.width,
|
|
7904
8312
|
height: plan.height,
|
|
@@ -7910,7 +8318,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
|
|
|
7910
8318
|
}
|
|
7911
8319
|
async function edgeMargins(source, plan, size) {
|
|
7912
8320
|
const out = [];
|
|
7913
|
-
const strip = async (extract, width, height) =>
|
|
8321
|
+
const strip = async (extract, width, height) => sharp21(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
|
|
7914
8322
|
if (plan.axis === "width") {
|
|
7915
8323
|
const before = plan.left;
|
|
7916
8324
|
const after = plan.width - plan.left - size.width;
|
|
@@ -8001,12 +8409,12 @@ async function resolveOutpaintRoute(all, shot) {
|
|
|
8001
8409
|
return { engine: shot, method: "reframe", crossed: false };
|
|
8002
8410
|
}
|
|
8003
8411
|
async function driftDiff(a, b) {
|
|
8004
|
-
const metaA = await
|
|
8005
|
-
const metaB = await
|
|
8412
|
+
const metaA = await sharp21(a).metadata();
|
|
8413
|
+
const metaB = await sharp21(b).metadata();
|
|
8006
8414
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
8007
8415
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
8008
8416
|
const [rawA, rawB] = await Promise.all(
|
|
8009
|
-
[a, b].map((buf) =>
|
|
8417
|
+
[a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
8010
8418
|
);
|
|
8011
8419
|
const out = new PNG({ width, height });
|
|
8012
8420
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -8018,11 +8426,11 @@ async function driftDiff(a, b) {
|
|
|
8018
8426
|
};
|
|
8019
8427
|
}
|
|
8020
8428
|
async function changeMask(a, b, cap2 = 1024) {
|
|
8021
|
-
const metaA = await
|
|
8429
|
+
const metaA = await sharp21(a).metadata();
|
|
8022
8430
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
8023
8431
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
8024
8432
|
const [rawA, rawB] = await Promise.all(
|
|
8025
|
-
[a, b].map((buf) =>
|
|
8433
|
+
[a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
8026
8434
|
);
|
|
8027
8435
|
const out = new PNG({ width, height });
|
|
8028
8436
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -8071,8 +8479,8 @@ function dilationFor(longEdge) {
|
|
|
8071
8479
|
// src/localEdit.ts
|
|
8072
8480
|
async function preserveOutsideChange(source, edited) {
|
|
8073
8481
|
try {
|
|
8074
|
-
const srcMeta = await
|
|
8075
|
-
const outMeta = await
|
|
8482
|
+
const srcMeta = await sharp21(source).metadata();
|
|
8483
|
+
const outMeta = await sharp21(edited).metadata();
|
|
8076
8484
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
8077
8485
|
return { image: edited, outcome: "error", changed: 0 };
|
|
8078
8486
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -8082,15 +8490,15 @@ async function preserveOutsideChange(source, edited) {
|
|
|
8082
8490
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
8083
8491
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
8084
8492
|
const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
|
|
8085
|
-
const spread = await
|
|
8086
|
-
const dilated = await
|
|
8087
|
-
const feathered = await
|
|
8088
|
-
const grown = await
|
|
8089
|
-
const editedRgb = await
|
|
8090
|
-
const masked = await
|
|
8493
|
+
const spread = await sharp21(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
|
|
8494
|
+
const dilated = await sharp21(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
|
|
8495
|
+
const feathered = await sharp21(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
|
|
8496
|
+
const grown = await sharp21(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
8497
|
+
const editedRgb = await sharp21(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
8498
|
+
const masked = await sharp21(editedRgb, {
|
|
8091
8499
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
8092
8500
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
8093
|
-
const image = await
|
|
8501
|
+
const image = await sharp21(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
8094
8502
|
return { image, outcome: "composited", changed: shape.changed };
|
|
8095
8503
|
} catch {
|
|
8096
8504
|
return { image: edited, outcome: "error", changed: 0 };
|
|
@@ -8128,7 +8536,7 @@ function registerLogoRoutes(app, deps) {
|
|
|
8128
8536
|
const v = validateBrand(json);
|
|
8129
8537
|
if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
|
|
8130
8538
|
const row = core.store.updateBrand(brand.id, json);
|
|
8131
|
-
const meta = await
|
|
8539
|
+
const meta = await sharp21(core.images.read(part.hash)).metadata().catch(() => null);
|
|
8132
8540
|
const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
|
|
8133
8541
|
return { ...row, logoHash: part.hash, logoEdge };
|
|
8134
8542
|
});
|
|
@@ -8250,7 +8658,7 @@ async function vibrantColor(input) {
|
|
|
8250
8658
|
let data;
|
|
8251
8659
|
let channels;
|
|
8252
8660
|
try {
|
|
8253
|
-
const out = await
|
|
8661
|
+
const out = await sharp21(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
8254
8662
|
data = out.data;
|
|
8255
8663
|
channels = out.info.channels;
|
|
8256
8664
|
} catch {
|
|
@@ -8273,7 +8681,7 @@ async function vibrantColor(input) {
|
|
|
8273
8681
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
8274
8682
|
if (best.score <= 0) {
|
|
8275
8683
|
try {
|
|
8276
|
-
const { dominant } = await
|
|
8684
|
+
const { dominant } = await sharp21(input).stats();
|
|
8277
8685
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
8278
8686
|
} catch {
|
|
8279
8687
|
return null;
|
|
@@ -8782,8 +9190,22 @@ function registerShowcaseRoutes(app, deps) {
|
|
|
8782
9190
|
}
|
|
8783
9191
|
|
|
8784
9192
|
// src/routes/projects.ts
|
|
9193
|
+
var SORTS = ["newest", "oldest", "cost", "keepers"];
|
|
9194
|
+
var NAMES_TTL_MS = 2e3;
|
|
9195
|
+
var NAMES_MAX = 50;
|
|
8785
9196
|
function registerProjectRoutes(app, deps) {
|
|
8786
9197
|
const { core } = deps;
|
|
9198
|
+
const names = /* @__PURE__ */ new Map();
|
|
9199
|
+
const tokenNames = (brand) => {
|
|
9200
|
+
const key = `${brand.id}:${brand.updatedAt}`;
|
|
9201
|
+
const now = Date.now();
|
|
9202
|
+
const hit = names.get(key);
|
|
9203
|
+
if (hit && now - hit.at < NAMES_TTL_MS) return hit.list;
|
|
9204
|
+
const list2 = deps.tokenNames(brand);
|
|
9205
|
+
if (names.size >= NAMES_MAX) names.delete(names.keys().next().value);
|
|
9206
|
+
names.set(key, { at: now, list: list2 });
|
|
9207
|
+
return list2;
|
|
9208
|
+
};
|
|
8787
9209
|
app.post("/api/projects", async (req, reply) => {
|
|
8788
9210
|
const { brandId, name } = req.body;
|
|
8789
9211
|
if (!core.store.getBrand(String(brandId))) return reply.status(404).send({ error: "brand not found" });
|
|
@@ -8807,10 +9229,61 @@ function registerProjectRoutes(app, deps) {
|
|
|
8807
9229
|
const project = core.store.workspaceFor(brand.id);
|
|
8808
9230
|
return {
|
|
8809
9231
|
project,
|
|
8810
|
-
|
|
9232
|
+
root: core.store.rootFor(project.id)?.id ?? null,
|
|
8811
9233
|
sets: core.store.listSets(brand.id),
|
|
8812
|
-
membership: core.store.membershipFor(brand.id)
|
|
9234
|
+
membership: core.store.membershipFor(brand.id),
|
|
9235
|
+
recent: core.store.recentShots(project.id, 48)
|
|
9236
|
+
};
|
|
9237
|
+
});
|
|
9238
|
+
app.get("/api/brands/:id/feed", async (req, reply) => {
|
|
9239
|
+
const brand = core.store.getBrand(req.params.id);
|
|
9240
|
+
if (!brand) return reply.status(404).send({ error: "brand not found" });
|
|
9241
|
+
const project = core.store.workspaceFor(brand.id);
|
|
9242
|
+
const qs = req.query ?? {};
|
|
9243
|
+
const lens = qs.lens === "keepers" || qs.lens === "archived" ? qs.lens : "all";
|
|
9244
|
+
const sort = SORTS.includes(qs.sort) ? qs.sort : "newest";
|
|
9245
|
+
const tokens = qs.token ? qs.token.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
9246
|
+
const q = qs.q ?? "";
|
|
9247
|
+
let terms;
|
|
9248
|
+
if (q.trim()) {
|
|
9249
|
+
const known = tokenNames(brand);
|
|
9250
|
+
const engines = deps.engineNames();
|
|
9251
|
+
terms = searchTerms(q).map((t) => ({
|
|
9252
|
+
...t,
|
|
9253
|
+
tokenIds: known.filter((n) => termMatches(n.name, t)).map((n) => n.id),
|
|
9254
|
+
engineIds: engines.filter((e) => termMatches(e.name, t)).map((e) => e.id)
|
|
9255
|
+
}));
|
|
9256
|
+
}
|
|
9257
|
+
const filter = {
|
|
9258
|
+
lens,
|
|
9259
|
+
set: qs.set || void 0,
|
|
9260
|
+
ungrouped: qs.ungrouped === "1" || qs.ungrouped === "true",
|
|
9261
|
+
lineage: qs.lineage || void 0,
|
|
9262
|
+
tokens: tokens?.length ? tokens : void 0,
|
|
9263
|
+
terms
|
|
8813
9264
|
};
|
|
9265
|
+
let page;
|
|
9266
|
+
try {
|
|
9267
|
+
page = core.store.feedPage(project.id, {
|
|
9268
|
+
...filter,
|
|
9269
|
+
sort,
|
|
9270
|
+
limit: Number(qs.limit) || 60,
|
|
9271
|
+
cursor: qs.cursor || null
|
|
9272
|
+
});
|
|
9273
|
+
} catch (err) {
|
|
9274
|
+
if (/cursor/.test(String(err.message))) return reply.status(400).send({ error: "invalid cursor" });
|
|
9275
|
+
throw err;
|
|
9276
|
+
}
|
|
9277
|
+
return qs.cursor ? page : { ...page, counts: core.store.feedCounts(project.id, filter) };
|
|
9278
|
+
});
|
|
9279
|
+
app.get("/api/nodes/:id/lineage", async (req, reply) => {
|
|
9280
|
+
const lineage = core.store.lineageOf(req.params.id);
|
|
9281
|
+
return lineage ?? reply.status(404).send({ error: "node not found" });
|
|
9282
|
+
});
|
|
9283
|
+
app.get("/api/brands/:id/usage", async (req, reply) => {
|
|
9284
|
+
const brand = core.store.getBrand(req.params.id);
|
|
9285
|
+
if (!brand) return reply.status(404).send({ error: "brand not found" });
|
|
9286
|
+
return { days: core.store.usageByDay(brand.id) };
|
|
8814
9287
|
});
|
|
8815
9288
|
app.get("/api/brands/:id/sets", async (req, reply) => {
|
|
8816
9289
|
const brand = core.store.getBrand(req.params.id);
|
|
@@ -8843,13 +9316,13 @@ function registerProjectRoutes(app, deps) {
|
|
|
8843
9316
|
const nodeIds = (Array.isArray(raw) ? raw : []).map(String).filter((id) => core.store.getNode(id));
|
|
8844
9317
|
if (nodeIds.length === 0) return reply.status(400).send({ error: "nodeIds must name at least one shot" });
|
|
8845
9318
|
core.store.addToSet(set.id, nodeIds);
|
|
8846
|
-
return { ok: true, added: nodeIds.length };
|
|
9319
|
+
return { ok: true, added: nodeIds.length, nodeIds: core.store.membersOf(set.id) };
|
|
8847
9320
|
});
|
|
8848
9321
|
app.delete("/api/sets/:id/nodes/:nodeId", async (req, reply) => {
|
|
8849
9322
|
const { id, nodeId } = req.params;
|
|
8850
9323
|
if (!core.store.getSet(id)) return reply.status(404).send({ error: "set not found" });
|
|
8851
9324
|
core.store.removeFromSet(id, nodeId);
|
|
8852
|
-
return { ok: true };
|
|
9325
|
+
return { ok: true, nodeIds: core.store.membersOf(id) };
|
|
8853
9326
|
});
|
|
8854
9327
|
}
|
|
8855
9328
|
|
|
@@ -8892,7 +9365,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
8892
9365
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
8893
9366
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
8894
9367
|
for (const p of chosen) {
|
|
8895
|
-
const buf = p.width && p.height ? await
|
|
9368
|
+
const buf = p.width && p.height ? await sharp21(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
8896
9369
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
8897
9370
|
}
|
|
8898
9371
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -9038,23 +9511,136 @@ function readme(json, missing) {
|
|
|
9038
9511
|
${missing} referenced image${missing === 1 ? " was" : "s were"} missing and left out.` : ""
|
|
9039
9512
|
].join("\n");
|
|
9040
9513
|
}
|
|
9514
|
+
var THUMB_WIDTHS = [640, 160];
|
|
9515
|
+
var isThumbWidth = (w) => THUMB_WIDTHS.includes(w);
|
|
9516
|
+
var QUALITY = { 640: 82, 160: 75 };
|
|
9517
|
+
function createThumbStore(core, opts = {}) {
|
|
9518
|
+
const dir = join(core.home, "thumbs");
|
|
9519
|
+
let enabled = true;
|
|
9520
|
+
try {
|
|
9521
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
9522
|
+
} catch {
|
|
9523
|
+
enabled = false;
|
|
9524
|
+
}
|
|
9525
|
+
const pathFor = (hash, w) => join(dir, `${hash}-w${w}.webp`);
|
|
9526
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
9527
|
+
const failed = /* @__PURE__ */ new Set();
|
|
9528
|
+
const concurrency = Math.max(1, opts.concurrency ?? 2);
|
|
9529
|
+
let active = 0;
|
|
9530
|
+
const waiting = [];
|
|
9531
|
+
const acquire = () => new Promise((resolve) => {
|
|
9532
|
+
if (active < concurrency) {
|
|
9533
|
+
active++;
|
|
9534
|
+
resolve();
|
|
9535
|
+
} else waiting.push(resolve);
|
|
9536
|
+
});
|
|
9537
|
+
const release = () => {
|
|
9538
|
+
const next = waiting.shift();
|
|
9539
|
+
if (next) next();
|
|
9540
|
+
else active--;
|
|
9541
|
+
};
|
|
9542
|
+
async function make(hash, w) {
|
|
9543
|
+
const final = pathFor(hash, w);
|
|
9544
|
+
const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
9545
|
+
await acquire();
|
|
9546
|
+
try {
|
|
9547
|
+
await sharp21(core.images.pathFor(hash)).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
|
|
9548
|
+
await rename(tmp, final);
|
|
9549
|
+
return final;
|
|
9550
|
+
} catch {
|
|
9551
|
+
await unlink(tmp).catch(() => {
|
|
9552
|
+
});
|
|
9553
|
+
failed.add(`${hash}-w${w}`);
|
|
9554
|
+
return null;
|
|
9555
|
+
} finally {
|
|
9556
|
+
release();
|
|
9557
|
+
}
|
|
9558
|
+
}
|
|
9559
|
+
return {
|
|
9560
|
+
dir,
|
|
9561
|
+
async ensure(hash, w) {
|
|
9562
|
+
if (!enabled || !/^[a-f0-9]{32}$/.test(hash)) return null;
|
|
9563
|
+
const key = `${hash}-w${w}`;
|
|
9564
|
+
if (failed.has(key)) return null;
|
|
9565
|
+
const final = pathFor(hash, w);
|
|
9566
|
+
try {
|
|
9567
|
+
await access(final);
|
|
9568
|
+
return final;
|
|
9569
|
+
} catch {
|
|
9570
|
+
}
|
|
9571
|
+
let job = inflight.get(key);
|
|
9572
|
+
if (!job) {
|
|
9573
|
+
job = make(hash, w).finally(() => inflight.delete(key));
|
|
9574
|
+
inflight.set(key, job);
|
|
9575
|
+
}
|
|
9576
|
+
return job;
|
|
9577
|
+
},
|
|
9578
|
+
warm(hash) {
|
|
9579
|
+
for (const w of THUMB_WIDTHS) void this.ensure(hash, w);
|
|
9580
|
+
},
|
|
9581
|
+
async settle() {
|
|
9582
|
+
await Promise.allSettled([...inflight.values()]);
|
|
9583
|
+
},
|
|
9584
|
+
clear() {
|
|
9585
|
+
failed.clear();
|
|
9586
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
9587
|
+
try {
|
|
9588
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
9589
|
+
} catch {
|
|
9590
|
+
enabled = false;
|
|
9591
|
+
}
|
|
9592
|
+
},
|
|
9593
|
+
stream: (path) => createReadStream(path)
|
|
9594
|
+
};
|
|
9595
|
+
}
|
|
9596
|
+
async function fileSize(path) {
|
|
9597
|
+
try {
|
|
9598
|
+
const s = await stat(path);
|
|
9599
|
+
return s.isFile() ? s.size : null;
|
|
9600
|
+
} catch {
|
|
9601
|
+
return null;
|
|
9602
|
+
}
|
|
9603
|
+
}
|
|
9041
9604
|
|
|
9042
9605
|
// src/routes/images.ts
|
|
9606
|
+
var IMMUTABLE = "public, max-age=31536000, immutable";
|
|
9043
9607
|
function registerImageRoutes(app, deps) {
|
|
9044
|
-
const { core } = deps;
|
|
9608
|
+
const { core, thumbs } = deps;
|
|
9045
9609
|
app.get("/api/images/:hash", async (req, reply) => {
|
|
9046
9610
|
const hash = String(req.params.hash);
|
|
9047
|
-
if (
|
|
9048
|
-
|
|
9049
|
-
|
|
9611
|
+
if (!/^[a-f0-9]{32}$/.test(hash)) return reply.status(404).send({ error: "image not found" });
|
|
9612
|
+
const path = core.images.pathFor(hash);
|
|
9613
|
+
const size = await fileSize(path);
|
|
9614
|
+
if (size === null) return reply.status(404).send({ error: "image not found" });
|
|
9615
|
+
const etag = `"${hash}"`;
|
|
9616
|
+
reply.header("cache-control", IMMUTABLE).header("etag", etag);
|
|
9617
|
+
if (req.headers["if-none-match"] === etag) return reply.status(304).send();
|
|
9618
|
+
reply.header("content-type", "image/png").header("content-length", String(size));
|
|
9619
|
+
return reply.send(createReadStream(path));
|
|
9620
|
+
});
|
|
9621
|
+
app.get("/api/images/:hash/thumb", async (req, reply) => {
|
|
9622
|
+
const hash = String(req.params.hash);
|
|
9623
|
+
const w = Number(req.query?.w);
|
|
9624
|
+
if (!isThumbWidth(w)) return reply.status(400).send({ error: "w must be 640 or 160" });
|
|
9625
|
+
if (!/^[a-f0-9]{32}$/.test(hash)) return reply.status(404).send({ error: "image not found" });
|
|
9626
|
+
const etag = `"${hash}-w${w}"`;
|
|
9627
|
+
if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", IMMUTABLE).send();
|
|
9628
|
+
if (await fileSize(core.images.pathFor(hash)) === null)
|
|
9629
|
+
return reply.status(404).send({ error: "image not found" });
|
|
9630
|
+
const path = await thumbs.ensure(hash, w);
|
|
9631
|
+
if (!path) return reply.header("cache-control", "no-store").redirect(`/api/images/${hash}`, 307);
|
|
9632
|
+
const size = await fileSize(path);
|
|
9633
|
+
if (size === null) return reply.header("cache-control", "no-store").redirect(`/api/images/${hash}`, 307);
|
|
9634
|
+
reply.header("content-type", "image/webp").header("cache-control", IMMUTABLE).header("etag", etag).header("content-length", String(size));
|
|
9635
|
+
return reply.send(thumbs.stream(path));
|
|
9050
9636
|
});
|
|
9051
9637
|
app.post("/api/images", async (req, reply) => {
|
|
9052
9638
|
const part = await req.file();
|
|
9053
9639
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
9054
9640
|
const buf = await part.toBuffer();
|
|
9055
9641
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
9056
|
-
const fmt = (await
|
|
9057
|
-
const png = fmt === "svg" ? await toMarkPng(buf) : await
|
|
9642
|
+
const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
|
|
9643
|
+
const png = fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
|
|
9058
9644
|
return { hash: core.images.save(png) };
|
|
9059
9645
|
});
|
|
9060
9646
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -9089,6 +9675,35 @@ function registerImageRoutes(app, deps) {
|
|
|
9089
9675
|
|
|
9090
9676
|
// src/release/notes.data.ts
|
|
9091
9677
|
var RELEASES = [
|
|
9678
|
+
{
|
|
9679
|
+
version: "0.8.2",
|
|
9680
|
+
date: "2026-09-03",
|
|
9681
|
+
sections: [
|
|
9682
|
+
{
|
|
9683
|
+
heading: "Security",
|
|
9684
|
+
body: "The server Scenri runs on and the URL parser under it are on their patched releases. Nothing about how Scenri works changes."
|
|
9685
|
+
}
|
|
9686
|
+
]
|
|
9687
|
+
},
|
|
9688
|
+
{
|
|
9689
|
+
version: "0.8.1",
|
|
9690
|
+
date: "2026-09-03",
|
|
9691
|
+
title: "The same speed at any size.",
|
|
9692
|
+
sections: [
|
|
9693
|
+
{
|
|
9694
|
+
heading: "Create",
|
|
9695
|
+
body: "The feed opens on its first page and brings in more as you scroll, so a brand holding ten shots and a brand holding ten thousand open in the same moment. Pictures arrive as light copies sized for where they are shown, and the full image only where you actually look at it, so a long feed scrolls smoothly and a shot opens with its picture already in hand. Search reads the whole brand from the third letter you type, and finds shots by the products, people and scenes they were made with as well as by their words."
|
|
9696
|
+
},
|
|
9697
|
+
{
|
|
9698
|
+
heading: "Library",
|
|
9699
|
+
body: "Opening Scenri no longer slows down as your library grows. The first start after this update builds a search index once, which takes a moment on a very large library; every start after that is immediate. Clearing every shot from the danger zone now removes their thumbnails too, which used to stay on disk with nothing left that could find them."
|
|
9700
|
+
},
|
|
9701
|
+
{
|
|
9702
|
+
heading: "Fixes",
|
|
9703
|
+
body: "Dragging a chip to reorder it shows the insertion mark in the gap you are pointing at, rather than a chip or two further along, and a drag that changes nothing leaves the caret where it was. A drag released outside the brief no longer leaves the line unable to take a caret at all. A shot that is still rendering now shows the same selection ring as every other tile when you open it."
|
|
9704
|
+
}
|
|
9705
|
+
]
|
|
9706
|
+
},
|
|
9092
9707
|
{
|
|
9093
9708
|
version: "0.8.0",
|
|
9094
9709
|
date: "2026-09-02",
|
|
@@ -9886,21 +10501,20 @@ function registerUpdateRoutes(app, deps) {
|
|
|
9886
10501
|
});
|
|
9887
10502
|
}
|
|
9888
10503
|
function registerSystemRoutes(app, deps) {
|
|
9889
|
-
const { core } = deps;
|
|
10504
|
+
const { core, thumbs } = deps;
|
|
9890
10505
|
app.get("/api/home", async () => {
|
|
9891
10506
|
const imagesDir = join(core.home, "images");
|
|
9892
|
-
let files = 0
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
9896
|
-
|
|
9897
|
-
|
|
9898
|
-
|
|
9899
|
-
|
|
9900
|
-
}
|
|
10507
|
+
let files = 0;
|
|
10508
|
+
let bytes = 0;
|
|
10509
|
+
const names = await readdir(imagesDir).catch(() => []);
|
|
10510
|
+
const sizes = await Promise.all(names.map((f) => stat(join(imagesDir, f)).catch(() => null)));
|
|
10511
|
+
for (const s of sizes) {
|
|
10512
|
+
if (!s?.isFile()) continue;
|
|
10513
|
+
bytes += s.size;
|
|
10514
|
+
files++;
|
|
9901
10515
|
}
|
|
9902
10516
|
const dbPath = join(core.home, "scenri.db");
|
|
9903
|
-
const dbBytes =
|
|
10517
|
+
const dbBytes = (await stat(dbPath).catch(() => null))?.size ?? 0;
|
|
9904
10518
|
return { dir: core.home, dbPath, images: files, bytes: bytes + dbBytes };
|
|
9905
10519
|
});
|
|
9906
10520
|
app.post("/api/system/reveal", async (_req, reply) => {
|
|
@@ -9945,10 +10559,11 @@ function registerSystemRoutes(app, deps) {
|
|
|
9945
10559
|
removed++;
|
|
9946
10560
|
}
|
|
9947
10561
|
}
|
|
10562
|
+
thumbs.clear();
|
|
9948
10563
|
return { ok: true, scope, projects: removed };
|
|
9949
10564
|
}
|
|
9950
10565
|
core.close();
|
|
9951
|
-
for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "backups"]) {
|
|
10566
|
+
for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "thumbs", "backups"]) {
|
|
9952
10567
|
rmSync(join(core.home, name), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
9953
10568
|
}
|
|
9954
10569
|
return { ok: true, scope };
|
|
@@ -9972,6 +10587,7 @@ function buildServer(opts) {
|
|
|
9972
10587
|
registerAccessGuard(app, opts.access);
|
|
9973
10588
|
const reserved = /* @__PURE__ */ new Map();
|
|
9974
10589
|
const runningGenerations = /* @__PURE__ */ new Map();
|
|
10590
|
+
const thumbs = createThumbStore(core);
|
|
9975
10591
|
const { scenes } = loadScenes(opts.templatesDir);
|
|
9976
10592
|
const resolveScene = sceneResolver(scenes);
|
|
9977
10593
|
const sceneFor = (brandJson) => (id) => brandSceneById(brandJson, id) ?? resolveScene(id);
|
|
@@ -10002,7 +10618,7 @@ function buildServer(opts) {
|
|
|
10002
10618
|
// Measured as stored (post-toMarkPng), so the scrape judges the same
|
|
10003
10619
|
// pixels the compiler will one day attach.
|
|
10004
10620
|
probeLongEdge: async (buf) => {
|
|
10005
|
-
const m = await
|
|
10621
|
+
const m = await sharp21(await toMarkPng(buf)).metadata();
|
|
10006
10622
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
10007
10623
|
},
|
|
10008
10624
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -10091,7 +10707,7 @@ function buildServer(opts) {
|
|
|
10091
10707
|
fetchImpl: opts.fetchImpl,
|
|
10092
10708
|
saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
|
|
10093
10709
|
probeLongEdge: async (buf) => {
|
|
10094
|
-
const m = await
|
|
10710
|
+
const m = await sharp21(await toMarkPng(buf)).metadata();
|
|
10095
10711
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
10096
10712
|
},
|
|
10097
10713
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -10388,7 +11004,19 @@ function buildServer(opts) {
|
|
|
10388
11004
|
const { referenceImages, ...rest } = compiled2;
|
|
10389
11005
|
return { ...rest, referenceCount: referenceImages.length, cap: engine.capabilities().maxReferenceImages };
|
|
10390
11006
|
});
|
|
10391
|
-
registerProjectRoutes(app, {
|
|
11007
|
+
registerProjectRoutes(app, {
|
|
11008
|
+
core,
|
|
11009
|
+
// what a search may match a brief token by: every name a token can carry today
|
|
11010
|
+
tokenNames: (brand) => [
|
|
11011
|
+
...core.catalog.listLibraryProducts(brand.id, brand.json).map((p) => ({ id: p.id, name: p.name })),
|
|
11012
|
+
...demoProducts.map((p) => ({ id: p.id, name: p.name })),
|
|
11013
|
+
...brandCharacters(brand.json).map((c) => ({ id: String(c.id), name: String(c.name ?? "") })),
|
|
11014
|
+
...presenters.map((p) => ({ id: p.id, name: p.name })),
|
|
11015
|
+
...brandScenes(brand.json).map((sc) => ({ id: sc.id, name: sc.name })),
|
|
11016
|
+
...scenes.map((sc) => ({ id: sc.id, name: sc.name }))
|
|
11017
|
+
],
|
|
11018
|
+
engineNames: () => engines.all().map((e) => ({ id: e.capabilities().id, name: e.capabilities().displayName }))
|
|
11019
|
+
});
|
|
10392
11020
|
registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup, codexRunner: engines.codexRunner });
|
|
10393
11021
|
app.get("/api/engines", async () => {
|
|
10394
11022
|
const list2 = [];
|
|
@@ -10452,11 +11080,11 @@ function buildServer(opts) {
|
|
|
10452
11080
|
const out = [];
|
|
10453
11081
|
for (const h of images) {
|
|
10454
11082
|
const buf = core.images.read(h);
|
|
10455
|
-
const meta2 = await
|
|
11083
|
+
const meta2 = await sharp21(buf).metadata().catch(() => null);
|
|
10456
11084
|
if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
|
|
10457
11085
|
const oriented = (meta2.orientation ?? 1) !== 1;
|
|
10458
11086
|
out.push(
|
|
10459
|
-
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await
|
|
11087
|
+
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp21(buf).rotate().png().toBuffer())
|
|
10460
11088
|
);
|
|
10461
11089
|
}
|
|
10462
11090
|
return out;
|
|
@@ -10468,7 +11096,7 @@ function buildServer(opts) {
|
|
|
10468
11096
|
const out = [];
|
|
10469
11097
|
for (const h of images) {
|
|
10470
11098
|
const buf = core.images.read(h);
|
|
10471
|
-
const meta2 = await
|
|
11099
|
+
const meta2 = await sharp21(buf).metadata();
|
|
10472
11100
|
if (!meta2.width || !meta2.height) {
|
|
10473
11101
|
out.push(h);
|
|
10474
11102
|
continue;
|
|
@@ -10481,7 +11109,7 @@ function buildServer(opts) {
|
|
|
10481
11109
|
}
|
|
10482
11110
|
const w = got > target ? Math.round(meta2.height * target) : meta2.width;
|
|
10483
11111
|
const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
|
|
10484
|
-
const cropped = await
|
|
11112
|
+
const cropped = await sharp21(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
10485
11113
|
app.log.info(
|
|
10486
11114
|
{ nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
|
|
10487
11115
|
"canvas: cropped a drifted frame to the asked ratio"
|
|
@@ -10500,7 +11128,7 @@ function buildServer(opts) {
|
|
|
10500
11128
|
async function assertAspect(images, expect) {
|
|
10501
11129
|
const want = expect.width / expect.height;
|
|
10502
11130
|
for (const h of images) {
|
|
10503
|
-
const meta2 = await
|
|
11131
|
+
const meta2 = await sharp21(core.images.read(h)).metadata();
|
|
10504
11132
|
if (!meta2.width || !meta2.height) continue;
|
|
10505
11133
|
const got = meta2.width / meta2.height;
|
|
10506
11134
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -10531,7 +11159,7 @@ function buildServer(opts) {
|
|
|
10531
11159
|
if (post) own = await post(own);
|
|
10532
11160
|
if (expect) await assertAspect(own, expect);
|
|
10533
11161
|
try {
|
|
10534
|
-
const meta2 = await
|
|
11162
|
+
const meta2 = await sharp21(core.images.read(own[0])).metadata();
|
|
10535
11163
|
const node = core.store.getNode(id);
|
|
10536
11164
|
if (node && meta2.width && meta2.height) {
|
|
10537
11165
|
const brief = node.brief ?? {};
|
|
@@ -10546,6 +11174,7 @@ function buildServer(opts) {
|
|
|
10546
11174
|
} catch {
|
|
10547
11175
|
}
|
|
10548
11176
|
core.store.completeNode(id, { images: own, costUsd: 0, durationMs: Date.now() - startedAt });
|
|
11177
|
+
thumbs.warm(own[0]);
|
|
10549
11178
|
} catch (err) {
|
|
10550
11179
|
core.store.failNode(id, String(err?.message ?? err));
|
|
10551
11180
|
} finally {
|
|
@@ -10639,7 +11268,7 @@ function buildServer(opts) {
|
|
|
10639
11268
|
crop: window
|
|
10640
11269
|
});
|
|
10641
11270
|
const work2 = async () => ({
|
|
10642
|
-
images: [core.images.save(await
|
|
11271
|
+
images: [core.images.save(await sharp21(args.srcBuf).extract(window).png().toBuffer())],
|
|
10643
11272
|
costUsd: 0
|
|
10644
11273
|
});
|
|
10645
11274
|
void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
@@ -10648,7 +11277,7 @@ function buildServer(opts) {
|
|
|
10648
11277
|
return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
|
|
10649
11278
|
};
|
|
10650
11279
|
if (kind === "edit" && reshape === "crop") {
|
|
10651
|
-
const rootForCrop = core.store.
|
|
11280
|
+
const rootForCrop = core.store.rootFor(project.id);
|
|
10652
11281
|
if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
|
|
10653
11282
|
const cropParentId = parentId ? String(parentId) : rootForCrop.id;
|
|
10654
11283
|
if (brief && Array.isArray(brief.tokens)) {
|
|
@@ -10664,7 +11293,7 @@ function buildServer(opts) {
|
|
|
10664
11293
|
if (!srcHash || !core.images.has(String(srcHash)))
|
|
10665
11294
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
10666
11295
|
const srcBuf = core.images.read(String(srcHash));
|
|
10667
|
-
const srcMeta = await
|
|
11296
|
+
const srcMeta = await sharp21(srcBuf).metadata();
|
|
10668
11297
|
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
10669
11298
|
return runCropNode({
|
|
10670
11299
|
parentId: cropParentId,
|
|
@@ -10680,7 +11309,7 @@ function buildServer(opts) {
|
|
|
10680
11309
|
if (!avail.ok) return reply.status(400).send({ error: avail.reason ?? "engine unavailable" });
|
|
10681
11310
|
if (kind !== "generation" && kind !== "edit")
|
|
10682
11311
|
return reply.status(400).send({ error: "kind must be generation|edit" });
|
|
10683
|
-
const rootNode = core.store.
|
|
11312
|
+
const rootNode = core.store.rootFor(project.id);
|
|
10684
11313
|
if (!rootNode) return reply.status(500).send({ error: "project has no root node" });
|
|
10685
11314
|
const resolvedParentId = parentId ? String(parentId) : rootNode.id;
|
|
10686
11315
|
const ctx = brandContext(core, project.brandId);
|
|
@@ -10879,7 +11508,7 @@ function buildServer(opts) {
|
|
|
10879
11508
|
);
|
|
10880
11509
|
}
|
|
10881
11510
|
const srcBuf = core.images.read(String(srcHash));
|
|
10882
|
-
const srcMeta = await
|
|
11511
|
+
const srcMeta = await sharp21(srcBuf).metadata();
|
|
10883
11512
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
10884
11513
|
const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
|
|
10885
11514
|
const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
|
|
@@ -10913,7 +11542,7 @@ function buildServer(opts) {
|
|
|
10913
11542
|
} else if (decision.op === "extend") {
|
|
10914
11543
|
if (decision.assist) {
|
|
10915
11544
|
expandAssist = { width: decision.assist.width, height: decision.assist.height };
|
|
10916
|
-
workBuf = await
|
|
11545
|
+
workBuf = await sharp21(srcBuf).extract(decision.assist).png().toBuffer();
|
|
10917
11546
|
workSize = { width: decision.assist.width, height: decision.assist.height };
|
|
10918
11547
|
}
|
|
10919
11548
|
expandPlan = planExpand(workSize, targetRatio);
|
|
@@ -10934,7 +11563,7 @@ function buildServer(opts) {
|
|
|
10934
11563
|
const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
|
|
10935
11564
|
if (fit.scale < 1) {
|
|
10936
11565
|
expandPlan = fit.plan;
|
|
10937
|
-
workBuf = await
|
|
11566
|
+
workBuf = await sharp21(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
|
|
10938
11567
|
workSize = fit.source;
|
|
10939
11568
|
extraWarnings.push(
|
|
10940
11569
|
`${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
|
|
@@ -10957,7 +11586,7 @@ function buildServer(opts) {
|
|
|
10957
11586
|
if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
|
|
10958
11587
|
sentSize = stepped;
|
|
10959
11588
|
budgetSourceHash = core.images.save(
|
|
10960
|
-
await
|
|
11589
|
+
await sharp21(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
|
|
10961
11590
|
);
|
|
10962
11591
|
if (!gradeOnlyAsk)
|
|
10963
11592
|
extraWarnings.push(
|
|
@@ -11097,11 +11726,11 @@ function buildServer(opts) {
|
|
|
11097
11726
|
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
11098
11727
|
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
11099
11728
|
const enforceEditCanvas = async (images) => {
|
|
11100
|
-
const srcMeta = await
|
|
11729
|
+
const srcMeta = await sharp21(original).metadata();
|
|
11101
11730
|
if (!srcMeta.width || !srcMeta.height) return images;
|
|
11102
11731
|
const out = [];
|
|
11103
11732
|
for (const h of images) {
|
|
11104
|
-
const meta2 = await
|
|
11733
|
+
const meta2 = await sharp21(core.images.read(h)).metadata();
|
|
11105
11734
|
const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
|
|
11106
11735
|
const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
|
|
11107
11736
|
pixelBudget: runEngine.capabilities().editPixelBudget
|
|
@@ -11131,7 +11760,7 @@ function buildServer(opts) {
|
|
|
11131
11760
|
);
|
|
11132
11761
|
out.push(
|
|
11133
11762
|
core.images.save(
|
|
11134
|
-
await
|
|
11763
|
+
await sharp21(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
|
|
11135
11764
|
)
|
|
11136
11765
|
);
|
|
11137
11766
|
try {
|
|
@@ -11153,7 +11782,7 @@ function buildServer(opts) {
|
|
|
11153
11782
|
const out = [];
|
|
11154
11783
|
for (const h of images) {
|
|
11155
11784
|
const answer = core.images.read(h);
|
|
11156
|
-
const got = await
|
|
11785
|
+
const got = await sharp21(answer).metadata();
|
|
11157
11786
|
if (got.width !== plan.width || got.height !== plan.height)
|
|
11158
11787
|
app.log.info(
|
|
11159
11788
|
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
@@ -11261,13 +11890,13 @@ function buildServer(opts) {
|
|
|
11261
11890
|
const n = core.store.getNode(req.params.id);
|
|
11262
11891
|
if (!n) return reply.status(404).send({ error: "node not found" });
|
|
11263
11892
|
core.store.setKept(n.id, Boolean(req.body?.kept ?? true));
|
|
11264
|
-
return core.store.
|
|
11893
|
+
return core.store.getFeedNode(n.id);
|
|
11265
11894
|
});
|
|
11266
11895
|
app.post("/api/nodes/:id/archive", async (req, reply) => {
|
|
11267
11896
|
const n = core.store.getNode(req.params.id);
|
|
11268
11897
|
if (!n) return reply.status(404).send({ error: "node not found" });
|
|
11269
11898
|
core.store.setArchived(n.id, Boolean(req.body?.archived ?? true));
|
|
11270
|
-
return core.store.
|
|
11899
|
+
return core.store.getFeedNode(n.id);
|
|
11271
11900
|
});
|
|
11272
11901
|
app.delete("/api/nodes/:id", async (req, reply) => {
|
|
11273
11902
|
const n = core.store.getNode(req.params.id);
|
|
@@ -11289,7 +11918,7 @@ function buildServer(opts) {
|
|
|
11289
11918
|
}
|
|
11290
11919
|
return { ok: true, deleted };
|
|
11291
11920
|
});
|
|
11292
|
-
registerImageRoutes(app, { core });
|
|
11921
|
+
registerImageRoutes(app, { core, thumbs });
|
|
11293
11922
|
const runtime = opts.runtime ?? { installKind: "unknown", supervised: false };
|
|
11294
11923
|
const updates = createUpdateChecker({ name: meta.name, store: core.store, fetchImpl: opts.fetchImpl });
|
|
11295
11924
|
app.decorate("updates", updates);
|
|
@@ -11314,12 +11943,13 @@ function buildServer(opts) {
|
|
|
11314
11943
|
while (runningGenerations.size > 0 && Date.now() < deadline) {
|
|
11315
11944
|
await new Promise((r) => setTimeout(r, 25));
|
|
11316
11945
|
}
|
|
11946
|
+
await thumbs.settle();
|
|
11317
11947
|
await app.close();
|
|
11318
11948
|
core.close();
|
|
11319
11949
|
})();
|
|
11320
11950
|
return drained;
|
|
11321
11951
|
});
|
|
11322
|
-
registerSystemRoutes(app, { core });
|
|
11952
|
+
registerSystemRoutes(app, { core, thumbs });
|
|
11323
11953
|
if (opts.studioDist && existsSync(opts.studioDist)) {
|
|
11324
11954
|
const dist = opts.studioDist;
|
|
11325
11955
|
app.register(fastifyStatic, { root: dist });
|
|
@@ -11383,7 +12013,6 @@ async function run() {
|
|
|
11383
12013
|
const onlyThisMachine = LOOPBACK2.includes(HOST);
|
|
11384
12014
|
const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
|
|
11385
12015
|
const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
|
|
11386
|
-
await repairPresenterCrops(core, (line) => console.log(line));
|
|
11387
12016
|
const app = buildServer({
|
|
11388
12017
|
core,
|
|
11389
12018
|
engines,
|
|
@@ -11445,6 +12074,7 @@ async function run() {
|
|
|
11445
12074
|
};
|
|
11446
12075
|
process.on("SIGINT", shutdown);
|
|
11447
12076
|
process.on("SIGTERM", shutdown);
|
|
12077
|
+
void repairPresenterCrops(core, (line) => console.log(line)).catch(() => void 0);
|
|
11448
12078
|
app.updates.schedule();
|
|
11449
12079
|
app.content.schedule();
|
|
11450
12080
|
const query = token ? `/?t=${token}` : "";
|
|
@@ -11488,8 +12118,8 @@ async function verify() {
|
|
|
11488
12118
|
const db = new Database2(":memory:");
|
|
11489
12119
|
db.pragma("user_version");
|
|
11490
12120
|
db.close();
|
|
11491
|
-
const { default:
|
|
11492
|
-
await
|
|
12121
|
+
const { default: sharp22 } = await import('sharp');
|
|
12122
|
+
await sharp22({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
11493
12123
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
11494
12124
|
} catch (err) {
|
|
11495
12125
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|