scenri 0.6.8 → 0.6.10

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
@@ -1310,6 +1310,28 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
1310
1310
  reference: "a reference for composition, lighting and treatment only"
1311
1311
  };
1312
1312
  var ASPECT_TOLERANCE = 0.15;
1313
+ var NAMED_RATIOS = [
1314
+ ["1:1", 1],
1315
+ ["4:5", 4 / 5],
1316
+ ["5:4", 5 / 4],
1317
+ ["2:3", 2 / 3],
1318
+ ["3:2", 3 / 2],
1319
+ ["3:4", 3 / 4],
1320
+ ["4:3", 4 / 3],
1321
+ ["9:16", 9 / 16],
1322
+ ["16:9", 16 / 9],
1323
+ ["2:1", 2],
1324
+ ["1:2", 0.5]
1325
+ ];
1326
+ function ratioLabel(width, height) {
1327
+ const ratio = width / height;
1328
+ for (const [label, value] of NAMED_RATIOS) {
1329
+ if (Math.abs(ratio - value) / value < 0.02) return label;
1330
+ }
1331
+ const gcd = (a, b) => b ? gcd(b, a % b) : a;
1332
+ const d = gcd(width, height) || 1;
1333
+ return `${Math.round(width / d)}:${Math.round(height / d)}`;
1334
+ }
1313
1335
 
1314
1336
  // ../core/src/index.ts
1315
1337
  function defaultHome() {
@@ -1483,7 +1505,10 @@ function createOpenRouterEngine(opts) {
1483
1505
  ...editRefs.map((p) => ({ type: "image_url", image_url: { url: dataUrl(p) } }))
1484
1506
  ]
1485
1507
  }
1486
- ]
1508
+ ],
1509
+ // Mirrors generate: an edit given no shape answered at whatever ratio
1510
+ // the model felt like, and the drift compounded down refine chains.
1511
+ ...req.width && req.height ? { image_config: { aspect_ratio: aspectRatioFor(req.width, req.height) } } : {}
1487
1512
  };
1488
1513
  const json = await post(key, body, signal);
1489
1514
  const [buf] = extractImages(json);
@@ -2476,6 +2501,27 @@ var CODEX_POOL = 2;
2476
2501
  function codexNodeBudgetMs(count) {
2477
2502
  return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
2478
2503
  }
2504
+ function orientationOf(width, height) {
2505
+ return width === height ? "square" : width > height ? "landscape" : "portrait";
2506
+ }
2507
+ var CODEX_PIXEL_BUDGET = 1572864;
2508
+ function codexNativeSize(width, height) {
2509
+ const ratio = width / height;
2510
+ if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height };
2511
+ return {
2512
+ width: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET * ratio)),
2513
+ height: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET / ratio))
2514
+ };
2515
+ }
2516
+ function refFileNames(roles, count) {
2517
+ const perRole = /* @__PURE__ */ new Map();
2518
+ return Array.from({ length: count }, (_, i) => {
2519
+ const role = roles[i] ?? "reference";
2520
+ const n = (perRole.get(role) ?? 0) + 1;
2521
+ perRole.set(role, n);
2522
+ return `${role}-${n}.png`;
2523
+ });
2524
+ }
2479
2525
  function createCodexEngine(opts) {
2480
2526
  const { saveImage } = opts;
2481
2527
  const platform = opts.platform ?? process.platform;
@@ -2508,7 +2554,9 @@ function createCodexEngine(opts) {
2508
2554
  }
2509
2555
  const hashes = [];
2510
2556
  for (const name of outFiles) {
2511
- hashes.push(saveImage(await readFile(join(dir, name))));
2557
+ const buf = await readFile(join(dir, name));
2558
+ if (buf.length === 0) throw new Error(`codex: ${name} is empty`);
2559
+ hashes.push(saveImage(buf));
2512
2560
  }
2513
2561
  return hashes;
2514
2562
  }
@@ -2555,6 +2603,11 @@ function createCodexEngine(opts) {
2555
2603
  * attached — and on an edit the first one attached is `input.png`, the
2556
2604
  * shot being edited. A refine carrying a full identity payload was
2557
2605
  * silently editing nothing at all.
2606
+ *
2607
+ * That same backwards walk is why every reference is bound to its role
2608
+ * by FILENAME (refFileNames) on both paths: the tool decides which
2609
+ * pictures it surfaces and in what order, so an ordinal "Attached
2610
+ * image N" binding pointed identity claims at the wrong picture.
2558
2611
  */
2559
2612
  maxReferenceImages: 5,
2560
2613
  /*
@@ -2586,8 +2639,9 @@ function createCodexEngine(opts) {
2586
2639
  (_, i) => async () => withWorkDir(async (dir) => {
2587
2640
  const args = execArgs(dir);
2588
2641
  let refBytes = 0;
2642
+ const names = refFileNames(roles, refs.length);
2589
2643
  for (const [idx, ref] of refs.entries()) {
2590
- const dest = join(dir, `ref-${idx}.png`);
2644
+ const dest = join(dir, names[idx]);
2591
2645
  await copyFile(ref, dest);
2592
2646
  refBytes += (await stat(dest)).size;
2593
2647
  args.splice(args.length - 1, 0, `--image=${dest}`);
@@ -2663,9 +2717,15 @@ function createCodexEngine(opts) {
2663
2717
  await copyFile(editRefs[i], join(dir, name));
2664
2718
  refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
2665
2719
  }
2666
- const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it).` + // sips is already licensed by the parenthesis above; an exact-size
2667
- // answer lets the caller's compositing pass skip its rescale.
2668
- (req.width && req.height ? ` Save the result at exactly ${req.width}x${req.height} pixels.` : "") + ` Nothing else.`;
2720
+ const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + // The old tail licensed "the commands needed to save and resize it"
2721
+ // and then asked for exactly WxH pixels - which the model honoured
2722
+ // with sips -z, a force-fit of BOTH axes. Every refine hop was a
2723
+ // cheap-kernel shell resample of freshly generated pixels, and when
2724
+ // the tool had drifted the shape it was a shear: the reported
2725
+ // crushed faces and the deep-chain mush. The server's own canvas
2726
+ // pass (enforceEditCanvas) owns size now, with one uniform lanczos
2727
+ // only when actually needed.
2728
+ (req.width && req.height ? ` Keep the edited frame at input.png's own ${ratioLabel(req.width, req.height)} shape, ${codexNativeSize(req.width, req.height).width}x${codexNativeSize(req.width, req.height).height}.` : "") + ` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.`;
2669
2729
  const args = execArgs(dir);
2670
2730
  for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
2671
2731
  args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
@@ -2689,10 +2749,30 @@ function createCodexEngine(opts) {
2689
2749
  }
2690
2750
  function buildPrompt2(req, index, roles) {
2691
2751
  const roleDirective = REFERENCE_ROLE_DIRECTIVE;
2692
- const refDirectives = roles.map(
2693
- (role, i) => roles.length > 1 ? `Attached image ${i + 1} is ${roleDirective[role]}.` : `The attached image is ${roleDirective[role]}.`
2694
- ).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)` : "");
2752
+ const names = refFileNames(roles, roles.length);
2753
+ const refDirectives = roles.map((role, i) => `${names[i]} shows ${roleDirective[role]}.`).join(" ");
2754
+ const count = Math.max(1, req.count);
2755
+ const native = codexNativeSize(req.width, req.height);
2756
+ return (
2757
+ // "professional-grade", not "flawless": the audit of the waxy-presenter
2758
+ // report traced part of the plastic, over-perfected rendering to that
2759
+ // one unconditional word. Still "image", never "photograph" - this
2760
+ // wrapper also generates graphic assets. Independently revertible on
2761
+ // render evidence.
2762
+ `Generate one professional-grade image immediately using your image generation tool, composed as a ${native.width}x${native.height} frame (${ratioLabel(req.width, req.height)} ${orientationOf(req.width, req.height)}): ${req.prompt}.` + (refDirectives ? ` ${refDirectives}` : "") + // The save instruction bans what the old one licensed. "you may run the
2763
+ // commands needed to save and resize it" invited sips -z, which
2764
+ // force-fits BOTH axes: the model drew at one shape, sheared the pixels
2765
+ // to the requested one, and the aspect check passed BECAUSE of the shear
2766
+ // - the reported crushed faces. Copy/move stays licensed because the
2767
+ // win32 recovery path moves files out of generated_images.
2768
+ ` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.` + // Every take in a batch gets the SAME-shaped clause. Take 1 used to get
2769
+ // nothing - so the first output was literally asked for the most
2770
+ // reference-faithful decode - and later takes were licensed to a
2771
+ // "different composition", which read as permission to drift from the
2772
+ // directives. Reported as: output #1 copies the scene reference, output
2773
+ // #2 mixes identities. A single generation stays byte-stable.
2774
+ (count > 1 ? ` (take ${index + 1} of ${count} \u2014 same brief, same identities and constraints, a naturally different moment and framing of the same shoot)` : "")
2775
+ );
2696
2776
  }
2697
2777
  }
2698
2778
 
@@ -2773,7 +2853,13 @@ function createDemoEngine(saveImage) {
2773
2853
  },
2774
2854
  async edit(req) {
2775
2855
  const colors = paletteOf(req);
2776
- const buf = await render(colors, `edit: ${req.instruction}`, 1024, 1024, req.instruction.length);
2856
+ const buf = await render(
2857
+ colors,
2858
+ `edit: ${req.instruction}`,
2859
+ req.width ?? 1024,
2860
+ req.height ?? 1024,
2861
+ req.instruction.length
2862
+ );
2777
2863
  return { images: [saveImage(buf)], costUsd: 0 };
2778
2864
  }
2779
2865
  };
@@ -2916,6 +3002,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
2916
3002
  name: presenter.name,
2917
3003
  ...presenter.identityNotes ? { identityNotes: presenter.identityNotes } : {},
2918
3004
  ...presenter.negativeConstraints?.length ? { negativeConstraints: presenter.negativeConstraints } : {},
3005
+ ...presenter.skin ? { skin: presenter.skin } : {},
2919
3006
  shots
2920
3007
  };
2921
3008
  }
@@ -3019,7 +3106,14 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
3019
3106
  // brushed steel case read as steel rather than plastic. They were being
3020
3107
  // dropped here, so the compiler never saw them.
3021
3108
  ...product.materials ? { materials: product.materials } : {},
3022
- ...product.primaryColors ? { primaryColors: product.primaryColors } : {}
3109
+ ...product.primaryColors ? { primaryColors: product.primaryColors } : {},
3110
+ // The description is the only text a demo product has that carries scale
3111
+ // ("sized for a facial serum or treatment oil") - dropping it here is why
3112
+ // a cream jar could render the size of a basketball. And category is what
3113
+ // arms the compiler's apparel guard; without it no demo garment ever
3114
+ // earned the "present it as a product" line.
3115
+ ...product.description ? { description: product.description } : {},
3116
+ ...product.category ? { category: product.category } : {}
3023
3117
  };
3024
3118
  }
3025
3119
  async function brandJsonWithResolvedDemoProducts(core, templatesRoot, demoProducts, brandJson, tokens) {
@@ -3127,6 +3221,38 @@ function editPreservationDirective(scope, opts) {
3127
3221
  function inheritedIdentityDirective() {
3128
3222
  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
3223
  }
3224
+ function productFactDirectives(p) {
3225
+ const out = [];
3226
+ if (p.preservationNotes) out.push(String(p.preservationNotes));
3227
+ if (p.negativeConstraints) out.push(`Avoid: ${p.negativeConstraints}`);
3228
+ const materials = p.materials ?? p.material;
3229
+ if (materials) out.push(`Its materials and finish: ${materials}.`);
3230
+ if (p.primaryColors) out.push(`Its actual colors: ${p.primaryColors}.`);
3231
+ if (p.dimensions)
3232
+ out.push(`Its real-world size is ${p.dimensions} \u2014 keep it at true scale relative to everything else in frame.`);
3233
+ return out;
3234
+ }
3235
+ function characterFactDirectives(c) {
3236
+ const out = [];
3237
+ if (c.identityNotes) out.push(String(c.identityNotes));
3238
+ if (c.negativeConstraints?.length) out.push(`Avoid: ${[].concat(c.negativeConstraints).join(", ")}`);
3239
+ return out;
3240
+ }
3241
+ function productEditFidelityDirective(name) {
3242
+ 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.`;
3243
+ }
3244
+ function characterEditIdentityDirective(name) {
3245
+ 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.`;
3246
+ }
3247
+ function markEditDirective() {
3248
+ 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.";
3249
+ }
3250
+ function personSkinDirective() {
3251
+ 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.";
3252
+ }
3253
+ function productHandlingDirective() {
3254
+ 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.";
3255
+ }
3130
3256
  function garmentDisplayDirective() {
3131
3257
  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
3258
  }
@@ -3315,14 +3441,10 @@ function compileBrief(brief, ctx) {
3315
3441
  attachments.push({ role: "product", id: p.id, label: p.name, hash: h, essential: i === 0 });
3316
3442
  });
3317
3443
  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)
3444
+ productDirectives.push(...productFactDirectives(p));
3445
+ if (p.description && !p.dimensions)
3324
3446
  productDirectives.push(
3325
- `Its real-world size is ${p.dimensions} \u2014 keep it at true scale relative to everything else in frame.`
3447
+ `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
3448
  );
3327
3449
  } else {
3328
3450
  warnings.push(`${p.name} has no usable photo, so it is named but not attached.`);
@@ -3348,9 +3470,11 @@ function compileBrief(brief, ctx) {
3348
3470
  personDirectives.push(
3349
3471
  "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
3472
  );
3351
- if (c.identityNotes) personDirectives.push(String(c.identityNotes));
3352
- if (c.negativeConstraints?.length)
3353
- personDirectives.push(`Avoid: ${[].concat(c.negativeConstraints).join(", ")}`);
3473
+ personDirectives.push(...characterFactDirectives(c));
3474
+ if (c.skin)
3475
+ personDirectives.push(
3476
+ `${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
3477
+ );
3354
3478
  } else {
3355
3479
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
3356
3480
  }
@@ -3466,7 +3590,8 @@ function compileBrief(brief, ctx) {
3466
3590
  // the compiler: fired only when a person is attached, worded like
3467
3591
  // the mark exception — the ban stays for invented people, the
3468
3592
  // 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."
3593
+ "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.",
3594
+ productHandlingDirective()
3470
3595
  ] : [];
3471
3596
  const figureDirectives = scene?.figure ? sceneFigureDirectives({
3472
3597
  figure: scene.figure,
@@ -3476,13 +3601,15 @@ function compileBrief(brief, ctx) {
3476
3601
  // deliberately in play; attachments are fully collected by this point.
3477
3602
  hasMark: attachments.some((a) => a.role === "brand")
3478
3603
  }) : [];
3604
+ if (hasPerson) personDirectives.push(personSkinDirective());
3479
3605
  const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
3480
3606
  "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
3607
  ] : [];
3482
3608
  const brandLines = brandRuleDirectives(ctx.brand);
3483
3609
  const preservation = ctx.mode === "edit" ? [
3484
3610
  ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3485
- ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
3611
+ ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
3612
+ ...ctx.inheritedDirectives ?? []
3486
3613
  ] : [];
3487
3614
  const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
3488
3615
  if (a.role !== "product" || !a.id) return false;
@@ -6259,23 +6386,34 @@ function registerAccessGuard(app, opts = {}) {
6259
6386
  }
6260
6387
 
6261
6388
  // 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
- };
6389
+ var MAX_HOPS = 64;
6390
+ var IDENTITY_KINDS = /* @__PURE__ */ new Set(["product", "character", "mark", "ref"]);
6391
+ var identityOf = (list2) => Array.isArray(list2) ? list2.filter((t) => IDENTITY_KINDS.has(t?.t)) : [];
6267
6392
  function inheritedIdentityTokens(parentId, getNode) {
6268
6393
  let id = parentId;
6394
+ const visited = /* @__PURE__ */ new Set();
6269
6395
  for (let hop = 0; hop < MAX_HOPS && id; hop++) {
6396
+ if (visited.has(id)) return { tokens: [], truncated: false };
6397
+ visited.add(id);
6270
6398
  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;
6399
+ if (!node || node.kind === "root") return { tokens: [], truncated: false };
6400
+ const brief = node.brief;
6401
+ const own = identityOf(brief?.tokens);
6402
+ const carried = identityOf(brief?.inherited);
6403
+ if (own.length || carried.length) {
6404
+ const seen = /* @__PURE__ */ new Set();
6405
+ const tokens = [];
6406
+ for (const t of [...own, ...carried]) {
6407
+ const key = JSON.stringify(t);
6408
+ if (seen.has(key)) continue;
6409
+ seen.add(key);
6410
+ tokens.push(t);
6411
+ }
6412
+ return { tokens, truncated: false };
6413
+ }
6276
6414
  id = node.parentId;
6277
6415
  }
6278
- return [];
6416
+ return { tokens: [], truncated: id !== null };
6279
6417
  }
6280
6418
 
6281
6419
  // src/editScopeRules.ts
@@ -6356,6 +6494,13 @@ function planExpand(source, targetRatio) {
6356
6494
  axis: "height"
6357
6495
  };
6358
6496
  }
6497
+ function wantsImplicitReshape(requested, parentNominal, sourcePixels) {
6498
+ const base = parentNominal ?? sourcePixels;
6499
+ if (!(requested.width > 0 && requested.height > 0 && base.width > 0 && base.height > 0)) return false;
6500
+ const want = requested.width / requested.height;
6501
+ const have = base.width / base.height;
6502
+ return Math.abs(want - have) / have > 0.01;
6503
+ }
6359
6504
  function expandInstruction(plan, direction) {
6360
6505
  const where = plan.axis === "width" ? "left and right" : "top and bottom";
6361
6506
  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." : "";
@@ -6367,28 +6512,6 @@ Continue: the same surface, the same light direction, the same colour temperatur
6367
6512
  Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
6368
6513
  Avoid: new objects, products, people, text or watermarks.` + own;
6369
6514
  }
6370
- var NAMED_RATIOS = [
6371
- ["1:1", 1],
6372
- ["4:5", 4 / 5],
6373
- ["5:4", 5 / 4],
6374
- ["2:3", 2 / 3],
6375
- ["3:2", 3 / 2],
6376
- ["3:4", 3 / 4],
6377
- ["4:3", 4 / 3],
6378
- ["9:16", 9 / 16],
6379
- ["16:9", 16 / 9],
6380
- ["2:1", 2],
6381
- ["1:2", 0.5]
6382
- ];
6383
- function ratioLabel(width, height) {
6384
- const ratio = width / height;
6385
- for (const [label, value] of NAMED_RATIOS) {
6386
- if (Math.abs(ratio - value) / value < 0.02) return label;
6387
- }
6388
- const gcd = (a, b) => b ? gcd(b, a % b) : a;
6389
- const d = gcd(width, height) || 1;
6390
- return `${Math.round(width / d)}:${Math.round(height / d)}`;
6391
- }
6392
6515
  function reframeInstruction(plan, source, direction) {
6393
6516
  const wider = plan.axis === "width";
6394
6517
  const where = wider ? "to the left and to the right" : "above and below";
@@ -6406,6 +6529,20 @@ Keep: every object in the same place at the same size, the same camera position
6406
6529
  Avoid: new objects, products, people, text or watermarks; do not recompose, recentre, crop or resize anything already visible.` + own;
6407
6530
  }
6408
6531
 
6532
+ // src/editSizeRules.ts
6533
+ var SAME_SHAPE_TOL = 0.01;
6534
+ var SHRINK_FLOOR = 0.8;
6535
+ function judgeEditSize(src, got) {
6536
+ if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
6537
+ if (got.width === src.width && got.height === src.height) return { action: "keep" };
6538
+ const want = src.width / src.height;
6539
+ const have = got.width / got.height;
6540
+ if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
6541
+ const scale = Math.max(got.width, got.height) / Math.max(src.width, src.height);
6542
+ if (scale < SHRINK_FLOOR) return { action: "reject", scale };
6543
+ return { action: "resize", scale };
6544
+ }
6545
+
6409
6546
  // src/cropRules.ts
6410
6547
  function planCrop(source, targetRatio) {
6411
6548
  if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
@@ -8075,6 +8212,40 @@ function registerImageRoutes(app, deps) {
8075
8212
 
8076
8213
  // src/release/notes.data.ts
8077
8214
  var RELEASES = [
8215
+ {
8216
+ version: "0.6.10",
8217
+ date: "2026-08-29",
8218
+ title: "Pictures keep their shape.",
8219
+ sections: [
8220
+ {
8221
+ heading: "Create",
8222
+ body: "Images can no longer come back crushed or stretched: Scenri now works with the exact frame sizes the Codex image tool actually produces, and an answer that drifts off the requested shape is trimmed to it rather than distorted or refused. The wave of failed shots saying the engine could not produce the requested aspect ratio is gone with it, and refining an image over and over keeps its full sharpness at every step."
8223
+ },
8224
+ {
8225
+ heading: "Scenes",
8226
+ body: "When a scene built from photographs of one person is used with a chosen presenter, every output now shows the chosen presenter. Each reference image travels with a name that says what it is, so a scene photograph can lend its world and its styling without lending anyone a face, and asking for several variations gives every variation the same instructions rather than letting the first one copy the reference."
8227
+ }
8228
+ ]
8229
+ },
8230
+ {
8231
+ version: "0.6.9",
8232
+ date: "2026-08-29",
8233
+ title: "Refinements keep the shot.",
8234
+ sections: [
8235
+ {
8236
+ heading: "Refine",
8237
+ 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."
8238
+ },
8239
+ {
8240
+ heading: "Presenters",
8241
+ 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."
8242
+ },
8243
+ {
8244
+ heading: "Create",
8245
+ 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."
8246
+ }
8247
+ ]
8248
+ },
8078
8249
  {
8079
8250
  version: "0.6.8",
8080
8251
  date: "2026-08-29",
@@ -8986,7 +9157,8 @@ function buildServer(opts) {
8986
9157
  registerShowcaseRoutes(app, { templatesRoot });
8987
9158
  app.get("/api/formats", async () => FORMATS);
8988
9159
  async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
8989
- const borrowed = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
9160
+ const inherited = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
9161
+ const borrowed = inherited.tokens;
8990
9162
  const already = new Set(
8991
9163
  brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
8992
9164
  );
@@ -9010,6 +9182,28 @@ function buildServer(opts) {
9010
9182
  const verdict = scopeOfInstruction(
9011
9183
  brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
9012
9184
  );
9185
+ const inheritedDirectives = [];
9186
+ let inheritedMark = false;
9187
+ for (const tok of inheritedTokens) {
9188
+ if (tok.t === "product") {
9189
+ const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
9190
+ if (rec)
9191
+ inheritedDirectives.push(
9192
+ productEditFidelityDirective(rec.promptName ?? rec.name),
9193
+ ...productFactDirectives(rec)
9194
+ );
9195
+ } else if (tok.t === "character") {
9196
+ const rec = (brandJson?.characters ?? []).find((x) => x?.id === tok.id);
9197
+ if (rec)
9198
+ inheritedDirectives.push(
9199
+ characterEditIdentityDirective(rec.promptName ?? rec.name),
9200
+ ...characterFactDirectives(rec)
9201
+ );
9202
+ } else if (tok.t === "mark" && !inheritedMark) {
9203
+ inheritedMark = true;
9204
+ inheritedDirectives.push(markEditDirective());
9205
+ }
9206
+ }
9013
9207
  const compiled2 = compileBrief(brief, {
9014
9208
  brand: brandJson,
9015
9209
  images: core.images,
@@ -9020,6 +9214,7 @@ function buildServer(opts) {
9020
9214
  editScope: verdict.scope,
9021
9215
  editRemoval: verdict.removal ?? false,
9022
9216
  inheritedIdentity: inheritedTokens.length > 0,
9217
+ inheritedDirectives,
9023
9218
  // Only the explicit op drops the dimension promise: an implicit legacy
9024
9219
  // expansion keeps its historical prompt byte for byte.
9025
9220
  ...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
@@ -9032,23 +9227,35 @@ function buildServer(opts) {
9032
9227
  { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
9033
9228
  );
9034
9229
  identityWarnings = identity.warnings;
9035
- inheritedAttachments = identity.attachments.filter((a) => a.essential || a.role === "brand" || a.role === "reference").map((a) => ({ ...a, inherited: true }));
9230
+ const productAngles = /* @__PURE__ */ new Map();
9231
+ inheritedAttachments = identity.attachments.filter((a) => {
9232
+ if (a.role === "product") {
9233
+ const n = (productAngles.get(String(a.id ?? a.hash)) ?? 0) + 1;
9234
+ productAngles.set(String(a.id ?? a.hash), n);
9235
+ return n <= 2;
9236
+ }
9237
+ return a.essential || a.role === "brand" || a.role === "reference";
9238
+ }).map((a) => ({ ...a, inherited: true }));
9036
9239
  }
9037
9240
  const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
9038
9241
  const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
9039
9242
  const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
9243
+ if (inherited.truncated)
9244
+ warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
9040
9245
  if (merged.dropped.length) {
9041
9246
  if (engineCaps.maxReferenceImages <= 1) {
9042
9247
  warnings.push(
9043
9248
  `${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
9044
9249
  );
9045
9250
  } 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
- );
9251
+ const keptLabels = new Set(merged.kept.map((a) => a.label));
9252
+ const names = [...new Set(merged.dropped.map((d) => d.label))].filter((l) => !keptLabels.has(l));
9253
+ if (names.length)
9254
+ warnings.push(
9255
+ `${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
9256
+ " and "
9257
+ )} ${names.length === 1 ? "was" : "were"} left out.`
9258
+ );
9052
9259
  }
9053
9260
  }
9054
9261
  return {
@@ -9172,10 +9379,51 @@ function buildServer(opts) {
9172
9379
  const out = [];
9173
9380
  for (const h of images) {
9174
9381
  const buf = core.images.read(h);
9175
- out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp7(buf).png().toBuffer()));
9382
+ const meta2 = await sharp7(buf).metadata().catch(() => null);
9383
+ if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9384
+ const oriented = (meta2.orientation ?? 1) !== 1;
9385
+ out.push(
9386
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp7(buf).rotate().png().toBuffer())
9387
+ );
9176
9388
  }
9177
9389
  return out;
9178
9390
  }
9391
+ const CROPPABLE_DRIFT = 0.35;
9392
+ function conformToCanvas(nodeId, want) {
9393
+ return async (images) => {
9394
+ const target = want.width / want.height;
9395
+ const out = [];
9396
+ for (const h of images) {
9397
+ const buf = core.images.read(h);
9398
+ const meta2 = await sharp7(buf).metadata();
9399
+ if (!meta2.width || !meta2.height) {
9400
+ out.push(h);
9401
+ continue;
9402
+ }
9403
+ const got = meta2.width / meta2.height;
9404
+ const drift = Math.abs(got - target) / target;
9405
+ if (drift <= SAME_SHAPE_TOL || drift > CROPPABLE_DRIFT) {
9406
+ out.push(h);
9407
+ continue;
9408
+ }
9409
+ const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9410
+ const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9411
+ const cropped = await sharp7(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9412
+ app.log.info(
9413
+ { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9414
+ "canvas: cropped a drifted frame to the asked ratio"
9415
+ );
9416
+ out.push(core.images.save(cropped));
9417
+ try {
9418
+ const fresh = core.store.getNode(nodeId);
9419
+ const b = fresh?.brief ?? {};
9420
+ core.store.setBrief(nodeId, { ...b, croppedFrom: [meta2.width, meta2.height] });
9421
+ } catch {
9422
+ }
9423
+ }
9424
+ return out;
9425
+ };
9426
+ }
9179
9427
  async function assertAspect(images, expect) {
9180
9428
  const want = expect.width / expect.height;
9181
9429
  for (const h of images) {
@@ -9219,7 +9467,13 @@ function buildServer(opts) {
9219
9467
  const brief = node.brief ?? {};
9220
9468
  const raw = result.raw;
9221
9469
  const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
9222
- core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors } });
9470
+ const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
9471
+ core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors, ...asked } });
9472
+ if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && sizes.some(([w, h]) => w === expect.width && h === expect.height))
9473
+ app.log.warn(
9474
+ { nodeId },
9475
+ "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
9476
+ );
9223
9477
  }
9224
9478
  } catch {
9225
9479
  }
@@ -9449,7 +9703,13 @@ function buildServer(opts) {
9449
9703
  const srcBuf = core.images.read(String(srcHash));
9450
9704
  const srcMeta = await sharp7(srcBuf).metadata();
9451
9705
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
9452
- if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
9706
+ const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
9707
+ const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
9708
+ const reshapeIntended = reshape === "extend" || reshape === void 0 && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
9709
+ width: srcMeta.width,
9710
+ height: srcMeta.height
9711
+ });
9712
+ if (reshapeIntended && srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
9453
9713
  expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
9454
9714
  }
9455
9715
  if (reshape === "extend" && !expandPlan)
@@ -9479,8 +9739,12 @@ function buildServer(opts) {
9479
9739
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
9480
9740
  ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
9481
9741
  // 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 } : {},
9742
+ // which is one whole class of seam misalignment gone when honored. A
9743
+ // plain refine states the source's own pixels for the same reason:
9744
+ // engines given no size answered at whatever size they liked, the
9745
+ // shrunken answer was stored, and the next refinement inherited it —
9746
+ // the chain that walked shots down to thumbnails.
9747
+ ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
9484
9748
  // Only an engine that can genuinely paint a margin is told where the
9485
9749
  // picture sits; the rest would ignore it anyway.
9486
9750
  ...expandPlan && canOutpaint2 ? {
@@ -9581,6 +9845,43 @@ function buildServer(opts) {
9581
9845
  const plan = expandPlan;
9582
9846
  const original = editedFrom ? core.images.read(editedFrom) : null;
9583
9847
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9848
+ const enforceEditCanvas = async (images) => {
9849
+ const srcMeta = await sharp7(original).metadata();
9850
+ if (!srcMeta.width || !srcMeta.height) return images;
9851
+ const out = [];
9852
+ for (const h of images) {
9853
+ const meta2 = await sharp7(core.images.read(h)).metadata();
9854
+ const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
9855
+ const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got);
9856
+ if (verdict.action === "reject")
9857
+ throw new Error(
9858
+ `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`
9859
+ );
9860
+ if (verdict.action === "resize") {
9861
+ app.log.info(
9862
+ { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${srcMeta.width}x${srcMeta.height}` },
9863
+ "edit: normalized the answer to the source canvas"
9864
+ );
9865
+ out.push(
9866
+ core.images.save(
9867
+ await sharp7(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
9868
+ )
9869
+ );
9870
+ try {
9871
+ const fresh = core.store.getNode(node.id);
9872
+ const b = fresh?.brief ?? {};
9873
+ const parentBrief = core.store.getNode(resolvedParentId)?.brief ?? {};
9874
+ core.store.setBrief(node.id, {
9875
+ ...b,
9876
+ resizedFrom: [got.width, got.height],
9877
+ resampledHops: (parentBrief.resampledHops ?? 0) + 1
9878
+ });
9879
+ } catch {
9880
+ }
9881
+ } else out.push(h);
9882
+ }
9883
+ return out;
9884
+ };
9584
9885
  const post = plan ? expandMethod === "outpaint" ? async (images) => {
9585
9886
  const out = [];
9586
9887
  for (const h of images) {
@@ -9609,15 +9910,21 @@ function buildServer(opts) {
9609
9910
  "expand: chose which frame to keep"
9610
9911
  );
9611
9912
  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);
9913
+ } : kind === "edit" && original ? async (images) => {
9914
+ let staged = images;
9915
+ if (localScope) {
9916
+ const out = [];
9917
+ for (const h of staged) {
9918
+ const { image, outcome, changed } = await preserveOutsideChange(original, core.images.read(h));
9919
+ app.log.info({ nodeId: node.id, outcome, changed }, "local edit");
9920
+ out.push(outcome === "composited" ? core.images.save(image) : h);
9921
+ }
9922
+ staged = out;
9923
+ } else if (expectShape) {
9924
+ staged = await conformToCanvas(node.id, expectShape)(staged);
9618
9925
  }
9619
- return out;
9620
- } : void 0;
9926
+ return enforceEditCanvas(staged);
9927
+ } : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
9621
9928
  const nodeBudgetMs = kind === "generation" && runEngine.capabilities().id === "codex-cli" ? codexNodeBudgetMs(Math.min(Math.max(1, Number(count)), 8)) : void 0;
9622
9929
  void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
9623
9930
  (err) => app.log.error({ err }, "node run failed")