scenri 0.6.8 → 0.6.9

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.9](https://github.com/tonygorb/Scenri/compare/v0.6.8...v0.6.9) (2026-08-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * a chip click with text selected still opens the picker ([34ea025](https://github.com/tonygorb/Scenri/commit/34ea0257d59920ed0a3abe93771329d75217162b))
9
+ * a chip click with text selected still opens the picker ([1f85306](https://github.com/tonygorb/Scenri/commit/1f85306ef4f19734208921e7c3167acb8596b89b))
10
+ * a refine target never follows you into a fresh Create ([43d32d5](https://github.com/tonygorb/Scenri/commit/43d32d58c3ab679015d800bdbb5b0c02c0444013))
11
+ * a refine target never follows you into a fresh Create ([7e15714](https://github.com/tonygorb/Scenri/commit/7e157141f820ae86a0281f3b949262797baed96a))
12
+ * a refinement chain carries the whole identity record ([4688f0f](https://github.com/tonygorb/Scenri/commit/4688f0f934294789f8fa9222fdc39becba4f0ce8))
13
+ * a refinement chain carries the whole identity record ([f174480](https://github.com/tonygorb/Scenri/commit/f174480c552347d20e110ad2dfc206f70d800aa9))
14
+ * a refinement edits at the source's real shape and holds its resolution ([ccd9be1](https://github.com/tonygorb/Scenri/commit/ccd9be16b2fd609824b5332335c66cf5cc7c06bb))
15
+ * a refinement edits at the source's real shape and holds its resolution ([d2b56af](https://github.com/tonygorb/Scenri/commit/d2b56affead40c51fe7f57bd073b07e8f1e7088d))
16
+ * **codex:** drop 'flawless' from the generation wrapper ([3190495](https://github.com/tonygorb/Scenri/commit/319049580bc97f07d85ddb9cb298451c247cf339))
17
+ * **codex:** drop 'flawless' from the generation wrapper ([7e07836](https://github.com/tonygorb/Scenri/commit/7e078363a0298972944e3b2f2877b6fcc36b992a))
18
+ * photographic skin, real product scale, real hands ([96b8729](https://github.com/tonygorb/Scenri/commit/96b87290a711f0ab250473e116e728493790e155))
19
+ * photographic skin, real product scale, real hands ([51ff91e](https://github.com/tonygorb/Scenri/commit/51ff91e9a71740ab93301a3a4a76216a4734f916))
20
+ * two portrait scenes stop commanding retouch ([58c6542](https://github.com/tonygorb/Scenri/commit/58c6542aeb1bba3e7ca73366a9ed60ecc788bbf3))
21
+ * two portrait scenes stop commanding retouch ([ca92794](https://github.com/tonygorb/Scenri/commit/ca92794636bc7bbe74454d6c37122a16989ed784))
22
+
3
23
  ## [0.6.8](https://github.com/tonygorb/Scenri/compare/v0.6.7...v0.6.8) (2026-08-29)
4
24
 
5
25
 
package/dist/serve.js CHANGED
@@ -1483,7 +1483,10 @@ function createOpenRouterEngine(opts) {
1483
1483
  ...editRefs.map((p) => ({ type: "image_url", image_url: { url: dataUrl(p) } }))
1484
1484
  ]
1485
1485
  }
1486
- ]
1486
+ ],
1487
+ // Mirrors generate: an edit given no shape answered at whatever ratio
1488
+ // the model felt like, and the drift compounded down refine chains.
1489
+ ...req.width && req.height ? { image_config: { aspect_ratio: aspectRatioFor(req.width, req.height) } } : {}
1487
1490
  };
1488
1491
  const json = await post(key, body, signal);
1489
1492
  const [buf] = extractImages(json);
@@ -2692,7 +2695,14 @@ function createCodexEngine(opts) {
2692
2695
  const refDirectives = roles.map(
2693
2696
  (role, i) => roles.length > 1 ? `Attached image ${i + 1} is ${roleDirective[role]}.` : `The attached image is ${roleDirective[role]}.`
2694
2697
  ).join(" ");
2695
- return `Generate one flawless, professional-grade image immediately using your image generation tool, ${req.width}x${req.height}: ${req.prompt}.` + (refDirectives ? ` ${refDirectives}` : "") + ` Do not browse the web or explore files. Save the image in the current directory as out-1.png (you may run the commands needed to save and resize it). Nothing else.` + (index > 0 ? ` (variant ${index + 1} \u2014 same brief, different composition)` : "");
2698
+ return (
2699
+ // "professional-grade", not "flawless": the audit of the waxy-presenter
2700
+ // report traced part of the plastic, over-perfected rendering to that
2701
+ // one unconditional word. Still "image", never "photograph" - this
2702
+ // wrapper also generates graphic assets. Independently revertible on
2703
+ // render evidence.
2704
+ `Generate one professional-grade image immediately using your image generation tool, ${req.width}x${req.height}: ${req.prompt}.` + (refDirectives ? ` ${refDirectives}` : "") + ` Do not browse the web or explore files. Save the image in the current directory as out-1.png (you may run the commands needed to save and resize it). Nothing else.` + (index > 0 ? ` (variant ${index + 1} \u2014 same brief, different composition)` : "")
2705
+ );
2696
2706
  }
2697
2707
  }
2698
2708
 
@@ -2773,7 +2783,13 @@ function createDemoEngine(saveImage) {
2773
2783
  },
2774
2784
  async edit(req) {
2775
2785
  const colors = paletteOf(req);
2776
- const buf = await render(colors, `edit: ${req.instruction}`, 1024, 1024, req.instruction.length);
2786
+ const buf = await render(
2787
+ colors,
2788
+ `edit: ${req.instruction}`,
2789
+ req.width ?? 1024,
2790
+ req.height ?? 1024,
2791
+ req.instruction.length
2792
+ );
2777
2793
  return { images: [saveImage(buf)], costUsd: 0 };
2778
2794
  }
2779
2795
  };
@@ -2916,6 +2932,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
2916
2932
  name: presenter.name,
2917
2933
  ...presenter.identityNotes ? { identityNotes: presenter.identityNotes } : {},
2918
2934
  ...presenter.negativeConstraints?.length ? { negativeConstraints: presenter.negativeConstraints } : {},
2935
+ ...presenter.skin ? { skin: presenter.skin } : {},
2919
2936
  shots
2920
2937
  };
2921
2938
  }
@@ -3019,7 +3036,14 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
3019
3036
  // brushed steel case read as steel rather than plastic. They were being
3020
3037
  // dropped here, so the compiler never saw them.
3021
3038
  ...product.materials ? { materials: product.materials } : {},
3022
- ...product.primaryColors ? { primaryColors: product.primaryColors } : {}
3039
+ ...product.primaryColors ? { primaryColors: product.primaryColors } : {},
3040
+ // The description is the only text a demo product has that carries scale
3041
+ // ("sized for a facial serum or treatment oil") - dropping it here is why
3042
+ // a cream jar could render the size of a basketball. And category is what
3043
+ // arms the compiler's apparel guard; without it no demo garment ever
3044
+ // earned the "present it as a product" line.
3045
+ ...product.description ? { description: product.description } : {},
3046
+ ...product.category ? { category: product.category } : {}
3023
3047
  };
3024
3048
  }
3025
3049
  async function brandJsonWithResolvedDemoProducts(core, templatesRoot, demoProducts, brandJson, tokens) {
@@ -3127,6 +3151,38 @@ function editPreservationDirective(scope, opts) {
3127
3151
  function inheritedIdentityDirective() {
3128
3152
  return "The extra attached references are the same product and the same person that are already in this picture. Use them to hold that identity exact while you make the change, not as a reason to re-stage the shot.";
3129
3153
  }
3154
+ function productFactDirectives(p) {
3155
+ const out = [];
3156
+ if (p.preservationNotes) out.push(String(p.preservationNotes));
3157
+ if (p.negativeConstraints) out.push(`Avoid: ${p.negativeConstraints}`);
3158
+ const materials = p.materials ?? p.material;
3159
+ if (materials) out.push(`Its materials and finish: ${materials}.`);
3160
+ if (p.primaryColors) out.push(`Its actual colors: ${p.primaryColors}.`);
3161
+ if (p.dimensions)
3162
+ out.push(`Its real-world size is ${p.dimensions} \u2014 keep it at true scale relative to everything else in frame.`);
3163
+ return out;
3164
+ }
3165
+ function characterFactDirectives(c) {
3166
+ const out = [];
3167
+ if (c.identityNotes) out.push(String(c.identityNotes));
3168
+ if (c.negativeConstraints?.length) out.push(`Avoid: ${[].concat(c.negativeConstraints).join(", ")}`);
3169
+ return out;
3170
+ }
3171
+ function productEditFidelityDirective(name) {
3172
+ return `Every attached product reference shows ${name}, the exact product already in this photograph: hold its label, shape, colours and proportions to what the references and the photograph agree on while you make the change, and never treat an extra angle as a second product. Any face of it no reference shows stays exactly as the photograph already has it.`;
3173
+ }
3174
+ function characterEditIdentityDirective(name) {
3175
+ return `${name} is the person in this photograph: keep them present and clearly visible. Match their face, facial structure, skin, hair and build to the attached person reference exactly. The reference's plain outfit and studio backdrop are capture conditions, not direction: keep the styling this photograph already has unless the instruction itself changes it, and never return them to the plain base layers they were photographed in.`;
3176
+ }
3177
+ function markEditDirective() {
3178
+ return "The attached brand mark is this brand's own mark: wherever the logo appears or the instruction asks for it, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions \u2014 never redrawn or re-lettered.";
3179
+ }
3180
+ function personSkinDirective() {
3181
+ return "Every person in this photograph has real photographed skin: fine natural texture at pore scale, faint lines and natural asymmetry left intact, true-to-life proportions, never airbrushed, waxy, plastic or synthetic-looking. Light behaves physically on it: a hard flash or a specular key may genuinely shine on skin, but the surface underneath stays living skin in a photograph, never gloss, never a render. Any retouch, clarity or sharpness language in the direction above is about finish and focus, never a licence to smooth skin beyond what a professional photograph holds; if the direction above explicitly asks for a stylised or non-photographic treatment of the person, that explicit request wins.";
3182
+ }
3183
+ function productHandlingDirective() {
3184
+ return "Where the presenter touches the product, they handle it the way a real person handles an object of exactly that size and weight: a natural grip, fingers wrapping in genuine contact with its surface, hand and wrist at anatomically real angles, the product supported by that grip rather than floating. If the direction above explicitly stages the product away from their hands or in an impossible way, that explicit direction wins.";
3185
+ }
3130
3186
  function garmentDisplayDirective() {
3131
3187
  return "No person is part of this brief. Present the garment as a product, laid, hung, folded or dressed on a plain form, never on a person, a partial figure or an invisible body, unless the direction above explicitly asks for it worn.";
3132
3188
  }
@@ -3315,14 +3371,10 @@ function compileBrief(brief, ctx) {
3315
3371
  attachments.push({ role: "product", id: p.id, label: p.name, hash: h, essential: i === 0 });
3316
3372
  });
3317
3373
  productDirectives.push(productFidelityDirective(phashes.length));
3318
- if (p.preservationNotes) productDirectives.push(String(p.preservationNotes));
3319
- if (p.negativeConstraints) productDirectives.push(`Avoid: ${p.negativeConstraints}`);
3320
- const materials = p.materials ?? p.material;
3321
- if (materials) productDirectives.push(`Its materials and finish: ${materials}.`);
3322
- if (p.primaryColors) productDirectives.push(`Its actual colors: ${p.primaryColors}.`);
3323
- if (p.dimensions)
3374
+ productDirectives.push(...productFactDirectives(p));
3375
+ if (p.description && !p.dimensions)
3324
3376
  productDirectives.push(
3325
- `Its real-world size is ${p.dimensions} \u2014 keep it at true scale relative to everything else in frame.`
3377
+ `What this object physically is: ${String(p.description).replace(/\.\s*$/, "")}. Keep it at that real size relative to hands, faces, furniture and everything else in frame.`
3326
3378
  );
3327
3379
  } else {
3328
3380
  warnings.push(`${p.name} has no usable photo, so it is named but not attached.`);
@@ -3348,9 +3400,11 @@ function compileBrief(brief, ctx) {
3348
3400
  personDirectives.push(
3349
3401
  "The attached person reference is the same person every time: match their face, facial structure, skin, hair and build exactly. Their outfit, pose, background and lighting are neutral studio capture conditions, not styling direction: dress and style them for this shot, to a commercial standard, following any wardrobe the direction itself specifies. Where the direction specifies none, dress them for the place and the occasion the frame shows, and never return them to the plain base layers they were photographed in."
3350
3402
  );
3351
- if (c.identityNotes) personDirectives.push(String(c.identityNotes));
3352
- if (c.negativeConstraints?.length)
3353
- personDirectives.push(`Avoid: ${[].concat(c.negativeConstraints).join(", ")}`);
3403
+ personDirectives.push(...characterFactDirectives(c));
3404
+ if (c.skin)
3405
+ personDirectives.push(
3406
+ `${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
3407
+ );
3354
3408
  } else {
3355
3409
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
3356
3410
  }
@@ -3466,7 +3520,8 @@ function compileBrief(brief, ctx) {
3466
3520
  // the compiler: fired only when a person is attached, worded like
3467
3521
  // the mark exception — the ban stays for invented people, the
3468
3522
  // attached one is deliberate.
3469
- "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."
3523
+ "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.",
3524
+ productHandlingDirective()
3470
3525
  ] : [];
3471
3526
  const figureDirectives = scene?.figure ? sceneFigureDirectives({
3472
3527
  figure: scene.figure,
@@ -3476,13 +3531,15 @@ function compileBrief(brief, ctx) {
3476
3531
  // deliberately in play; attachments are fully collected by this point.
3477
3532
  hasMark: attachments.some((a) => a.role === "brand")
3478
3533
  }) : [];
3534
+ if (hasPerson) personDirectives.push(personSkinDirective());
3479
3535
  const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
3480
3536
  "The tight framing includes the presenter: keep at least their hand in genuine contact with the product, and as much more of them as the crop allows. A close-up is never a reason to leave the person out of the photograph."
3481
3537
  ] : [];
3482
3538
  const brandLines = brandRuleDirectives(ctx.brand);
3483
3539
  const preservation = ctx.mode === "edit" ? [
3484
3540
  ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3485
- ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
3541
+ ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
3542
+ ...ctx.inheritedDirectives ?? []
3486
3543
  ] : [];
3487
3544
  const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
3488
3545
  if (a.role !== "product" || !a.id) return false;
@@ -6259,23 +6316,34 @@ function registerAccessGuard(app, opts = {}) {
6259
6316
  }
6260
6317
 
6261
6318
  // src/editIdentity.ts
6262
- var MAX_HOPS = 8;
6263
- var tokensOf = (node) => {
6264
- const t = node?.brief?.tokens;
6265
- return Array.isArray(t) ? t : [];
6266
- };
6319
+ var MAX_HOPS = 64;
6320
+ var IDENTITY_KINDS = /* @__PURE__ */ new Set(["product", "character", "mark", "ref"]);
6321
+ var identityOf = (list2) => Array.isArray(list2) ? list2.filter((t) => IDENTITY_KINDS.has(t?.t)) : [];
6267
6322
  function inheritedIdentityTokens(parentId, getNode) {
6268
6323
  let id = parentId;
6324
+ const visited = /* @__PURE__ */ new Set();
6269
6325
  for (let hop = 0; hop < MAX_HOPS && id; hop++) {
6326
+ if (visited.has(id)) return { tokens: [], truncated: false };
6327
+ visited.add(id);
6270
6328
  const node = getNode(id);
6271
- if (!node || node.kind === "root") return [];
6272
- const identity = tokensOf(node).filter(
6273
- (t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref"
6274
- );
6275
- if (identity.length) return identity;
6329
+ if (!node || node.kind === "root") return { tokens: [], truncated: false };
6330
+ const brief = node.brief;
6331
+ const own = identityOf(brief?.tokens);
6332
+ const carried = identityOf(brief?.inherited);
6333
+ if (own.length || carried.length) {
6334
+ const seen = /* @__PURE__ */ new Set();
6335
+ const tokens = [];
6336
+ for (const t of [...own, ...carried]) {
6337
+ const key = JSON.stringify(t);
6338
+ if (seen.has(key)) continue;
6339
+ seen.add(key);
6340
+ tokens.push(t);
6341
+ }
6342
+ return { tokens, truncated: false };
6343
+ }
6276
6344
  id = node.parentId;
6277
6345
  }
6278
- return [];
6346
+ return { tokens: [], truncated: id !== null };
6279
6347
  }
6280
6348
 
6281
6349
  // src/editScopeRules.ts
@@ -6356,6 +6424,13 @@ function planExpand(source, targetRatio) {
6356
6424
  axis: "height"
6357
6425
  };
6358
6426
  }
6427
+ function wantsImplicitReshape(requested, parentNominal, sourcePixels) {
6428
+ const base = parentNominal ?? sourcePixels;
6429
+ if (!(requested.width > 0 && requested.height > 0 && base.width > 0 && base.height > 0)) return false;
6430
+ const want = requested.width / requested.height;
6431
+ const have = base.width / base.height;
6432
+ return Math.abs(want - have) / have > 0.01;
6433
+ }
6359
6434
  function expandInstruction(plan, direction) {
6360
6435
  const where = plan.axis === "width" ? "left and right" : "top and bottom";
6361
6436
  const nearEdge = plan.axis === "height" ? "\nDepth: the bottom edge of the frame is the part of the surface nearest the camera; the top edge is the furthest away." : "";
@@ -6406,6 +6481,20 @@ Keep: every object in the same place at the same size, the same camera position
6406
6481
  Avoid: new objects, products, people, text or watermarks; do not recompose, recentre, crop or resize anything already visible.` + own;
6407
6482
  }
6408
6483
 
6484
+ // src/editSizeRules.ts
6485
+ var SAME_SHAPE_TOL = 0.01;
6486
+ var SHRINK_FLOOR = 0.8;
6487
+ function judgeEditSize(src, got) {
6488
+ if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
6489
+ if (got.width === src.width && got.height === src.height) return { action: "keep" };
6490
+ const want = src.width / src.height;
6491
+ const have = got.width / got.height;
6492
+ if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
6493
+ const scale = Math.max(got.width, got.height) / Math.max(src.width, src.height);
6494
+ if (scale < SHRINK_FLOOR) return { action: "reject", scale };
6495
+ return { action: "resize", scale };
6496
+ }
6497
+
6409
6498
  // src/cropRules.ts
6410
6499
  function planCrop(source, targetRatio) {
6411
6500
  if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
@@ -8075,6 +8164,25 @@ function registerImageRoutes(app, deps) {
8075
8164
 
8076
8165
  // src/release/notes.data.ts
8077
8166
  var RELEASES = [
8167
+ {
8168
+ version: "0.6.9",
8169
+ date: "2026-08-29",
8170
+ title: "Refinements keep the shot.",
8171
+ sections: [
8172
+ {
8173
+ heading: "Refine",
8174
+ body: "Refining a shot no longer wears it down. Every refinement keeps the original resolution, an answer that comes back too small is restored to the frame or refused rather than quietly shrinking the thread, and a plain refinement stays a plain refinement instead of silently rebuilding the frame when an engine drifted its shape. The product, the presenter, the brand mark and your references now travel down the whole chain, together with what must hold about each of them, so the tenth refinement knows the shot as well as the first."
8175
+ },
8176
+ {
8177
+ heading: "Presenters",
8178
+ body: "People render with real photographed skin: natural texture and fine lines stay, and the waxy, over-retouched gloss goes. Light still behaves like light, so a hard flash can genuinely shine. Products hold their real-world size in the frame, and a held product sits in a real grip instead of floating beside the hand. Asking for something surreal still wins."
8179
+ },
8180
+ {
8181
+ heading: "Create",
8182
+ body: "Starting a fresh shot always starts fresh: a refinement you left open days ago can no longer greet you as the thing you are about to make. Refining any older shot still works exactly as before, whenever you come back to it. Around the chips, text selects, double-clicks and keyboard selection behave like any editor, and clicking a chip while text is selected opens its picker."
8183
+ }
8184
+ ]
8185
+ },
8078
8186
  {
8079
8187
  version: "0.6.8",
8080
8188
  date: "2026-08-29",
@@ -8986,7 +9094,8 @@ function buildServer(opts) {
8986
9094
  registerShowcaseRoutes(app, { templatesRoot });
8987
9095
  app.get("/api/formats", async () => FORMATS);
8988
9096
  async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
8989
- const borrowed = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
9097
+ const inherited = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
9098
+ const borrowed = inherited.tokens;
8990
9099
  const already = new Set(
8991
9100
  brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
8992
9101
  );
@@ -9010,6 +9119,28 @@ function buildServer(opts) {
9010
9119
  const verdict = scopeOfInstruction(
9011
9120
  brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
9012
9121
  );
9122
+ const inheritedDirectives = [];
9123
+ let inheritedMark = false;
9124
+ for (const tok of inheritedTokens) {
9125
+ if (tok.t === "product") {
9126
+ const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
9127
+ if (rec)
9128
+ inheritedDirectives.push(
9129
+ productEditFidelityDirective(rec.promptName ?? rec.name),
9130
+ ...productFactDirectives(rec)
9131
+ );
9132
+ } else if (tok.t === "character") {
9133
+ const rec = (brandJson?.characters ?? []).find((x) => x?.id === tok.id);
9134
+ if (rec)
9135
+ inheritedDirectives.push(
9136
+ characterEditIdentityDirective(rec.promptName ?? rec.name),
9137
+ ...characterFactDirectives(rec)
9138
+ );
9139
+ } else if (tok.t === "mark" && !inheritedMark) {
9140
+ inheritedMark = true;
9141
+ inheritedDirectives.push(markEditDirective());
9142
+ }
9143
+ }
9013
9144
  const compiled2 = compileBrief(brief, {
9014
9145
  brand: brandJson,
9015
9146
  images: core.images,
@@ -9020,6 +9151,7 @@ function buildServer(opts) {
9020
9151
  editScope: verdict.scope,
9021
9152
  editRemoval: verdict.removal ?? false,
9022
9153
  inheritedIdentity: inheritedTokens.length > 0,
9154
+ inheritedDirectives,
9023
9155
  // Only the explicit op drops the dimension promise: an implicit legacy
9024
9156
  // expansion keeps its historical prompt byte for byte.
9025
9157
  ...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
@@ -9032,23 +9164,35 @@ function buildServer(opts) {
9032
9164
  { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
9033
9165
  );
9034
9166
  identityWarnings = identity.warnings;
9035
- inheritedAttachments = identity.attachments.filter((a) => a.essential || a.role === "brand" || a.role === "reference").map((a) => ({ ...a, inherited: true }));
9167
+ const productAngles = /* @__PURE__ */ new Map();
9168
+ inheritedAttachments = identity.attachments.filter((a) => {
9169
+ if (a.role === "product") {
9170
+ const n = (productAngles.get(String(a.id ?? a.hash)) ?? 0) + 1;
9171
+ productAngles.set(String(a.id ?? a.hash), n);
9172
+ return n <= 2;
9173
+ }
9174
+ return a.essential || a.role === "brand" || a.role === "reference";
9175
+ }).map((a) => ({ ...a, inherited: true }));
9036
9176
  }
9037
9177
  const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
9038
9178
  const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
9039
9179
  const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
9180
+ if (inherited.truncated)
9181
+ warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
9040
9182
  if (merged.dropped.length) {
9041
9183
  if (engineCaps.maxReferenceImages <= 1) {
9042
9184
  warnings.push(
9043
9185
  `${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
9044
9186
  );
9045
9187
  } else {
9046
- const names = [...new Set(merged.dropped.map((d) => d.label))];
9047
- warnings.push(
9048
- `${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
9049
- " and "
9050
- )} ${names.length === 1 ? "was" : "were"} left out.`
9051
- );
9188
+ const keptLabels = new Set(merged.kept.map((a) => a.label));
9189
+ const names = [...new Set(merged.dropped.map((d) => d.label))].filter((l) => !keptLabels.has(l));
9190
+ if (names.length)
9191
+ warnings.push(
9192
+ `${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
9193
+ " and "
9194
+ )} ${names.length === 1 ? "was" : "were"} left out.`
9195
+ );
9052
9196
  }
9053
9197
  }
9054
9198
  return {
@@ -9449,7 +9593,13 @@ function buildServer(opts) {
9449
9593
  const srcBuf = core.images.read(String(srcHash));
9450
9594
  const srcMeta = await sharp7(srcBuf).metadata();
9451
9595
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
9452
- if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
9596
+ const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
9597
+ const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
9598
+ const reshapeIntended = reshape === "extend" || reshape === void 0 && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
9599
+ width: srcMeta.width,
9600
+ height: srcMeta.height
9601
+ });
9602
+ if (reshapeIntended && srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
9453
9603
  expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
9454
9604
  }
9455
9605
  if (reshape === "extend" && !expandPlan)
@@ -9479,8 +9629,12 @@ function buildServer(opts) {
9479
9629
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
9480
9630
  ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
9481
9631
  // An answer at the planned size lets compositeExpand skip its rescale,
9482
- // which is one whole class of seam misalignment gone when honored.
9483
- ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : {},
9632
+ // which is one whole class of seam misalignment gone when honored. A
9633
+ // plain refine states the source's own pixels for the same reason:
9634
+ // engines given no size answered at whatever size they liked, the
9635
+ // shrunken answer was stored, and the next refinement inherited it —
9636
+ // the chain that walked shots down to thumbnails.
9637
+ ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
9484
9638
  // Only an engine that can genuinely paint a margin is told where the
9485
9639
  // picture sits; the rest would ignore it anyway.
9486
9640
  ...expandPlan && canOutpaint2 ? {
@@ -9581,6 +9735,38 @@ function buildServer(opts) {
9581
9735
  const plan = expandPlan;
9582
9736
  const original = editedFrom ? core.images.read(editedFrom) : null;
9583
9737
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9738
+ const enforceEditCanvas = async (images) => {
9739
+ const srcMeta = await sharp7(original).metadata();
9740
+ if (!srcMeta.width || !srcMeta.height) return images;
9741
+ const out = [];
9742
+ for (const h of images) {
9743
+ const meta2 = await sharp7(core.images.read(h)).metadata();
9744
+ const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
9745
+ const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got);
9746
+ if (verdict.action === "reject")
9747
+ throw new Error(
9748
+ `engine returned ${got.width}x${got.height} for a ${srcMeta.width}x${srcMeta.height} frame: too little of the picture came back to keep at this resolution`
9749
+ );
9750
+ if (verdict.action === "resize") {
9751
+ app.log.info(
9752
+ { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${srcMeta.width}x${srcMeta.height}` },
9753
+ "edit: normalized the answer to the source canvas"
9754
+ );
9755
+ out.push(
9756
+ core.images.save(
9757
+ await sharp7(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
9758
+ )
9759
+ );
9760
+ try {
9761
+ const fresh = core.store.getNode(node.id);
9762
+ const b = fresh?.brief ?? {};
9763
+ core.store.setBrief(node.id, { ...b, resizedFrom: [got.width, got.height] });
9764
+ } catch {
9765
+ }
9766
+ } else out.push(h);
9767
+ }
9768
+ return out;
9769
+ };
9584
9770
  const post = plan ? expandMethod === "outpaint" ? async (images) => {
9585
9771
  const out = [];
9586
9772
  for (const h of images) {
@@ -9609,14 +9795,18 @@ function buildServer(opts) {
9609
9795
  "expand: chose which frame to keep"
9610
9796
  );
9611
9797
  return images;
9612
- } : localScope ? async (images) => {
9613
- const out = [];
9614
- for (const h of images) {
9615
- const { image, outcome, changed } = await preserveOutsideChange(original, core.images.read(h));
9616
- app.log.info({ nodeId: node.id, outcome, changed }, "local edit");
9617
- out.push(outcome === "composited" ? core.images.save(image) : h);
9798
+ } : kind === "edit" && original ? async (images) => {
9799
+ let staged = images;
9800
+ if (localScope) {
9801
+ const out = [];
9802
+ for (const h of staged) {
9803
+ const { image, outcome, changed } = await preserveOutsideChange(original, core.images.read(h));
9804
+ app.log.info({ nodeId: node.id, outcome, changed }, "local edit");
9805
+ out.push(outcome === "composited" ? core.images.save(image) : h);
9806
+ }
9807
+ staged = out;
9618
9808
  }
9619
- return out;
9809
+ return enforceEditCanvas(staged);
9620
9810
  } : void 0;
9621
9811
  const nodeBudgetMs = kind === "generation" && runEngine.capabilities().id === "codex-cli" ? codexNodeBudgetMs(Math.min(Math.max(1, Number(count)), 8)) : void 0;
9622
9812
  void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",