scenri 0.7.4 → 0.7.5
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 +7 -0
- package/dist/index.js +1 -1
- package/dist/{launcher-RETYUGSR.js → launcher-DIZ4MQRF.js} +2 -2
- package/dist/serve.js +383 -77
- package/package.json +1 -1
- package/studio-dist/assets/index-Bpw6p5Pj.js +103 -0
- package/studio-dist/assets/{index-B6onUYQF.css → index-C4vkwhbc.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-BxcB2_fl.js +0 -103
package/dist/serve.js
CHANGED
|
@@ -212,11 +212,13 @@ function widenNodeStatusCheck(db) {
|
|
|
212
212
|
overlays TEXT NOT NULL DEFAULT '{}',
|
|
213
213
|
brief TEXT,
|
|
214
214
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
215
|
-
duration_ms INTEGER
|
|
215
|
+
duration_ms INTEGER,
|
|
216
|
+
batch_id TEXT,
|
|
217
|
+
batch_index INTEGER NOT NULL DEFAULT 0
|
|
216
218
|
);
|
|
217
219
|
INSERT INTO nodes_new
|
|
218
220
|
SELECT id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept, error,
|
|
219
|
-
created_at, overlays, brief, archived, duration_ms
|
|
221
|
+
created_at, overlays, brief, archived, duration_ms, batch_id, batch_index
|
|
220
222
|
FROM nodes;
|
|
221
223
|
DROP TABLE nodes;
|
|
222
224
|
ALTER TABLE nodes_new RENAME TO nodes;
|
|
@@ -295,11 +297,104 @@ function collapseProjects(db) {
|
|
|
295
297
|
})();
|
|
296
298
|
}
|
|
297
299
|
}
|
|
298
|
-
|
|
300
|
+
function splitMultiImageNodes(db) {
|
|
301
|
+
const rows = db.prepare("SELECT * FROM nodes").all();
|
|
302
|
+
const multi = rows.filter((r) => {
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(r.images).length > 1;
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
if (!multi.length) return;
|
|
310
|
+
const parse2 = (s) => {
|
|
311
|
+
if (!s) return null;
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(s);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const stampOf = (iso, minusMs) => {
|
|
319
|
+
const t = (/* @__PURE__ */ new Date(`${iso.replace(" ", "T")}Z`)).getTime() - minusMs;
|
|
320
|
+
const d = new Date(t);
|
|
321
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
322
|
+
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
|
|
323
|
+
};
|
|
324
|
+
const updateOriginal = db.prepare(
|
|
325
|
+
"UPDATE nodes SET images=?, overlays=?, brief=?, batch_id=?, batch_index=0 WHERE id=?"
|
|
326
|
+
);
|
|
327
|
+
const insertSibling = db.prepare(
|
|
328
|
+
`INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept,
|
|
329
|
+
error, created_at, overlays, brief, archived, duration_ms, batch_id, batch_index)
|
|
330
|
+
VALUES (?,?,?,?,?,?,?,?,0,?,?,?,?,?,?,NULL,?,?)`
|
|
331
|
+
);
|
|
332
|
+
const childrenOf = db.prepare("SELECT id, brief FROM nodes WHERE parent_id=?");
|
|
333
|
+
const repoint = db.prepare("UPDATE nodes SET parent_id=? WHERE id=?");
|
|
334
|
+
const setsOf = db.prepare("SELECT set_id FROM set_nodes WHERE node_id=?");
|
|
335
|
+
const addMember = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
|
|
336
|
+
db.transaction(() => {
|
|
337
|
+
for (const r of multi) {
|
|
338
|
+
const images = JSON.parse(r.images);
|
|
339
|
+
const overlays = parse2(r.overlays) ?? {};
|
|
340
|
+
const brief = parse2(r.brief);
|
|
341
|
+
const sizes = Array.isArray(brief?.rendered?.sizes) ? brief.rendered.sizes : null;
|
|
342
|
+
const briefFor = (i) => {
|
|
343
|
+
if (!brief) return null;
|
|
344
|
+
const b = { ...brief, variants: images.length };
|
|
345
|
+
if (brief.rendered) {
|
|
346
|
+
const { requested: _req, variantIndexes: _vi, ...rendered } = brief.rendered;
|
|
347
|
+
b.rendered = { ...rendered, ...sizes ? { sizes: sizes[i] !== void 0 ? [sizes[i]] : [] } : {} };
|
|
348
|
+
}
|
|
349
|
+
return JSON.stringify(b);
|
|
350
|
+
};
|
|
351
|
+
const siblingIds = [r.id];
|
|
352
|
+
updateOriginal.run(
|
|
353
|
+
JSON.stringify([images[0]]),
|
|
354
|
+
JSON.stringify(overlays["0"] !== void 0 ? { "0": overlays["0"] } : {}),
|
|
355
|
+
briefFor(0),
|
|
356
|
+
r.id,
|
|
357
|
+
r.id
|
|
358
|
+
);
|
|
359
|
+
for (let i = 1; i < images.length; i++) {
|
|
360
|
+
const id = randomUUID();
|
|
361
|
+
siblingIds.push(id);
|
|
362
|
+
insertSibling.run(
|
|
363
|
+
id,
|
|
364
|
+
r.project_id,
|
|
365
|
+
r.parent_id,
|
|
366
|
+
r.kind,
|
|
367
|
+
r.prompt,
|
|
368
|
+
r.engine_id,
|
|
369
|
+
r.status,
|
|
370
|
+
JSON.stringify([images[i]]),
|
|
371
|
+
r.kept,
|
|
372
|
+
r.error,
|
|
373
|
+
stampOf(r.created_at, i),
|
|
374
|
+
JSON.stringify(overlays[String(i)] !== void 0 ? { "0": overlays[String(i)] } : {}),
|
|
375
|
+
briefFor(i),
|
|
376
|
+
r.archived,
|
|
377
|
+
r.id,
|
|
378
|
+
i
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
for (const child of childrenOf.all(r.id)) {
|
|
382
|
+
const src = parse2(child.brief)?.sourceImage;
|
|
383
|
+
if (typeof src !== "string") continue;
|
|
384
|
+
const at = images.indexOf(src);
|
|
385
|
+
if (at > 0) repoint.run(siblingIds[at], child.id);
|
|
386
|
+
}
|
|
387
|
+
for (const s of setsOf.all(r.id)) {
|
|
388
|
+
for (let i = 1; i < siblingIds.length; i++) addMember.run(s.set_id, siblingIds[i]);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
})();
|
|
392
|
+
}
|
|
393
|
+
var SCHEMA_VERSION = 2;
|
|
299
394
|
var SchemaTooNewError = class extends Error {
|
|
300
|
-
constructor(found, supported) {
|
|
395
|
+
constructor(found, supported, backupsDir) {
|
|
301
396
|
super(
|
|
302
|
-
`This library was written by a newer Scenri (schema ${found}; this build understands ${supported}). Update and retry: npx scenri@latest`
|
|
397
|
+
`This library was written by a newer Scenri (schema ${found}; this build understands ${supported}). Update and retry: npx scenri@latest` + (backupsDir ? ` (a pre-migration snapshot of the library is kept in ${backupsDir})` : "")
|
|
303
398
|
);
|
|
304
399
|
this.name = "SchemaTooNewError";
|
|
305
400
|
}
|
|
@@ -329,7 +424,7 @@ function openDb(homeDir) {
|
|
|
329
424
|
const found = db.pragma("user_version", { simple: true });
|
|
330
425
|
if (found > SCHEMA_VERSION) {
|
|
331
426
|
db.close();
|
|
332
|
-
throw new SchemaTooNewError(found, SCHEMA_VERSION);
|
|
427
|
+
throw new SchemaTooNewError(found, SCHEMA_VERSION, join(homeDir, "backups"));
|
|
333
428
|
}
|
|
334
429
|
if (preExisting && found < SCHEMA_VERSION) backupBeforeMigration(db, homeDir, found);
|
|
335
430
|
db.exec(MIGRATIONS);
|
|
@@ -346,6 +441,12 @@ function openDb(homeDir) {
|
|
|
346
441
|
if (!nodeCols.includes("duration_ms")) {
|
|
347
442
|
db.exec("ALTER TABLE nodes ADD COLUMN duration_ms INTEGER");
|
|
348
443
|
}
|
|
444
|
+
if (!nodeCols.includes("batch_id")) {
|
|
445
|
+
db.exec("ALTER TABLE nodes ADD COLUMN batch_id TEXT");
|
|
446
|
+
}
|
|
447
|
+
if (!nodeCols.includes("batch_index")) {
|
|
448
|
+
db.exec("ALTER TABLE nodes ADD COLUMN batch_index INTEGER NOT NULL DEFAULT 0");
|
|
449
|
+
}
|
|
349
450
|
const projectCols = db.pragma("table_info(projects)").map((c) => c.name);
|
|
350
451
|
if (!projectCols.includes("slug")) {
|
|
351
452
|
db.exec("ALTER TABLE projects ADD COLUMN slug TEXT");
|
|
@@ -362,6 +463,7 @@ function openDb(homeDir) {
|
|
|
362
463
|
widenNodeStatusCheck(db);
|
|
363
464
|
backfillSlugs(db);
|
|
364
465
|
collapseProjects(db);
|
|
466
|
+
splitMultiImageNodes(db);
|
|
365
467
|
db.prepare(
|
|
366
468
|
"UPDATE nodes SET status='error', error='interrupted: server restarted mid-generation' WHERE status='running'"
|
|
367
469
|
).run();
|
|
@@ -482,9 +584,21 @@ function rowToNode(r) {
|
|
|
482
584
|
createdAt: r.created_at,
|
|
483
585
|
overlays: JSON.parse(r.overlays ?? "{}"),
|
|
484
586
|
brief: r.brief ? JSON.parse(r.brief) : null,
|
|
485
|
-
archived: !!r.archived
|
|
587
|
+
archived: !!r.archived,
|
|
588
|
+
batchId: r.batch_id ?? null,
|
|
589
|
+
batchIndex: r.batch_index ?? 0
|
|
486
590
|
};
|
|
487
591
|
}
|
|
592
|
+
var lastBatchStamp = 0;
|
|
593
|
+
function batchStamps(count) {
|
|
594
|
+
const base = Math.max(Date.now(), lastBatchStamp + count);
|
|
595
|
+
lastBatchStamp = base;
|
|
596
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
597
|
+
return Array.from({ length: count }, (_, i) => {
|
|
598
|
+
const d = new Date(base - i);
|
|
599
|
+
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
488
602
|
function createStore(db) {
|
|
489
603
|
return {
|
|
490
604
|
// brands
|
|
@@ -648,6 +762,42 @@ function createStore(db) {
|
|
|
648
762
|
).run(id, input.projectId, input.parentId, input.kind, input.prompt, input.engineId);
|
|
649
763
|
return this.getNode(id);
|
|
650
764
|
},
|
|
765
|
+
/**
|
|
766
|
+
* One multi-shot request, N first-class sibling nodes, one transaction.
|
|
767
|
+
* Slot 0 gets the newest stamp (see batchStamps) so the newest-first feed
|
|
768
|
+
* reads the batch in request order; batch_id is the first node's id, held
|
|
769
|
+
* by every sibling including the first, and stays null for a single send
|
|
770
|
+
* — one shot is not a batch.
|
|
771
|
+
*/
|
|
772
|
+
addNodes(input) {
|
|
773
|
+
if (input.parentId) {
|
|
774
|
+
const parent = this.getNode(input.parentId);
|
|
775
|
+
if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
|
|
776
|
+
}
|
|
777
|
+
const count = Math.max(1, Math.floor(input.count));
|
|
778
|
+
const ids = Array.from({ length: count }, () => randomUUID());
|
|
779
|
+
const stamps = batchStamps(count);
|
|
780
|
+
const batchId = count > 1 ? ids[0] : null;
|
|
781
|
+
const insert = db.prepare(
|
|
782
|
+
"INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at, batch_id, batch_index) VALUES (?,?,?,?,?,?,?,?,?)"
|
|
783
|
+
);
|
|
784
|
+
db.transaction(() => {
|
|
785
|
+
for (let i = 0; i < count; i++) {
|
|
786
|
+
insert.run(
|
|
787
|
+
ids[i],
|
|
788
|
+
input.projectId,
|
|
789
|
+
input.parentId,
|
|
790
|
+
input.kind,
|
|
791
|
+
input.prompt,
|
|
792
|
+
input.engineId,
|
|
793
|
+
stamps[i],
|
|
794
|
+
batchId,
|
|
795
|
+
i
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
})();
|
|
799
|
+
return ids.map((id) => this.getNode(id));
|
|
800
|
+
},
|
|
651
801
|
completeNode(id, result) {
|
|
652
802
|
db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=?, duration_ms=? WHERE id=?").run(
|
|
653
803
|
JSON.stringify(result.images),
|
|
@@ -1454,7 +1604,17 @@ function createOpenRouterEngine(opts) {
|
|
|
1454
1604
|
localOnly: false,
|
|
1455
1605
|
supportsEdit: true,
|
|
1456
1606
|
supportsMask: false,
|
|
1607
|
+
// Four is OUR conservative constant, not a provider fact: OpenRouter
|
|
1608
|
+
// multiplexes many image models and their input limits differ, so
|
|
1609
|
+
// there is no single upstream number to cite the way codex's five is
|
|
1610
|
+
// cited. Four keeps a full identity payload (product essential +
|
|
1611
|
+
// angle, presenter, mark) inside every model we have routed to.
|
|
1457
1612
|
maxReferenceImages: 4,
|
|
1613
|
+
// Same uplink argument as codex: the provider reads references at
|
|
1614
|
+
// reduced resolution anyway, and these ride base64-inlined inside a
|
|
1615
|
+
// JSON body — a full-resolution phone photo is tens of megabytes of
|
|
1616
|
+
// request for nothing.
|
|
1617
|
+
maxReferenceEdge: 2048,
|
|
1458
1618
|
// N sequential calls, one image each: the server budgets the node by
|
|
1459
1619
|
// that shape instead of handing the whole run one flat ten minutes.
|
|
1460
1620
|
perImageTimeoutMs: PER_IMAGE_TIMEOUT_MS,
|
|
@@ -3242,9 +3402,9 @@ var ROLE_PRIORITY = {
|
|
|
3242
3402
|
product: 0,
|
|
3243
3403
|
character: 1,
|
|
3244
3404
|
brand: 2,
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3405
|
+
reference: 3,
|
|
3406
|
+
scene: 4,
|
|
3407
|
+
composition: 5,
|
|
3248
3408
|
style: 6
|
|
3249
3409
|
};
|
|
3250
3410
|
function allocateAttachments(attachments, cap2) {
|
|
@@ -3594,6 +3754,7 @@ function validateBrief(brief) {
|
|
|
3594
3754
|
function compileBrief(brief, ctx) {
|
|
3595
3755
|
const warnings = [];
|
|
3596
3756
|
const attachments = [];
|
|
3757
|
+
const unattachable = [];
|
|
3597
3758
|
const rawSceneFallback = [];
|
|
3598
3759
|
const productDirectives = [];
|
|
3599
3760
|
const personDirectives = [];
|
|
@@ -3645,7 +3806,7 @@ function compileBrief(brief, ctx) {
|
|
|
3645
3806
|
...angle ? { angle } : {}
|
|
3646
3807
|
});
|
|
3647
3808
|
});
|
|
3648
|
-
productDirectives.push(
|
|
3809
|
+
productDirectives.push({ need: "fidelity", id: p.id });
|
|
3649
3810
|
productDirectives.push(...productFactDirectives(p));
|
|
3650
3811
|
if (p.description && !p.dimensions)
|
|
3651
3812
|
productDirectives.push(
|
|
@@ -3653,6 +3814,7 @@ function compileBrief(brief, ctx) {
|
|
|
3653
3814
|
);
|
|
3654
3815
|
} else {
|
|
3655
3816
|
warnings.push(`${p.name} has no usable photo, so it is named but not attached.`);
|
|
3817
|
+
unattachable.push({ role: "product", id: p.id, label: p.name, hash: "", essential: true, reason: "missing" });
|
|
3656
3818
|
}
|
|
3657
3819
|
break;
|
|
3658
3820
|
}
|
|
@@ -3694,6 +3856,14 @@ function compileBrief(brief, ctx) {
|
|
|
3694
3856
|
if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
|
|
3695
3857
|
} else {
|
|
3696
3858
|
warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
|
|
3859
|
+
unattachable.push({
|
|
3860
|
+
role: "character",
|
|
3861
|
+
id: c.id,
|
|
3862
|
+
label: c.name,
|
|
3863
|
+
hash: "",
|
|
3864
|
+
essential: true,
|
|
3865
|
+
reason: "missing"
|
|
3866
|
+
});
|
|
3697
3867
|
}
|
|
3698
3868
|
break;
|
|
3699
3869
|
}
|
|
@@ -3713,7 +3883,12 @@ function compileBrief(brief, ctx) {
|
|
|
3713
3883
|
break;
|
|
3714
3884
|
}
|
|
3715
3885
|
attachments.push({ role: "reference", label: "Reference shot", hash: tok.imageHash });
|
|
3716
|
-
otherDirectives.push(
|
|
3886
|
+
otherDirectives.push({
|
|
3887
|
+
need: "attachment",
|
|
3888
|
+
role: "reference",
|
|
3889
|
+
hash: tok.imageHash,
|
|
3890
|
+
text: "Match the composition, lighting and treatment of the attached reference."
|
|
3891
|
+
});
|
|
3717
3892
|
break;
|
|
3718
3893
|
}
|
|
3719
3894
|
case "mark": {
|
|
@@ -3733,9 +3908,12 @@ function compileBrief(brief, ctx) {
|
|
|
3733
3908
|
);
|
|
3734
3909
|
} catch {
|
|
3735
3910
|
}
|
|
3736
|
-
otherDirectives.push(
|
|
3737
|
-
"
|
|
3738
|
-
|
|
3911
|
+
otherDirectives.push({
|
|
3912
|
+
need: "attachment",
|
|
3913
|
+
role: "brand",
|
|
3914
|
+
hash: tok.imageHash,
|
|
3915
|
+
text: "The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Every character it carries appears intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled. Otherwise take only its colour and treatment from it."
|
|
3916
|
+
});
|
|
3739
3917
|
break;
|
|
3740
3918
|
}
|
|
3741
3919
|
case "template": {
|
|
@@ -3815,8 +3993,32 @@ function compileBrief(brief, ctx) {
|
|
|
3815
3993
|
if (i !== -1) attachments.splice(i, 1);
|
|
3816
3994
|
}
|
|
3817
3995
|
}
|
|
3996
|
+
const identityHashes = /* @__PURE__ */ new Map();
|
|
3997
|
+
for (const a of attachments)
|
|
3998
|
+
if ((a.role === "product" || a.role === "character") && !identityHashes.has(a.hash))
|
|
3999
|
+
identityHashes.set(a.hash, a.label);
|
|
4000
|
+
for (let i = attachments.length - 1; i >= 0; i--) {
|
|
4001
|
+
const a = attachments[i];
|
|
4002
|
+
if (a.role === "reference" && identityHashes.has(a.hash)) {
|
|
4003
|
+
attachments.splice(i, 1);
|
|
4004
|
+
warnings.push(
|
|
4005
|
+
`That reference is the same image as ${identityHashes.get(a.hash)}'s own photo, so it rides once, as the identity.`
|
|
4006
|
+
);
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
3818
4009
|
const max = ctx.engineCaps.maxReferenceImages;
|
|
3819
|
-
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
4010
|
+
const { kept, dropped: budgetDropped } = allocateAttachments(attachments, max);
|
|
4011
|
+
const presentKeys = new Set((ctx.presentAttachments ?? kept).map((a) => `${a.role}:${a.hash}`));
|
|
4012
|
+
const resolveDirective = (d) => {
|
|
4013
|
+
if (typeof d === "string") return d;
|
|
4014
|
+
if (d.need === "fidelity") {
|
|
4015
|
+
const n = attachments.filter(
|
|
4016
|
+
(a) => a.role === "product" && a.id === d.id && presentKeys.has(`product:${a.hash}`)
|
|
4017
|
+
).length;
|
|
4018
|
+
return n > 0 ? productFidelityDirective(n) : null;
|
|
4019
|
+
}
|
|
4020
|
+
return presentKeys.has(`${d.role}:${d.hash}`) ? d.text : null;
|
|
4021
|
+
};
|
|
3820
4022
|
const guard = scene ? sceneGuardDirectives({
|
|
3821
4023
|
hasProduct: !!productId,
|
|
3822
4024
|
hasPerson,
|
|
@@ -3838,8 +4040,9 @@ function compileBrief(brief, ctx) {
|
|
|
3838
4040
|
treatment: scene.figureTreatment,
|
|
3839
4041
|
hasPerson,
|
|
3840
4042
|
// The treatment's fictional-brands rule needs to know a real mark is
|
|
3841
|
-
// deliberately in play
|
|
3842
|
-
|
|
4043
|
+
// deliberately in play - and only one that actually rides counts,
|
|
4044
|
+
// same honesty rule as the photo guard above.
|
|
4045
|
+
hasMark: [...presentKeys].some((k) => k.startsWith("brand:"))
|
|
3843
4046
|
}) : [];
|
|
3844
4047
|
if (hasPerson) personDirectives.push(personSkinDirective());
|
|
3845
4048
|
const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
|
|
@@ -3877,23 +4080,26 @@ function compileBrief(brief, ctx) {
|
|
|
3877
4080
|
...refGuard,
|
|
3878
4081
|
...preservation
|
|
3879
4082
|
];
|
|
3880
|
-
|
|
3881
|
-
if (
|
|
4083
|
+
const spoken = dedupe(allDirectives.map(resolveDirective).filter((s) => s !== null));
|
|
4084
|
+
if (spoken.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${spoken.join(" ")}`;
|
|
4085
|
+
if (budgetDropped.some((d) => d.role !== "scene")) {
|
|
3882
4086
|
const keptLabels = new Set(kept.map((a) => a.label));
|
|
3883
|
-
const names = [...new Set(
|
|
4087
|
+
const names = [...new Set(budgetDropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
|
|
3884
4088
|
(l) => !keptLabels.has(l)
|
|
3885
4089
|
);
|
|
3886
4090
|
if (names.length) {
|
|
3887
4091
|
const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
|
|
3888
4092
|
warnings.push(
|
|
3889
|
-
`${
|
|
4093
|
+
`${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out \u2014 ${ctx.engineCaps.displayName} ${reads}.`
|
|
3890
4094
|
);
|
|
3891
4095
|
}
|
|
3892
4096
|
}
|
|
3893
4097
|
return {
|
|
3894
4098
|
prompt: prompt.trim(),
|
|
3895
4099
|
referenceImages: kept.map((a) => ctx.images.pathFor(a.hash)),
|
|
3896
|
-
|
|
4100
|
+
// The missing-photo identities lead: they are essential, and the refusal
|
|
4101
|
+
// path reads this list. Budget losses carry their reason for the chips.
|
|
4102
|
+
dropped: [...unattachable, ...budgetDropped.map((d) => ({ ...d, reason: "budget" }))],
|
|
3897
4103
|
width,
|
|
3898
4104
|
height,
|
|
3899
4105
|
attachments: kept,
|
|
@@ -8760,6 +8966,25 @@ function registerImageRoutes(app, deps) {
|
|
|
8760
8966
|
|
|
8761
8967
|
// src/release/notes.data.ts
|
|
8762
8968
|
var RELEASES = [
|
|
8969
|
+
{
|
|
8970
|
+
version: "0.7.5",
|
|
8971
|
+
date: "2026-09-01",
|
|
8972
|
+
title: "Every shot is one card.",
|
|
8973
|
+
sections: [
|
|
8974
|
+
{
|
|
8975
|
+
heading: "Create",
|
|
8976
|
+
body: "Every shot is now its own card with its own image. Asking for several shots gives you that many cards, made together and standing on their own, and older multi-image shots split into separate cards the first time this version opens. The shot panel is rebuilt around the brief itself, with inline ingredient chips and versions in a single strip under the picture."
|
|
8977
|
+
},
|
|
8978
|
+
{
|
|
8979
|
+
heading: "Refining",
|
|
8980
|
+
body: "The shot being refined appears as a regular chip in the composer, and the card it points at is marked in the feed. Scenes sit out of the attach panel while a refine is armed, with a note saying why, instead of quietly trading the refine for a new shot."
|
|
8981
|
+
},
|
|
8982
|
+
{
|
|
8983
|
+
heading: "Fixes",
|
|
8984
|
+
body: "Generation requests carry exactly the reference images they claim to carry, and a product or presenter whose photo is missing stops the shot with a clear message instead of running without it. Cards that are still rendering show a simple counter and a cancel button."
|
|
8985
|
+
}
|
|
8986
|
+
]
|
|
8987
|
+
},
|
|
8763
8988
|
{
|
|
8764
8989
|
version: "0.7.4",
|
|
8765
8990
|
date: "2026-08-31",
|
|
@@ -9900,7 +10125,7 @@ function buildServer(opts) {
|
|
|
9900
10125
|
}
|
|
9901
10126
|
}
|
|
9902
10127
|
if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
|
|
9903
|
-
const
|
|
10128
|
+
const compileCtx = {
|
|
9904
10129
|
brand: brandJson,
|
|
9905
10130
|
images: core.images,
|
|
9906
10131
|
engineCaps: uncapped,
|
|
@@ -9918,7 +10143,8 @@ function buildServer(opts) {
|
|
|
9918
10143
|
// Only the explicit op drops the dimension promise: an implicit legacy
|
|
9919
10144
|
// expansion keeps its historical prompt byte for byte.
|
|
9920
10145
|
...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
|
|
9921
|
-
}
|
|
10146
|
+
};
|
|
10147
|
+
const compiled2 = compileBrief(brief, compileCtx);
|
|
9922
10148
|
let inheritedAttachments = [];
|
|
9923
10149
|
let identityWarnings = [];
|
|
9924
10150
|
if (inheritedTokens.length) {
|
|
@@ -9939,27 +10165,22 @@ function buildServer(opts) {
|
|
|
9939
10165
|
}
|
|
9940
10166
|
const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
|
|
9941
10167
|
const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
|
|
10168
|
+
const prompt = compileBrief(brief, { ...compileCtx, presentAttachments: merged.kept }).prompt;
|
|
9942
10169
|
const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
|
|
9943
10170
|
if (inherited.truncated)
|
|
9944
10171
|
warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
|
|
9945
10172
|
if (merged.dropped.length) {
|
|
9946
10173
|
if (engineCaps.maxReferenceImages <= 1) {
|
|
9947
|
-
warnings.push(
|
|
9948
|
-
`${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
|
|
9949
|
-
);
|
|
10174
|
+
warnings.push(`The identity rides on the shot itself \u2014 ${engineCaps.displayName} reads no other images.`);
|
|
9950
10175
|
} else {
|
|
9951
10176
|
const keptLabels = new Set(merged.kept.map((a) => a.label));
|
|
9952
10177
|
const names = [...new Set(merged.dropped.map((d) => d.label))].filter((l) => !keptLabels.has(l));
|
|
9953
10178
|
if (names.length)
|
|
9954
|
-
warnings.push(
|
|
9955
|
-
`${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
|
|
9956
|
-
" and "
|
|
9957
|
-
)} ${names.length === 1 ? "was" : "were"} left out.`
|
|
9958
|
-
);
|
|
10179
|
+
warnings.push(`${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out of this refinement.`);
|
|
9959
10180
|
}
|
|
9960
10181
|
}
|
|
9961
10182
|
return {
|
|
9962
|
-
compiled: compiled2,
|
|
10183
|
+
compiled: { ...compiled2, prompt },
|
|
9963
10184
|
inheritedTokens,
|
|
9964
10185
|
merged,
|
|
9965
10186
|
warnings,
|
|
@@ -9986,7 +10207,9 @@ function buildServer(opts) {
|
|
|
9986
10207
|
return {
|
|
9987
10208
|
...rest2,
|
|
9988
10209
|
attachments: edit.merged.kept,
|
|
9989
|
-
dropped
|
|
10210
|
+
// The own compile runs uncapped, so its dropped list holds exactly the
|
|
10211
|
+
// missing-photo identities; the budget losses live on the merge.
|
|
10212
|
+
dropped: [...edit.compiled.dropped, ...edit.merged.dropped],
|
|
9990
10213
|
warnings: edit.warnings,
|
|
9991
10214
|
referenceCount: edit.merged.kept.length
|
|
9992
10215
|
};
|
|
@@ -10141,11 +10364,11 @@ function buildServer(opts) {
|
|
|
10141
10364
|
}
|
|
10142
10365
|
}
|
|
10143
10366
|
const NODE_TIMEOUT_MS = 6e5;
|
|
10144
|
-
async function runNode(
|
|
10367
|
+
async function runNode(nodeIds, engine, estimate, work, expect, postFor, timeoutMs) {
|
|
10145
10368
|
const engineId = engine?.capabilities().id ?? "local";
|
|
10146
10369
|
reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
|
|
10147
10370
|
const ctrl = new AbortController();
|
|
10148
|
-
runningGenerations.set(
|
|
10371
|
+
for (const id of nodeIds) runningGenerations.set(id, ctrl);
|
|
10149
10372
|
const bound = opts.nodeTimeoutMs ?? timeoutMs ?? NODE_TIMEOUT_MS;
|
|
10150
10373
|
let watchdogFired = false;
|
|
10151
10374
|
const watchdog = setTimeout(() => {
|
|
@@ -10153,43 +10376,68 @@ function buildServer(opts) {
|
|
|
10153
10376
|
ctrl.abort(BUDGET_EXHAUSTED);
|
|
10154
10377
|
}, bound);
|
|
10155
10378
|
const startedAt = Date.now();
|
|
10379
|
+
const settled = /* @__PURE__ */ new Set();
|
|
10156
10380
|
try {
|
|
10157
10381
|
const result = await work(ctrl.signal);
|
|
10158
10382
|
clearTimeout(watchdog);
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
const
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10383
|
+
const images = await normalizePngs(result.images);
|
|
10384
|
+
const raw = result.raw;
|
|
10385
|
+
const bySlot = new Array(nodeIds.length);
|
|
10386
|
+
images.forEach((h, k) => {
|
|
10387
|
+
const slot = raw?.variantIndexes?.[k] ?? k;
|
|
10388
|
+
if (slot < nodeIds.length && bySlot[slot] === void 0) bySlot[slot] = h;
|
|
10389
|
+
});
|
|
10390
|
+
const wall = Date.now() - startedAt;
|
|
10391
|
+
const failures = [...raw?.partialFailures ?? []];
|
|
10392
|
+
for (let slot = 0; slot < nodeIds.length; slot++) {
|
|
10393
|
+
const id = nodeIds[slot];
|
|
10394
|
+
const hash = bySlot[slot];
|
|
10395
|
+
if (hash === void 0) {
|
|
10396
|
+
core.store.failNode(id, failures.shift() ?? "the engine returned no image for this shot");
|
|
10397
|
+
settled.add(id);
|
|
10398
|
+
continue;
|
|
10167
10399
|
}
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
const
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
10176
|
-
|
|
10177
|
-
|
|
10178
|
-
|
|
10179
|
-
|
|
10400
|
+
try {
|
|
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));
|
|
10180
10427
|
}
|
|
10181
|
-
|
|
10428
|
+
settled.add(id);
|
|
10182
10429
|
}
|
|
10183
|
-
core.
|
|
10184
|
-
core.ledger.recordCost(engineId, nodeId, result.costUsd);
|
|
10430
|
+
core.ledger.recordCost(engineId, nodeIds[0], result.costUsd);
|
|
10185
10431
|
} catch (err) {
|
|
10186
|
-
|
|
10187
|
-
|
|
10188
|
-
|
|
10189
|
-
|
|
10432
|
+
for (const id of nodeIds) {
|
|
10433
|
+
if (settled.has(id)) continue;
|
|
10434
|
+
if (watchdogFired) core.store.failNode(id, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
|
|
10435
|
+
else if (ctrl.signal.aborted) core.store.cancelNode(id);
|
|
10436
|
+
else core.store.failNode(id, String(err?.message ?? err));
|
|
10437
|
+
}
|
|
10190
10438
|
} finally {
|
|
10191
10439
|
clearTimeout(watchdog);
|
|
10192
|
-
runningGenerations.delete(
|
|
10440
|
+
for (const id of nodeIds) runningGenerations.delete(id);
|
|
10193
10441
|
const left = (reserved.get(engineId) ?? 0) - estimate;
|
|
10194
10442
|
if (left > 1e-9) reserved.set(engineId, left);
|
|
10195
10443
|
else reserved.delete(engineId);
|
|
@@ -10238,7 +10486,7 @@ function buildServer(opts) {
|
|
|
10238
10486
|
images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
|
|
10239
10487
|
costUsd: 0
|
|
10240
10488
|
});
|
|
10241
|
-
void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
10489
|
+
void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
10242
10490
|
(err) => app.log.error({ err }, "crop run failed")
|
|
10243
10491
|
);
|
|
10244
10492
|
return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
|
|
@@ -10395,6 +10643,14 @@ function buildServer(opts) {
|
|
|
10395
10643
|
const wantedCount = Math.min(Math.max(1, Number(count)), 8);
|
|
10396
10644
|
const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
|
|
10397
10645
|
if (lostIdentity.length) {
|
|
10646
|
+
const missing = lostIdentity.filter((d) => d.reason === "missing");
|
|
10647
|
+
if (missing.length) {
|
|
10648
|
+
const names2 = joinNames(missing.map((d) => d.label));
|
|
10649
|
+
const kindWord2 = missing[0].role === "product" ? "product" : "presenter";
|
|
10650
|
+
return reply.code(400).send({
|
|
10651
|
+
error: `${names2} ${missing.length === 1 ? "has" : "have"} no usable photo, so the result would not be your ${kindWord2}. Re-add ${missing.length === 1 ? "its" : "their"} photo, or remove ${names2} from the brief.`
|
|
10652
|
+
});
|
|
10653
|
+
}
|
|
10398
10654
|
const names = joinNames(lostIdentity.map((d) => d.label));
|
|
10399
10655
|
const kindWord = lostIdentity[0].role === "product" ? "product" : "presenter";
|
|
10400
10656
|
return reply.code(400).send({
|
|
@@ -10405,6 +10661,19 @@ function buildServer(opts) {
|
|
|
10405
10661
|
const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
|
|
10406
10662
|
const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
|
|
10407
10663
|
const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
|
|
10664
|
+
if (process.env.SCENRI_DEBUG) {
|
|
10665
|
+
const sent = {};
|
|
10666
|
+
for (const r of sentRoles) sent[r] = (sent[r] ?? 0) + 1;
|
|
10667
|
+
app.log.info(
|
|
10668
|
+
{
|
|
10669
|
+
engine: engine.capabilities().id,
|
|
10670
|
+
cap: cap2,
|
|
10671
|
+
sent,
|
|
10672
|
+
dropped: (compiled2?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
|
|
10673
|
+
},
|
|
10674
|
+
"reference transport"
|
|
10675
|
+
);
|
|
10676
|
+
}
|
|
10408
10677
|
const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
|
|
10409
10678
|
const variations = variationPlan(wantedCount, {
|
|
10410
10679
|
hasPresenter: sentRoles.includes("character"),
|
|
@@ -10436,6 +10705,20 @@ function buildServer(opts) {
|
|
|
10436
10705
|
const editRefs = mergedEdit ? mergedEdit.kept.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })) : (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] })).slice(0, Math.max(0, engine.capabilities().maxReferenceImages - 1));
|
|
10437
10706
|
const editEdge = engine.capabilities().maxReferenceEdge;
|
|
10438
10707
|
if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
|
|
10708
|
+
if (process.env.SCENRI_DEBUG) {
|
|
10709
|
+
const sent = {};
|
|
10710
|
+
for (const r of editRefs) sent[String(r.role ?? "reference")] = (sent[String(r.role ?? "reference")] ?? 0) + 1;
|
|
10711
|
+
app.log.info(
|
|
10712
|
+
{
|
|
10713
|
+
engine: engine.capabilities().id,
|
|
10714
|
+
cap: Math.max(0, engine.capabilities().maxReferenceImages - 1),
|
|
10715
|
+
sourceFrame: true,
|
|
10716
|
+
sent,
|
|
10717
|
+
dropped: (mergedEdit?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
|
|
10718
|
+
},
|
|
10719
|
+
"reference transport"
|
|
10720
|
+
);
|
|
10721
|
+
}
|
|
10439
10722
|
const srcBuf = core.images.read(String(srcHash));
|
|
10440
10723
|
const srcMeta = await sharp20(srcBuf).metadata();
|
|
10441
10724
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
@@ -10603,15 +10886,26 @@ function buildServer(opts) {
|
|
|
10603
10886
|
}
|
|
10604
10887
|
const billedId = runEngine.capabilities().id;
|
|
10605
10888
|
core.ledger.assertUnderCap(billedId, estimate + (reserved.get(billedId) ?? 0));
|
|
10606
|
-
const
|
|
10889
|
+
const nodes = kind === "generation" ? core.store.addNodes({
|
|
10607
10890
|
projectId: project.id,
|
|
10608
10891
|
parentId: resolvedParentId,
|
|
10609
10892
|
kind,
|
|
10610
10893
|
prompt: finalPrompt,
|
|
10611
|
-
engineId: billedId
|
|
10612
|
-
|
|
10613
|
-
|
|
10614
|
-
|
|
10894
|
+
engineId: billedId,
|
|
10895
|
+
// the same clamp the engine request and the watchdog use
|
|
10896
|
+
count: Math.min(Math.max(1, Number(count)), 8)
|
|
10897
|
+
}) : [
|
|
10898
|
+
core.store.addNode({
|
|
10899
|
+
projectId: project.id,
|
|
10900
|
+
parentId: resolvedParentId,
|
|
10901
|
+
kind,
|
|
10902
|
+
prompt: finalPrompt,
|
|
10903
|
+
engineId: billedId
|
|
10904
|
+
})
|
|
10905
|
+
];
|
|
10906
|
+
const node = nodes[0];
|
|
10907
|
+
for (const sibling of brief ? nodes : [])
|
|
10908
|
+
core.store.setBrief(sibling.id, {
|
|
10615
10909
|
...briefInputsOnly(brief),
|
|
10616
10910
|
...editedFrom ? { sourceImage: editedFrom } : {},
|
|
10617
10911
|
...kind === "edit" && reshape ? { reshape } : {},
|
|
@@ -10758,14 +11052,23 @@ function buildServer(opts) {
|
|
|
10758
11052
|
staged = await conformToCanvas(node.id, expectShape)(staged);
|
|
10759
11053
|
}
|
|
10760
11054
|
return enforceEditCanvas(staged);
|
|
10761
|
-
} :
|
|
11055
|
+
} : void 0;
|
|
11056
|
+
const genW = compiled2?.width;
|
|
11057
|
+
const genH = compiled2?.height;
|
|
11058
|
+
const postFor = post !== void 0 ? () => post : kind === "generation" && genW && genH ? (id) => conformToCanvas(id, { width: genW, height: genH }) : void 0;
|
|
10762
11059
|
const runCaps = runEngine.capabilities();
|
|
10763
11060
|
const nodeBudgetMs = kind === "generation" && runCaps.perImageTimeoutMs ? Math.ceil(Math.min(Math.max(1, Number(count)), 8) / Math.max(1, runCaps.imageConcurrency ?? 1)) * runCaps.perImageTimeoutMs + 6e4 : void 0;
|
|
10764
|
-
void runNode(
|
|
10765
|
-
(
|
|
10766
|
-
|
|
11061
|
+
void runNode(
|
|
11062
|
+
nodes.map((n) => n.id),
|
|
11063
|
+
runEngine,
|
|
11064
|
+
estimate,
|
|
11065
|
+
work,
|
|
11066
|
+
expectShape,
|
|
11067
|
+
postFor,
|
|
11068
|
+
nodeBudgetMs
|
|
11069
|
+
).catch((err) => app.log.error({ err }, "node run failed"));
|
|
10767
11070
|
const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
|
|
10768
|
-
return reply.status(202).send(allWarnings.length ? {
|
|
11071
|
+
return reply.status(202).send({ ...node, siblings: nodes, ...allWarnings.length ? { warnings: allWarnings } : {} });
|
|
10769
11072
|
});
|
|
10770
11073
|
app.post("/api/nodes/:id/cancel", async (req, reply) => {
|
|
10771
11074
|
const id = req.params.id;
|
|
@@ -10838,7 +11141,10 @@ function buildServer(opts) {
|
|
|
10838
11141
|
runtime,
|
|
10839
11142
|
stageImpl: opts.stageImpl,
|
|
10840
11143
|
exitImpl: opts.exitImpl,
|
|
10841
|
-
|
|
11144
|
+
// one physical run counts once, however many sibling nodes share its
|
|
11145
|
+
// controller — an update gate held open by a 4-shot batch is still held
|
|
11146
|
+
// open by exactly one piece of work
|
|
11147
|
+
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
|
|
10842
11148
|
});
|
|
10843
11149
|
let drained = null;
|
|
10844
11150
|
app.decorate("drain", () => {
|