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