scenri 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -806,6 +806,16 @@ function createStore(db) {
806
806
  id
807
807
  );
808
808
  },
809
+ /**
810
+ * The run's money, written once it is known. A batch's first sibling used
811
+ * to be charged inside completeNode, at the end of the whole call; a
812
+ * sibling now completes the moment its own image lands, and the cost is
813
+ * only known when the call resolves, so it is written afterwards, onto a
814
+ * node that finished. A failed or running node keeps 0, as before.
815
+ */
816
+ chargeNode(id, costUsd) {
817
+ db.prepare("UPDATE nodes SET cost_usd=? WHERE id=? AND status='done'").run(costUsd, id);
818
+ },
809
819
  failNode(id, error) {
810
820
  db.prepare("UPDATE nodes SET status='error', error=? WHERE id=?").run(error, id);
811
821
  },
@@ -1630,7 +1640,7 @@ function createOpenRouterEngine(opts) {
1630
1640
  const count = "count" in req ? req.count : 1;
1631
1641
  return count * perImageUsd;
1632
1642
  },
1633
- async generate(req, signal) {
1643
+ async generate(req, signal, onImage) {
1634
1644
  const key = requireKey();
1635
1645
  const roles = req.referenceRoles ?? [];
1636
1646
  const refs = req.referenceImages ?? [];
@@ -1666,7 +1676,9 @@ function createOpenRouterEngine(opts) {
1666
1676
  signal
1667
1677
  );
1668
1678
  raws.push(json);
1669
- for (const buf of extractImages(json)) hashes.push(opts.saveImage(buf));
1679
+ const own = extractImages(json).map((buf) => opts.saveImage(buf));
1680
+ hashes.push(...own);
1681
+ if (own[0]) onImage?.(i, own[0]);
1670
1682
  if (typeof json?.usage?.cost === "number") {
1671
1683
  reportedCost += json.usage.cost;
1672
1684
  sawReportedCost = true;
@@ -2863,7 +2875,7 @@ function createCodexEngine(opts) {
2863
2875
  async costEstimate() {
2864
2876
  return 0;
2865
2877
  },
2866
- async generate(req, signal) {
2878
+ async generate(req, signal, onImage) {
2867
2879
  const count = Math.max(1, req.count);
2868
2880
  const refs = req.referenceImages ?? [];
2869
2881
  const roles = req.referenceRoles ?? refs.map(() => "reference");
@@ -2902,6 +2914,7 @@ function createCodexEngine(opts) {
2902
2914
  const i = next++;
2903
2915
  try {
2904
2916
  results[i] = await jobs[i]();
2917
+ if (results[i][0]) onImage?.(i, results[i][0]);
2905
2918
  } catch (err) {
2906
2919
  if (signal?.aborted && signal.reason !== BUDGET_EXHAUSTED) throw err;
2907
2920
  results[i] = [];
@@ -3041,7 +3054,30 @@ function createEngineRegistry(core, extra = []) {
3041
3054
  codexRunner
3042
3055
  };
3043
3056
  }
3044
- function createDemoEngine(saveImage) {
3057
+ function demoOptionsFromEnv(env) {
3058
+ const out = {};
3059
+ const stagger = Number(env.SCENRI_DEMO_STAGGER_MS);
3060
+ if (env.SCENRI_DEMO_STAGGER_MS && Number.isFinite(stagger) && stagger > 0) out.staggerMs = stagger;
3061
+ if (env.SCENRI_DEMO_ORDER === "reverse") out.order = "reverse";
3062
+ const fail = Number(env.SCENRI_DEMO_FAIL_SLOT);
3063
+ if (env.SCENRI_DEMO_FAIL_SLOT && Number.isInteger(fail) && fail >= 0) out.failSlot = fail;
3064
+ return out;
3065
+ }
3066
+ function sleep2(ms, signal) {
3067
+ return new Promise((resolve) => {
3068
+ if (signal?.aborted) return resolve();
3069
+ const onAbort = () => {
3070
+ clearTimeout(timer);
3071
+ resolve();
3072
+ };
3073
+ const timer = setTimeout(() => {
3074
+ signal?.removeEventListener("abort", onAbort);
3075
+ resolve();
3076
+ }, ms);
3077
+ signal?.addEventListener("abort", onAbort, { once: true });
3078
+ });
3079
+ }
3080
+ function createDemoEngine(saveImage, opts = {}) {
3045
3081
  const paletteOf = (req) => {
3046
3082
  const p = req.brand?.brand?.palette;
3047
3083
  const hexes = [p?.primary?.hex, p?.secondary?.hex, ...(p?.accent ?? []).map((a) => a?.hex)].filter(
@@ -3087,13 +3123,31 @@ function createDemoEngine(saveImage) {
3087
3123
  async costEstimate() {
3088
3124
  return 0;
3089
3125
  },
3090
- async generate(req) {
3126
+ async generate(req, signal, onImage) {
3091
3127
  const colors = paletteOf(req);
3092
- const images = [];
3093
- for (let i = 0; i < Math.max(1, req.count); i++) {
3094
- images.push(saveImage(await render(colors, req.prompt, req.width, req.height, i + req.prompt.length)));
3095
- }
3096
- return { images, costUsd: 0 };
3128
+ const count = Math.max(1, req.count);
3129
+ const slots = Array.from({ length: count }, (_, i) => i);
3130
+ if (opts.order === "reverse") slots.reverse();
3131
+ const landed = /* @__PURE__ */ new Map();
3132
+ const failures = [];
3133
+ for (const [position, slot] of slots.entries()) {
3134
+ if (position > 0 && opts.staggerMs) await sleep2(opts.staggerMs, signal);
3135
+ if (signal?.aborted) {
3136
+ if (signal.reason === BUDGET_EXHAUSTED) break;
3137
+ throw Object.assign(new Error("generation cancelled"), { name: "AbortError" });
3138
+ }
3139
+ if (slot === opts.failSlot) {
3140
+ failures.push(`demo: slot ${slot + 1} refused`);
3141
+ continue;
3142
+ }
3143
+ const hash = saveImage(await render(colors, req.prompt, req.width, req.height, slot + req.prompt.length));
3144
+ landed.set(slot, hash);
3145
+ onImage?.(slot, hash);
3146
+ }
3147
+ const done = [...landed.keys()].sort((a, b) => a - b);
3148
+ const images = done.map((slot) => landed.get(slot));
3149
+ if (done.length === count) return { images, costUsd: 0 };
3150
+ return { images, costUsd: 0, raw: { requested: count, variantIndexes: done, partialFailures: failures } };
3097
3151
  },
3098
3152
  async edit(req) {
3099
3153
  const colors = paletteOf(req);
@@ -3398,6 +3452,15 @@ function defaultDemoProductsDir() {
3398
3452
  }
3399
3453
 
3400
3454
  // src/attachmentBudget.ts
3455
+ var SEAT_TIER = {
3456
+ brand: 0,
3457
+ reference: 0,
3458
+ product: 0,
3459
+ character: 0,
3460
+ scene: 0,
3461
+ composition: 1,
3462
+ style: 2
3463
+ };
3401
3464
  var ROLE_PRIORITY = {
3402
3465
  product: 0,
3403
3466
  character: 1,
@@ -3420,11 +3483,8 @@ function allocateAttachments(attachments, cap2) {
3420
3483
  kept.add(x.i);
3421
3484
  keptGroups.add(groupOf(x.a));
3422
3485
  };
3423
- for (const x of legacyOrder) {
3424
- if (kept.size >= max) break;
3425
- if (x.a.essential) admit(x);
3426
- }
3427
- for (const x of legacyOrder) {
3486
+ const seatOrder = [...indexed].sort((x, y) => SEAT_TIER[x.a.role] - SEAT_TIER[y.a.role] || x.i - y.i);
3487
+ for (const x of seatOrder) {
3428
3488
  if (kept.size >= max) break;
3429
3489
  if (!kept.has(x.i) && !keptGroups.has(groupOf(x.a))) admit(x);
3430
3490
  }
@@ -3449,7 +3509,13 @@ function allocateAttachments(attachments, cap2) {
3449
3509
  }
3450
3510
  return {
3451
3511
  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)
3512
+ dropped: legacyOrder.filter((x) => !kept.has(x.i)).map((x) => x.a),
3513
+ // The same images in the order the brief placed them, for a caller that
3514
+ // will allocate again: `kept` is re-sorted by role for the consumers
3515
+ // below, and feeding that back into a second, tighter allocation put
3516
+ // every product ahead of every face on a refinement whatever the line
3517
+ // said.
3518
+ seated: seatOrder.filter((x) => kept.has(x.i)).map((x) => x.a)
3453
3519
  };
3454
3520
  }
3455
3521
  function mergeEditAttachments(own, inherited, cap2) {
@@ -3614,12 +3680,19 @@ function shotSpecifiesCamera(text) {
3614
3680
  text
3615
3681
  );
3616
3682
  }
3683
+ function namesAreNotLetteringDirective() {
3684
+ 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.";
3685
+ }
3617
3686
  function sceneFigureDirectives(opts) {
3618
3687
  const figure = opts.figure.trim().replace(/[.\s]+$/, "");
3619
3688
  if (!figure) return [];
3620
3689
  const treatment = (opts.treatment ?? "").trim().replace(/[.\s]+$/, "");
3621
3690
  const out = [];
3622
- if (opts.hasPerson) {
3691
+ if (opts.hasPerson && (opts.people ?? 1) > 1) {
3692
+ out.push(
3693
+ `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.`
3694
+ );
3695
+ } else if (opts.hasPerson) {
3623
3696
  out.push(
3624
3697
  `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
3698
  );
@@ -3740,6 +3813,8 @@ function validateBrief(brief) {
3740
3813
  case "ref":
3741
3814
  case "mark":
3742
3815
  if (!str4(t.imageHash)) errors.push(`${at}.imageHash must be a non-empty string`);
3816
+ if (t.t === "ref" && t.label !== void 0 && typeof t.label !== "string")
3817
+ errors.push(`${at}.label must be a string when present`);
3743
3818
  break;
3744
3819
  case "format":
3745
3820
  if (!Number.isFinite(t.w) || !Number.isFinite(t.h) || Number(t.w) <= 0 || Number(t.h) <= 0)
@@ -3766,6 +3841,7 @@ function compileBrief(brief, ctx) {
3766
3841
  const characters = ctx.brand?.characters ?? [];
3767
3842
  const inlineTemplates = [];
3768
3843
  let hasPerson = false;
3844
+ let people = 0;
3769
3845
  let sentence = "";
3770
3846
  const append = (s) => {
3771
3847
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
@@ -3825,6 +3901,7 @@ function compileBrief(brief, ctx) {
3825
3901
  break;
3826
3902
  }
3827
3903
  hasPerson = true;
3904
+ people += 1;
3828
3905
  append(c.promptName ?? c.name);
3829
3906
  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
3907
  if (cshots.length) {
@@ -3869,8 +3946,10 @@ function compileBrief(brief, ctx) {
3869
3946
  }
3870
3947
  case "color": {
3871
3948
  const hex = tok.hex.toUpperCase();
3872
- append(tok.name ? `${tok.name} (${hex})` : hex);
3873
- otherDirectives.push(`Use ${hex} as a defining color in the composition.`);
3949
+ append(tok.name ? `(brand color ${tok.name} ${hex})` : `(brand color ${hex})`);
3950
+ otherDirectives.push(
3951
+ `Use ${hex} as a defining color in the composition, in surfaces, materials and light, never as lettering.`
3952
+ );
3874
3953
  break;
3875
3954
  }
3876
3955
  case "ref": {
@@ -4007,7 +4086,7 @@ function compileBrief(brief, ctx) {
4007
4086
  }
4008
4087
  }
4009
4088
  const max = ctx.engineCaps.maxReferenceImages;
4010
- const { kept, dropped: budgetDropped } = allocateAttachments(attachments, max);
4089
+ const { kept, dropped: budgetDropped, seated } = allocateAttachments(attachments, max);
4011
4090
  const presentKeys = new Set((ctx.presentAttachments ?? kept).map((a) => `${a.role}:${a.hash}`));
4012
4091
  const resolveDirective = (d) => {
4013
4092
  if (typeof d === "string") return d;
@@ -4019,6 +4098,24 @@ function compileBrief(brief, ctx) {
4019
4098
  }
4020
4099
  return presentKeys.has(`${d.role}:${d.hash}`) ? d.text : null;
4021
4100
  };
4101
+ const absentDirectives = [];
4102
+ const absentSeen = /* @__PURE__ */ new Set();
4103
+ for (const a of attachments) {
4104
+ const key = `${a.role}:${a.hash}`;
4105
+ if (presentKeys.has(key) || absentSeen.has(key)) continue;
4106
+ if (a.role === "reference") {
4107
+ absentSeen.add(key);
4108
+ const words = ctx.wordsFor?.(a.hash) ?? null;
4109
+ absentDirectives.push(
4110
+ 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."
4111
+ );
4112
+ } else if (a.role === "brand") {
4113
+ absentSeen.add(key);
4114
+ absentDirectives.push(
4115
+ `The brand mark "${a.label}" was not attached this time; keep every branded surface plain and do not invent a logo.`
4116
+ );
4117
+ }
4118
+ }
4022
4119
  const guard = scene ? sceneGuardDirectives({
4023
4120
  hasProduct: !!productId,
4024
4121
  hasPerson,
@@ -4035,10 +4132,12 @@ function compileBrief(brief, ctx) {
4035
4132
  "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
4133
  productHandlingDirective()
4037
4134
  ] : [];
4135
+ const nameDirectives = productId || hasPerson ? [namesAreNotLetteringDirective()] : [];
4038
4136
  const figureDirectives = scene?.figure ? sceneFigureDirectives({
4039
4137
  figure: scene.figure,
4040
4138
  treatment: scene.figureTreatment,
4041
4139
  hasPerson,
4140
+ people,
4042
4141
  // The treatment's fictional-brands rule needs to know a real mark is
4043
4142
  // deliberately in play - and only one that actually rides counts,
4044
4143
  // same honesty rule as the photo guard above.
@@ -4067,12 +4166,16 @@ function compileBrief(brief, ctx) {
4067
4166
  }) ? [garmentDisplayDirective()] : [];
4068
4167
  const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
4069
4168
  const allDirectives = [
4169
+ // First, beside the sentence that names things: the model reads the names
4170
+ // and this line in one breath, before any spec repeats them.
4171
+ ...nameDirectives,
4070
4172
  ...productDirectives,
4071
4173
  ...personDirectives,
4072
4174
  ...pairDirectives,
4073
4175
  ...figureDirectives,
4074
4176
  ...closeUpDirectives,
4075
4177
  ...otherDirectives,
4178
+ ...absentDirectives,
4076
4179
  ...cameraDirectives,
4077
4180
  ...apparelUnworn,
4078
4181
  ...brandLines,
@@ -4082,15 +4185,14 @@ function compileBrief(brief, ctx) {
4082
4185
  ];
4083
4186
  const spoken = dedupe(allDirectives.map(resolveDirective).filter((s) => s !== null));
4084
4187
  if (spoken.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${spoken.join(" ")}`;
4085
- if (budgetDropped.some((d) => d.role !== "scene")) {
4188
+ if (max === 0 && budgetDropped.some((d) => d.role !== "scene")) {
4086
4189
  const keptLabels = new Set(kept.map((a) => a.label));
4087
4190
  const names = [...new Set(budgetDropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
4088
4191
  (l) => !keptLabels.has(l)
4089
4192
  );
4090
4193
  if (names.length) {
4091
- const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
4092
4194
  warnings.push(
4093
- `${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out \u2014 ${ctx.engineCaps.displayName} ${reads}.`
4195
+ `${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out \u2014 ${ctx.engineCaps.displayName} reads no reference images.`
4094
4196
  );
4095
4197
  }
4096
4198
  }
@@ -4103,11 +4205,37 @@ function compileBrief(brief, ctx) {
4103
4205
  width,
4104
4206
  height,
4105
4207
  attachments: kept,
4208
+ seated,
4106
4209
  warnings,
4107
4210
  productId
4108
4211
  };
4109
4212
  }
4110
4213
  var dedupe = (xs) => [...new Set(xs)];
4214
+
4215
+ // src/shotWords.ts
4216
+ function shotWords(prompt, max = 160) {
4217
+ if (!prompt) return null;
4218
+ const head = prompt.split(/\. (?=[A-Z])/)[0]?.trim() ?? "";
4219
+ if (!head) return null;
4220
+ const clipped = head.length > max ? `${head.slice(0, max).replace(/\s+\S*$/, "")}\u2026` : head;
4221
+ return clipped.replace(/[.\s]+$/, "");
4222
+ }
4223
+ function shotWordsFor(core, brandId) {
4224
+ let byHash = null;
4225
+ return (hash) => {
4226
+ if (!byHash) {
4227
+ byHash = /* @__PURE__ */ new Map();
4228
+ for (const p of core.store.listProjects(brandId)) {
4229
+ for (const n of core.store.treeFor(p.id)) {
4230
+ const words = shotWords(n.prompt);
4231
+ if (!words) continue;
4232
+ for (const h of n.images ?? []) if (!byHash.has(h)) byHash.set(h, words);
4233
+ }
4234
+ }
4235
+ }
4236
+ return byHash.get(hash) ?? null;
4237
+ };
4238
+ }
4111
4239
  var DEFAULT_CONTENT_URL = "https://github.com/tonygorb/scenri/releases/download/content-latest/scenri-content.zip";
4112
4240
  var TIMEOUT_MS = 10 * 60 * 1e3;
4113
4241
  function resolveContentUrl(env = process.env, override) {
@@ -5034,7 +5162,7 @@ function dedupeProducts(products) {
5034
5162
 
5035
5163
  // ../catalog/src/http/fetch.ts
5036
5164
  var USER_AGENT = "scenri-catalog/0.1 (+https://scenri.co)";
5037
- function sleep2(ms) {
5165
+ function sleep3(ms) {
5038
5166
  return new Promise((r) => setTimeout(r, ms));
5039
5167
  }
5040
5168
  async function httpGet(url, opts = {}) {
@@ -5058,7 +5186,7 @@ async function httpGet(url, opts = {}) {
5058
5186
  }
5059
5187
  });
5060
5188
  if ((res.status === 429 || res.status >= 500) && attempt < retries) {
5061
- await sleep2(400 * 2 ** attempt);
5189
+ await sleep3(400 * 2 ** attempt);
5062
5190
  continue;
5063
5191
  }
5064
5192
  return res;
@@ -5066,7 +5194,7 @@ async function httpGet(url, opts = {}) {
5066
5194
  lastErr = err;
5067
5195
  if (opts.signal?.aborted) throw err;
5068
5196
  if (attempt < retries) {
5069
- await sleep2(400 * 2 ** attempt);
5197
+ await sleep3(400 * 2 ** attempt);
5070
5198
  continue;
5071
5199
  }
5072
5200
  throw err;
@@ -8036,11 +8164,6 @@ function registerLogoRoutes(app, deps) {
8036
8164
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
8037
8165
  return core.store.updateBrand(brand.id, json);
8038
8166
  });
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
8167
  app.delete("/api/brands/:id/logos/:hash", async (req, reply) => {
8045
8168
  const brand = core.store.getBrand(req.params.id);
8046
8169
  if (!brand) return reply.status(404).send({ error: "brand not found" });
@@ -8966,6 +9089,29 @@ function registerImageRoutes(app, deps) {
8966
9089
 
8967
9090
  // src/release/notes.data.ts
8968
9091
  var RELEASES = [
9092
+ {
9093
+ version: "0.8.0",
9094
+ date: "2026-09-02",
9095
+ title: "The composer, rebuilt around what a shot is made of.",
9096
+ sections: [
9097
+ {
9098
+ heading: "Create",
9099
+ 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."
9100
+ },
9101
+ {
9102
+ heading: "Refine",
9103
+ 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."
9104
+ },
9105
+ {
9106
+ heading: "Brand",
9107
+ 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."
9108
+ },
9109
+ {
9110
+ heading: "Fixes",
9111
+ 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."
9112
+ }
9113
+ ]
9114
+ },
8969
9115
  {
8970
9116
  version: "0.7.5",
8971
9117
  date: "2026-09-01",
@@ -10128,6 +10274,7 @@ function buildServer(opts) {
10128
10274
  const compileCtx = {
10129
10275
  brand: brandJson,
10130
10276
  images: core.images,
10277
+ wordsFor: shotWordsFor(core, brandId),
10131
10278
  engineCaps: uncapped,
10132
10279
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10133
10280
  templateById: sceneById,
@@ -10164,25 +10311,19 @@ function buildServer(opts) {
10164
10311
  }).map((a) => ({ ...a, inherited: true }));
10165
10312
  }
10166
10313
  const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
10167
- const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
10314
+ const merged = mergeEditAttachments(compiled2.seated, inheritedAttachments, cap2);
10168
10315
  const prompt = compileBrief(brief, { ...compileCtx, presentAttachments: merged.kept }).prompt;
10169
10316
  const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
10170
10317
  if (inherited.truncated)
10171
10318
  warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
10172
- if (merged.dropped.length) {
10173
- if (engineCaps.maxReferenceImages <= 1) {
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
- }
10319
+ if (merged.dropped.length && engineCaps.maxReferenceImages <= 1)
10320
+ warnings.push(`The identity rides on the shot itself \u2014 ${engineCaps.displayName} reads no other images.`);
10182
10321
  return {
10183
10322
  compiled: { ...compiled2, prompt },
10184
10323
  inheritedTokens,
10185
10324
  merged,
10325
+ /** The seats this refinement had: the engine's, less the source frame. */
10326
+ cap: cap2,
10186
10327
  warnings,
10187
10328
  editScope: verdict.scope,
10188
10329
  editRemoval: verdict.removal ?? false
@@ -10211,7 +10352,11 @@ function buildServer(opts) {
10211
10352
  // missing-photo identities; the budget losses live on the merge.
10212
10353
  dropped: [...edit.compiled.dropped, ...edit.merged.dropped],
10213
10354
  warnings: edit.warnings,
10214
- referenceCount: edit.merged.kept.length
10355
+ referenceCount: edit.merged.kept.length,
10356
+ // How many photo groups this refine can carry in total: the engine's
10357
+ // slots less the one the source frame holds. The composer refuses a
10358
+ // pick past it rather than warning after the fact.
10359
+ cap: edit.cap
10215
10360
  };
10216
10361
  }
10217
10362
  const brandJson = await brandJsonWithIdentityCrops(
@@ -10235,12 +10380,13 @@ function buildServer(opts) {
10235
10380
  const compiled2 = compileBrief(brief, {
10236
10381
  brand: brandJson,
10237
10382
  images: core.images,
10383
+ wordsFor: shotWordsFor(core, brand.id),
10238
10384
  engineCaps: engine.capabilities(),
10239
10385
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10240
10386
  templateById: sceneById
10241
10387
  });
10242
10388
  const { referenceImages, ...rest } = compiled2;
10243
- return { ...rest, referenceCount: referenceImages.length };
10389
+ return { ...rest, referenceCount: referenceImages.length, cap: engine.capabilities().maxReferenceImages };
10244
10390
  });
10245
10391
  registerProjectRoutes(app, { core });
10246
10392
  registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup, codexRunner: engines.codexRunner });
@@ -10377,58 +10523,68 @@ function buildServer(opts) {
10377
10523
  }, bound);
10378
10524
  const startedAt = Date.now();
10379
10525
  const settled = /* @__PURE__ */ new Set();
10526
+ const settleSlot = async (slot, hash) => {
10527
+ const id = nodeIds[slot];
10528
+ try {
10529
+ let own = await normalizePngs([hash]);
10530
+ const post = postFor?.(id);
10531
+ if (post) own = await post(own);
10532
+ if (expect) await assertAspect(own, expect);
10533
+ try {
10534
+ const meta2 = await sharp20(core.images.read(own[0])).metadata();
10535
+ const node = core.store.getNode(id);
10536
+ if (node && meta2.width && meta2.height) {
10537
+ const brief = node.brief ?? {};
10538
+ const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
10539
+ core.store.setBrief(id, { ...brief, rendered: { sizes: [[meta2.width, meta2.height]], ...asked } });
10540
+ if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && meta2.width === expect.width && meta2.height === expect.height)
10541
+ app.log.warn(
10542
+ { nodeId: id },
10543
+ "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
10544
+ );
10545
+ }
10546
+ } catch {
10547
+ }
10548
+ core.store.completeNode(id, { images: own, costUsd: 0, durationMs: Date.now() - startedAt });
10549
+ } catch (err) {
10550
+ core.store.failNode(id, String(err?.message ?? err));
10551
+ } finally {
10552
+ settled.add(id);
10553
+ }
10554
+ };
10555
+ const landing = /* @__PURE__ */ new Map();
10556
+ let accepting = true;
10557
+ const onImage = (slot, hash) => {
10558
+ if (!accepting || !Number.isInteger(slot) || slot < 0 || slot >= nodeIds.length || landing.has(slot)) return;
10559
+ landing.set(slot, settleSlot(slot, hash));
10560
+ };
10380
10561
  try {
10381
- const result = await work(ctrl.signal);
10562
+ const result = await work(ctrl.signal, onImage);
10563
+ accepting = false;
10382
10564
  clearTimeout(watchdog);
10383
- const images = await normalizePngs(result.images);
10565
+ await Promise.allSettled(landing.values());
10384
10566
  const raw = result.raw;
10385
10567
  const bySlot = new Array(nodeIds.length);
10386
- images.forEach((h, k) => {
10568
+ result.images.forEach((h, k) => {
10387
10569
  const slot = raw?.variantIndexes?.[k] ?? k;
10388
10570
  if (slot < nodeIds.length && bySlot[slot] === void 0) bySlot[slot] = h;
10389
10571
  });
10390
- const wall = Date.now() - startedAt;
10391
10572
  const failures = [...raw?.partialFailures ?? []];
10392
10573
  for (let slot = 0; slot < nodeIds.length; slot++) {
10393
- const id = nodeIds[slot];
10574
+ if (landing.has(slot)) continue;
10394
10575
  const hash = bySlot[slot];
10395
10576
  if (hash === void 0) {
10396
- core.store.failNode(id, failures.shift() ?? "the engine returned no image for this shot");
10397
- settled.add(id);
10577
+ core.store.failNode(nodeIds[slot], failures.shift() ?? "the engine returned no image for this shot");
10578
+ settled.add(nodeIds[slot]);
10398
10579
  continue;
10399
10580
  }
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));
10427
- }
10428
- settled.add(id);
10581
+ await settleSlot(slot, hash);
10429
10582
  }
10583
+ core.store.chargeNode(nodeIds[0], result.costUsd);
10430
10584
  core.ledger.recordCost(engineId, nodeIds[0], result.costUsd);
10431
10585
  } catch (err) {
10586
+ accepting = false;
10587
+ await Promise.allSettled(landing.values());
10432
10588
  for (const id of nodeIds) {
10433
10589
  if (settled.has(id)) continue;
10434
10590
  if (watchdogFired) core.store.failNode(id, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
@@ -10580,6 +10736,7 @@ function buildServer(opts) {
10580
10736
  compiled2 = compileBrief(brief, {
10581
10737
  brand: brandJson,
10582
10738
  images: core.images,
10739
+ wordsFor: shotWordsFor(core, project.brandId),
10583
10740
  engineCaps: engine.capabilities(),
10584
10741
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10585
10742
  templateById: sceneById
@@ -10617,6 +10774,7 @@ function buildServer(opts) {
10617
10774
  compiled2 = compileBrief(legacyBrief, {
10618
10775
  brand: brandJson,
10619
10776
  images: core.images,
10777
+ wordsFor: shotWordsFor(core, project.brandId),
10620
10778
  engineCaps: engine.capabilities(),
10621
10779
  templateById: sceneFor(brandJson)
10622
10780
  });
@@ -10641,7 +10799,8 @@ function buildServer(opts) {
10641
10799
  if (kind === "generation") {
10642
10800
  const cap2 = engine.capabilities().maxReferenceImages;
10643
10801
  const wantedCount = Math.min(Math.max(1, Number(count)), 8);
10644
- const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
10802
+ const blind = engine.capabilities().maxReferenceImages === 0;
10803
+ const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential && (d.reason === "missing" || blind));
10645
10804
  if (lostIdentity.length) {
10646
10805
  const missing = lostIdentity.filter((d) => d.reason === "missing");
10647
10806
  if (missing.length) {
@@ -10692,7 +10851,7 @@ function buildServer(opts) {
10692
10851
  ...variations.length ? { variations } : {}
10693
10852
  };
10694
10853
  estimate = await engine.costEstimate(genReq);
10695
- work = (signal) => engine.generate(genReq, signal);
10854
+ work = (signal, onImage) => engine.generate(genReq, signal, onImage);
10696
10855
  expectShape = { width, height };
10697
10856
  } else {
10698
10857
  const parent = core.store.getNode(resolvedParentId);
@@ -10794,8 +10953,9 @@ function buildServer(opts) {
10794
10953
  expectShape = { width: expandPlan.width, height: expandPlan.height };
10795
10954
  }
10796
10955
  const editPixelBudget = runEngine.capabilities().editPixelBudget;
10797
- if (!expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget) {
10798
- sentSize = budgetSize(srcMeta.width, srcMeta.height, editPixelBudget);
10956
+ const stepped = !expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget ? budgetSize(srcMeta.width, srcMeta.height, editPixelBudget) : null;
10957
+ if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
10958
+ sentSize = stepped;
10799
10959
  budgetSourceHash = core.images.save(
10800
10960
  await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
10801
10961
  );
@@ -10930,7 +11090,7 @@ function buildServer(opts) {
10930
11090
  }
10931
11091
  } : {},
10932
11092
  // What the refinement carried, recorded apart from what it asked for:
10933
- // the detail view shows both, and remix reads tokens alone.
11093
+ // the detail view shows both, and reuse setup merges both (mergeCarried).
10934
11094
  ...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
10935
11095
  });
10936
11096
  const plan = expandPlan;
@@ -11213,7 +11373,7 @@ async function serve() {
11213
11373
  }
11214
11374
  async function run() {
11215
11375
  const core = createCore();
11216
- const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b))] : [];
11376
+ const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
11217
11377
  const engines = createEngineRegistry(core, stubs);
11218
11378
  const here = dirname(fileURLToPath(import.meta.url));
11219
11379
  const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "open": "^10.1.0",
29
29
  "pixelmatch": "^7.1.0",
30
30
  "pngjs": "^7.0.0",
31
- "sharp": "^0.35.3"
31
+ "sharp": "^0.35.4"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@scenri/brand": "workspace:*",