scenri 0.6.12 → 0.6.13

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
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
10
10
  import { fileURLToPath } from 'url';
11
11
  import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp7 from 'sharp';
13
+ import sharp19 from 'sharp';
14
14
  import Fastify from 'fastify';
15
15
  import fastifyStatic from '@fastify/static';
16
16
  import fastifyMultipart from '@fastify/multipart';
@@ -1285,7 +1285,7 @@ function createCatalogStore(db) {
1285
1285
  var REFERENCE_ROLE_DIRECTIVE = {
1286
1286
  product: "the exact product \u2014 preserve its label, shape, colors and design faithfully; do not redesign it",
1287
1287
  character: "the exact person \u2014 match their face, facial structure, skin, hair and build exactly; their clothing, pose and background are capture context, not styling to reproduce",
1288
- brand: "the brand's own mark \u2014 if the direction calls for the mark to appear, reproduce it exactly as drawn, same colours, letterforms and proportions; otherwise take only its colour and treatment, and never its subject, geometry or composition",
1288
+ brand: "the brand's own mark \u2014 if the direction calls for the mark to appear, reproduce it exactly as drawn, same colours, letterforms and proportions, every character down to the smallest secondary lettering, in its original script and reading direction, never translated, transliterated or re-spelled; otherwise take only its colour and treatment, and never its subject, geometry or composition",
1289
1289
  // Only a figure-led scene attaches one of these now, so this says what that
1290
1290
  // case actually needs. It used to read "environment and light only - take no
1291
1291
  // subject or person from it", which handed the model a photograph of a face
@@ -1303,7 +1303,7 @@ var REFERENCE_ROLE_DIRECTIVE = {
1303
1303
  var EDIT_REFERENCE_ROLE_DIRECTIVE = {
1304
1304
  product: "the exact product: keep or restore its label, shape and design faithfully",
1305
1305
  character: "the exact person: keep their face, facial structure, skin, hair and build faithfully; take no clothing, pose or background from this reference, and keep the source image's existing outfit unless the instruction changes it",
1306
- brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears, never redrawn or re-lettered",
1306
+ brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears \u2014 every character down to the smallest secondary lettering, in its original script and reading direction \u2014 never redrawn, re-lettered, translated or transliterated",
1307
1307
  scene: "a reference for environment and light only",
1308
1308
  composition: "a reference for framing and pose only",
1309
1309
  style: "a reference for treatment and mood only",
@@ -2831,7 +2831,7 @@ function createDemoEngine(saveImage) {
2831
2831
  <text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
2832
2832
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
2833
2833
  </svg>`;
2834
- return sharp7(Buffer.from(svg)).png().toBuffer();
2834
+ return sharp19(Buffer.from(svg)).png().toBuffer();
2835
2835
  }
2836
2836
  return {
2837
2837
  capabilities() {
@@ -3005,7 +3005,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
3005
3005
  for (const [slot, angle] of PRESENTER_ANGLES) {
3006
3006
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
3007
3007
  if (!existsSync(path)) continue;
3008
- const png = await sharp7(readFileSync(path)).png().toBuffer();
3008
+ const png = await sharp19(readFileSync(path)).png().toBuffer();
3009
3009
  const hash = core.images.save(png);
3010
3010
  shots.push({ file: `asset:${hash}`, angle, locked: true });
3011
3011
  }
@@ -3100,7 +3100,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
3100
3100
  for (const angle of angles) {
3101
3101
  const path = demoProductRefPath(templatesRoot, product.id, angle);
3102
3102
  if (!existsSync(path)) continue;
3103
- const png = await sharp7(readFileSync(path)).png().toBuffer();
3103
+ const png = await sharp19(readFileSync(path)).png().toBuffer();
3104
3104
  const hash = core.images.save(png);
3105
3105
  shots.push({ file: `asset:${hash}`, angle, locked: true });
3106
3106
  }
@@ -3216,13 +3216,92 @@ function mergeEditAttachments(own, inherited, cap2) {
3216
3216
  const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
3217
3217
  return allocateAttachments([...own, ...borrowed], cap2);
3218
3218
  }
3219
+ function joinNames(labels) {
3220
+ const uniq = [...new Set(labels)];
3221
+ if (uniq.length <= 1) return uniq[0] ?? "";
3222
+ return `${uniq.slice(0, -1).join(", ")} and ${uniq[uniq.length - 1]}`;
3223
+ }
3224
+ function brandContext(core, brandId) {
3225
+ const brand = core.store.getBrand(brandId);
3226
+ if (!brand) throw new Error("brand not found");
3227
+ const assetPaths = {};
3228
+ const json = brand.json;
3229
+ for (const logo of json.logos ?? []) {
3230
+ const ref = String(logo.file ?? "");
3231
+ if (ref.startsWith("asset:") && core.images.has(ref.slice(6))) assetPaths[ref] = core.images.pathFor(ref.slice(6));
3232
+ }
3233
+ return { brand: brand.json, assetPaths };
3234
+ }
3235
+ var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3236
+ var assetHash = (ref) => {
3237
+ const s = String(ref ?? "");
3238
+ return s.startsWith("asset:") ? s.slice(6) : null;
3239
+ };
3240
+ var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3241
+ var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3242
+ var toPng = (buf) => sharp19(buf).rotate().png().toBuffer();
3243
+ var COST_PROBE = {
3244
+ prompt: "",
3245
+ brand: { brand: {}, assetPaths: {} },
3246
+ width: 1024,
3247
+ height: 1024,
3248
+ count: 1
3249
+ };
3250
+ var MARK_MAX_EDGE = 2048;
3251
+ var MARK_MIN_EDGE = 1024;
3252
+ var MARK_TINY_EDGE = 256;
3253
+ var MARK_WARN_EDGE = 512;
3254
+ var toMarkPng = async (buf) => {
3255
+ const out = await sharp19(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3256
+ const meta = await sharp19(out).metadata();
3257
+ const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3258
+ if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3259
+ return sharp19(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3260
+ }
3261
+ return out;
3262
+ };
3263
+ var cappedRefs = /* @__PURE__ */ new Map();
3264
+ async function capReferenceEdge(core, path, maxEdge) {
3265
+ const key = `${path}#${maxEdge}`;
3266
+ const hit = cappedRefs.get(key);
3267
+ if (hit) return hit;
3268
+ let out = path;
3269
+ try {
3270
+ const meta = await sharp19(path).metadata();
3271
+ if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3272
+ const buf = await sharp19(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3273
+ out = core.images.pathFor(core.images.save(buf));
3274
+ }
3275
+ } catch {
3276
+ }
3277
+ cappedRefs.set(key, out);
3278
+ return out;
3279
+ }
3280
+ var readImagePart = async (core, req, normalize2) => {
3281
+ const part = await req.file();
3282
+ if (!part) return { error: "multipart file field required" };
3283
+ const buf = await part.toBuffer();
3284
+ if (buf.length === 0) return { error: "empty file" };
3285
+ try {
3286
+ return { hash: core.images.save(await normalize2(buf)), fields: part.fields ?? {}, filename: part.filename };
3287
+ } catch {
3288
+ return { error: "that file is not an image we can read" };
3289
+ }
3290
+ };
3291
+ var mtimeQS = (path) => existsSync(path) ? `?v=${Math.round(statSync(path).mtimeMs)}` : "";
3292
+ var serveJpeg = (req, reply, path) => {
3293
+ const etag = `"${statSync(path).mtimeMs}"`;
3294
+ reply.header("cache-control", "public, max-age=31536000, immutable").header("etag", etag);
3295
+ if (req.headers["if-none-match"] === etag) return reply.status(304).send();
3296
+ return reply.header("content-type", "image/jpeg").send(readFileSync(path));
3297
+ };
3219
3298
 
3220
3299
  // src/briefDirectives.ts
3221
3300
  function productFidelityDirective(attached) {
3222
3301
  if (attached <= 1) {
3223
3302
  return "The attached product image is the exact product: preserve its label, shape, colors and proportions faithfully, and do not redesign it. It is also the only view of this product that exists. Any face, side or detail not visible in it is unknown \u2014 keep those plain and consistent with the visible materials and color, and do not invent hardware, text, seams, closures, ornament or branding on them. Prefer a composition that shows the product from the view the reference gives.";
3224
3303
  }
3225
- return "The attached product images all show the exact same product from different angles: preserve its label, shape and colors faithfully, do not redesign it, and do not treat the extra angles as additional products. Any face not visible in them is unknown \u2014 keep it plain and consistent with the visible materials, and do not invent detail on it.";
3304
+ return "The attached product images all show the exact product to feature: preserve its label, shape and proportions faithfully, do not redesign it, and never treat an extra image as an additional product. The first product image is the authority for its color, finish and material. Where another image differs in color or finish, it shows the same product in another colorway \u2014 never blend colorways, and render the one the first image shows. Any face not visible in them is unknown \u2014 keep it plain and consistent with the materials the first image shows, and do not invent detail on it. If the direction above explicitly asks for more than one colorway, that explicit request wins.";
3226
3305
  }
3227
3306
  function editPreservationDirective(scope, opts) {
3228
3307
  if (scope === "local") {
@@ -3241,6 +3320,10 @@ function productFactDirectives(p) {
3241
3320
  const materials = p.materials ?? p.material;
3242
3321
  if (materials) out.push(`Its materials and finish: ${materials}.`);
3243
3322
  if (p.primaryColors) out.push(`Its actual colors: ${p.primaryColors}.`);
3323
+ if (Array.isArray(p.colorways) && p.colorways.length)
3324
+ out.push(
3325
+ `It is sold in these colorways: ${p.colorways.join(", ")}. The one in this shot is the colorway the first product image shows.`
3326
+ );
3244
3327
  if (p.dimensions)
3245
3328
  out.push(`Its real-world size is ${p.dimensions} \u2014 keep it at true scale relative to everything else in frame.`);
3246
3329
  return out;
@@ -3258,7 +3341,7 @@ function characterEditIdentityDirective(name) {
3258
3341
  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.`;
3259
3342
  }
3260
3343
  function markEditDirective() {
3261
- 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.";
3344
+ 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. Every character it carries stays intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled.";
3262
3345
  }
3263
3346
  function personSkinDirective() {
3264
3347
  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.";
@@ -3296,7 +3379,7 @@ function sceneFigureDirectives(opts) {
3296
3379
  // instruction to mutate the one real mark the user deliberately
3297
3380
  // attached. Same shape as pairDirectives' packshot override - name the
3298
3381
  // scope of the earlier rule, carry the exception in the same breath.
3299
- (opts.hasMark ? " The one exception is the attached brand mark: it is this brand's own real mark, deliberately attached, so the fictional-brands rule above does not apply to it. Where the direction asks for that mark to appear, it appears exactly as drawn - never redrawn, re-lettered or fictionalised." : "")
3382
+ (opts.hasMark ? " The one exception is the attached brand mark: it is this brand's own real mark, deliberately attached, so the fictional-brands rule above does not apply to it. Where the direction asks for that mark to appear, it appears exactly as drawn - every character, including the smallest lettering, in its original script - never redrawn, re-lettered, translated or fictionalised." : "")
3300
3383
  );
3301
3384
  }
3302
3385
  return out;
@@ -3365,7 +3448,7 @@ var FORMATS = [
3365
3448
  { id: "landscape", label: "Landscape 16:9", w: 1600, h: 900 },
3366
3449
  { id: "portrait", label: "Portrait 4:5", w: 1024, h: 1280 }
3367
3450
  ];
3368
- var assetHash = (ref) => {
3451
+ var assetHash2 = (ref) => {
3369
3452
  const s = String(ref ?? "");
3370
3453
  return s.startsWith("asset:") ? s.slice(6) : null;
3371
3454
  };
@@ -3428,6 +3511,9 @@ function compileBrief(brief, ctx) {
3428
3511
  const append = (s) => {
3429
3512
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
3430
3513
  };
3514
+ const attachingMarkHashes = new Set(
3515
+ brief.tokens.filter((t) => t?.t === "mark").map((t) => t.imageHash).filter((h) => (ctx.brand?.logos ?? []).some((l) => assetHash2(l?.file) === h) && ctx.images.has(h))
3516
+ );
3431
3517
  for (const tok of brief.tokens) {
3432
3518
  switch (tok.t) {
3433
3519
  case "text":
@@ -3446,7 +3532,7 @@ function compileBrief(brief, ctx) {
3446
3532
  const phashes = [];
3447
3533
  for (const s of orderedShots) {
3448
3534
  if (phashes.length >= PRODUCT_REF_MAX) break;
3449
- const h = assetHash(s?.file);
3535
+ const h = assetHash2(s?.file);
3450
3536
  if (h && ctx.images.has(h) && !phashes.includes(h)) phashes.push(h);
3451
3537
  }
3452
3538
  if (phashes.length) {
@@ -3472,7 +3558,7 @@ function compileBrief(brief, ctx) {
3472
3558
  }
3473
3559
  hasPerson = true;
3474
3560
  append(c.promptName ?? c.name);
3475
- const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => assetHash(s?.file)).filter((h) => !!h && ctx.images.has(h));
3561
+ const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => assetHash2(s?.file)).filter((h) => !!h && ctx.images.has(h));
3476
3562
  if (chashes.length) {
3477
3563
  chashes.forEach((chash, i) => {
3478
3564
  attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
@@ -3500,6 +3586,10 @@ function compileBrief(brief, ctx) {
3500
3586
  break;
3501
3587
  }
3502
3588
  case "ref": {
3589
+ if (attachingMarkHashes.has(tok.imageHash)) {
3590
+ warnings.push("That reference is the same image as your brand mark, so it rides once, as the mark.");
3591
+ break;
3592
+ }
3503
3593
  if (!ctx.images.has(tok.imageHash)) {
3504
3594
  warnings.push("A reference shot is missing and was skipped.");
3505
3595
  break;
@@ -3510,14 +3600,23 @@ function compileBrief(brief, ctx) {
3510
3600
  }
3511
3601
  case "mark": {
3512
3602
  const logos = ctx.brand?.logos ?? [];
3513
- const logo = logos.find((l) => assetHash(l?.file) === tok.imageHash);
3603
+ const logo = logos.find((l) => assetHash2(l?.file) === tok.imageHash);
3514
3604
  if (!logo || !ctx.images.has(tok.imageHash)) {
3515
3605
  warnings.push("A brand mark in this brief is no longer in the kit.");
3516
3606
  break;
3517
3607
  }
3518
3608
  attachments.push({ role: "brand", label: markLabel(ctx.brand, logo), hash: tok.imageHash });
3609
+ try {
3610
+ const head = ctx.images.read(tok.imageHash);
3611
+ const edge = Math.max(head.readUInt32BE(16), head.readUInt32BE(20));
3612
+ if (edge < MARK_WARN_EDGE)
3613
+ warnings.push(
3614
+ `${markLabel(ctx.brand, logo)} is only ${edge}px across, so fine lettering will not survive generation. Export it larger, or as SVG.`
3615
+ );
3616
+ } catch {
3617
+ }
3519
3618
  otherDirectives.push(
3520
- "The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Otherwise take only its colour and treatment from it."
3619
+ "The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Every character it carries appears intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled. Otherwise take only its colour and treatment from it."
3521
3620
  );
3522
3621
  break;
3523
3622
  }
@@ -3535,7 +3634,7 @@ function compileBrief(brief, ctx) {
3535
3634
  append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
3536
3635
  if (ctx.mode !== "edit" && t.figure) {
3537
3636
  for (const r of (t.refs ?? []).slice(0, SCENE_REF_MAX)) {
3538
- const h = assetHash(r?.file);
3637
+ const h = assetHash2(r?.file);
3539
3638
  if (h && ctx.images.has(h)) {
3540
3639
  attachments.push({ role: "scene", id: t.id, label: t.name, hash: h, essential: false });
3541
3640
  }
@@ -4334,6 +4433,7 @@ async function buildFromUrl(url, opts = {}) {
4334
4433
  if (!palette.primary) warnings.push("No confident palette found. Set colors manually.");
4335
4434
  let logoRef;
4336
4435
  let logoFromOg = false;
4436
+ let logoTiny = false;
4337
4437
  let iconHref = $('link[rel="apple-touch-icon"]').attr("href") || $('link[rel~="icon"]').attr("href");
4338
4438
  if (!iconHref) {
4339
4439
  iconHref = $('meta[property="og:image"]').attr("content");
@@ -4344,7 +4444,18 @@ async function buildFromUrl(url, opts = {}) {
4344
4444
  const iconRes = await fetchImpl(new URL(iconHref, origin).toString(), { redirect: "follow" });
4345
4445
  if (iconRes.ok) {
4346
4446
  const buf = Buffer.from(await iconRes.arrayBuffer());
4347
- if (buf.length > 0) logoRef = await opts.saveAsset(buf, "logo");
4447
+ if (buf.length > 0) {
4448
+ logoRef = await opts.saveAsset(buf, "logo");
4449
+ if (!logoFromOg && opts.probeLongEdge) {
4450
+ const edge = await opts.probeLongEdge(buf).catch(() => null);
4451
+ if (edge !== null && edge < 256) {
4452
+ logoTiny = true;
4453
+ warnings.push(
4454
+ `The site icon is favicon-sized (${edge}px), so it was saved as an alternate mark, not the logo. Upload your real logo in Settings.`
4455
+ );
4456
+ }
4457
+ }
4458
+ }
4348
4459
  }
4349
4460
  } catch {
4350
4461
  warnings.push("Logo download failed.");
@@ -4373,7 +4484,7 @@ async function buildFromUrl(url, opts = {}) {
4373
4484
  ...palette.neutrals.length ? { neutrals: palette.neutrals.map((hex) => ({ hex })) } : {}
4374
4485
  }
4375
4486
  } : {},
4376
- ...logoRef ? { logos: [{ role: logoFromOg ? "alternate" : "primary", file: logoRef }] } : {}
4487
+ ...logoRef ? { logos: [{ role: logoFromOg || logoTiny ? "alternate" : "primary", file: logoRef }] } : {}
4377
4488
  };
4378
4489
  return { brand, warnings };
4379
4490
  }
@@ -5645,8 +5756,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
5645
5756
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
5646
5757
  return;
5647
5758
  }
5648
- const png = await sharp7(buf).rotate().png().toBuffer();
5649
- const meta = await sharp7(png).metadata();
5759
+ const png = await sharp19(buf).rotate().png().toBuffer();
5760
+ const meta = await sharp19(png).metadata();
5650
5761
  const hash = core.images.save(png);
5651
5762
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
5652
5763
  width: meta.width,
@@ -5703,24 +5814,54 @@ function resolveLibraryProduct(core, brandId, productId) {
5703
5814
  const library = core.catalog.listLibraryProducts(brandId, brand.json);
5704
5815
  return library.find((p) => p.id === productId) ?? null;
5705
5816
  }
5817
+ function stripHtml(html) {
5818
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\s+/g, " ").trim();
5819
+ }
5820
+ function firstSentence(text, max) {
5821
+ if (text.length <= max) return text;
5822
+ const stop = text.slice(0, max + 1).search(/[.!?](\s|$)/);
5823
+ if (stop > 40) return text.slice(0, stop + 1);
5824
+ const cut = text.lastIndexOf(" ", max);
5825
+ return `${text.slice(0, cut > 40 ? cut : max).trimEnd()}\u2026`;
5826
+ }
5827
+ function colorwaysOf(variants) {
5828
+ const seen = [];
5829
+ for (const v of variants ?? []) {
5830
+ const key = Object.keys(v.options ?? {}).find((k) => /colou?r/i.test(k));
5831
+ const val = key ? String(v.options[key]).trim() : "";
5832
+ if (val && !seen.includes(val)) seen.push(val);
5833
+ if (seen.length >= 12) break;
5834
+ }
5835
+ return seen.length >= 2 ? seen : [];
5836
+ }
5706
5837
  function brandJsonWithCatalogProducts(core, brandId) {
5707
5838
  const brand = core.store.getBrand(brandId);
5708
5839
  if (!brand) return null;
5709
5840
  const json = { ...brand.json };
5710
5841
  const library = core.catalog.listLibraryProducts(brandId, brand.json);
5711
- json.products = library.map((p) => ({
5712
- id: p.id,
5713
- name: p.name,
5714
- shots: p.shots,
5715
- notes: p.url ?? void 0,
5716
- // Forwarded so the compiler can state real-world material and size. These
5717
- // were dropped here, which is part of why a watch could render plate-sized:
5718
- // nothing downstream ever knew how big the object actually is.
5719
- ...p.category ? { category: p.category } : {},
5720
- ...p.variant ? { variant: p.variant } : {},
5721
- ...p.material ? { material: p.material } : {},
5722
- ...p.dimensions ? { dimensions: p.dimensions } : {}
5723
- }));
5842
+ json.products = library.map((p) => {
5843
+ const colorways = colorwaysOf(p.variants);
5844
+ return {
5845
+ id: p.id,
5846
+ name: p.name,
5847
+ shots: p.shots,
5848
+ notes: p.url ?? void 0,
5849
+ // Forwarded so the compiler can state real-world material and size. These
5850
+ // were dropped here, which is part of why a watch could render plate-sized:
5851
+ // nothing downstream ever knew how big the object actually is.
5852
+ ...p.category ? { category: p.category } : {},
5853
+ ...p.variant ? { variant: p.variant } : {},
5854
+ ...p.material ? { material: p.material } : {},
5855
+ ...p.dimensions ? { dimensions: p.dimensions } : {},
5856
+ // The store's own words, the way the 0.6.9 fix gave demo products
5857
+ // theirs: the description is what anchors scale when dimensions are
5858
+ // absent, and imported products shipped with neither.
5859
+ ...p.descriptionHtml ? { description: firstSentence(stripHtml(String(p.descriptionHtml)), 300) } : {},
5860
+ // The declared colorways, so the compiler can say a colour difference
5861
+ // between references is a colorway rather than lighting.
5862
+ ...colorways.length ? { colorways } : {}
5863
+ };
5864
+ });
5724
5865
  return json;
5725
5866
  }
5726
5867
  function runningImportCount() {
@@ -6065,7 +6206,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6065
6206
  return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
6066
6207
  }
6067
6208
  async function edgeBarGeometry(buf) {
6068
- const { data, info } = await sharp7(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6209
+ const { data, info } = await sharp19(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6069
6210
  const W = info.width;
6070
6211
  const H = info.height;
6071
6212
  const scan = (len, cross, at) => {
@@ -6119,7 +6260,7 @@ async function trimEdgeBars(core, hash) {
6119
6260
  const width = g.right - g.left + 1;
6120
6261
  const height = g.bottom - g.top + 1;
6121
6262
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
6122
- const png = await sharp7(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
6263
+ const png = await sharp19(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
6123
6264
  return core.images.save(png);
6124
6265
  } catch {
6125
6266
  return hash;
@@ -6161,12 +6302,12 @@ async function avatarCrop(core, hash) {
6161
6302
  );
6162
6303
  }
6163
6304
  async function figureBox(buf) {
6164
- const meta = await sharp7(buf).metadata();
6305
+ const meta = await sharp19(buf).metadata();
6165
6306
  const W = meta.width ?? 0;
6166
6307
  const H = meta.height ?? 0;
6167
6308
  if (!W || !H) return null;
6168
6309
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
6169
- const { info } = await sharp7(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6310
+ const { info } = await sharp19(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6170
6311
  const left = Math.abs(info.trimOffsetLeft ?? 0);
6171
6312
  const top = Math.abs(info.trimOffsetTop ?? 0);
6172
6313
  const width = info.width ?? 0;
@@ -6199,13 +6340,13 @@ async function smartCover(core, hash, box) {
6199
6340
  if (!hash || !core.images.has(hash)) return void 0;
6200
6341
  try {
6201
6342
  const buf = core.images.read(hash);
6202
- const meta = await sharp7(buf).metadata();
6343
+ const meta = await sharp19(buf).metadata();
6203
6344
  const w = meta.width ?? 0;
6204
6345
  const h = meta.height ?? 0;
6205
6346
  if (!w || !h) return void 0;
6206
6347
  const raw = box(w, h);
6207
6348
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
6208
- const png = await sharp7(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
6349
+ const png = await sharp19(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
6209
6350
  return core.images.save(png);
6210
6351
  } catch {
6211
6352
  return void 0;
@@ -6214,11 +6355,11 @@ async function smartCover(core, hash, box) {
6214
6355
  async function crop(core, hash, region, cap2) {
6215
6356
  if (!hash || !core.images.has(hash)) return void 0;
6216
6357
  try {
6217
- const meta = await sharp7(core.images.read(hash)).metadata();
6358
+ const meta = await sharp19(core.images.read(hash)).metadata();
6218
6359
  const w = meta.width ?? 0;
6219
6360
  const h = meta.height ?? 0;
6220
6361
  if (!w || !h) return void 0;
6221
- let pipeline = sharp7(core.images.read(hash)).extract(region(w, h));
6362
+ let pipeline = sharp19(core.images.read(hash)).extract(region(w, h));
6222
6363
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
6223
6364
  const png = await pipeline.png().toBuffer();
6224
6365
  return core.images.save(png);
@@ -6402,6 +6543,7 @@ function registerAccessGuard(app, opts = {}) {
6402
6543
  var MAX_HOPS = 64;
6403
6544
  var IDENTITY_KINDS = /* @__PURE__ */ new Set(["product", "character", "mark", "ref"]);
6404
6545
  var identityOf = (list2) => Array.isArray(list2) ? list2.filter((t) => IDENTITY_KINDS.has(t?.t)) : [];
6546
+ var identityTokenKey = (t) => t?.t === "product" ? `p:${t.id}` : JSON.stringify(t);
6405
6547
  function inheritedIdentityTokens(parentId, getNode) {
6406
6548
  let id = parentId;
6407
6549
  const visited = /* @__PURE__ */ new Set();
@@ -6417,7 +6559,7 @@ function inheritedIdentityTokens(parentId, getNode) {
6417
6559
  const seen = /* @__PURE__ */ new Set();
6418
6560
  const tokens = [];
6419
6561
  for (const t of [...own, ...carried]) {
6420
- const key = JSON.stringify(t);
6562
+ const key = identityTokenKey(t);
6421
6563
  if (seen.has(key)) continue;
6422
6564
  seen.add(key);
6423
6565
  tokens.push(t);
@@ -6570,7 +6712,7 @@ function planCrop(source, targetRatio) {
6570
6712
  }
6571
6713
  async function attentionCropOrigin(srcBuf, source, plan) {
6572
6714
  try {
6573
- const { info } = await sharp7(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6715
+ const { info } = await sharp19(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6574
6716
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
6575
6717
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
6576
6718
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -6723,23 +6865,23 @@ function relax(grid, seam, fixedSweeps) {
6723
6865
 
6724
6866
  // src/expand.ts
6725
6867
  async function expandCanvas(source, plan) {
6726
- const bed = await sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6727
- return sharp7(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6868
+ const bed = await sharp19(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6869
+ return sharp19(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6728
6870
  }
6729
6871
  async function compositeExpand(engineImage, source, plan) {
6730
- const meta = await sharp7(engineImage).metadata();
6872
+ const meta = await sharp19(engineImage).metadata();
6731
6873
  const want = plan.width / plan.height;
6732
6874
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
6733
6875
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
6734
6876
  const aligned = sameOrientation;
6735
6877
  const exact = meta.width === plan.width && meta.height === plan.height;
6736
- const surround = aligned ? exact ? engineImage : await sharp7(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6878
+ const surround = aligned ? exact ? engineImage : await sharp19(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6737
6879
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
6738
- const image = await sharp7(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6880
+ const image = await sharp19(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6739
6881
  return { image, aligned };
6740
6882
  }
6741
6883
  async function matchMarginsToSeam(surround, source, plan) {
6742
- const src = await sharp7(source).metadata();
6884
+ const src = await sharp19(source).metadata();
6743
6885
  if (!src.width || !src.height) return surround;
6744
6886
  const SW = src.width;
6745
6887
  const SH = src.height;
@@ -6786,8 +6928,8 @@ var MAX_CORRECTION = 60;
6786
6928
  async function reconcile(surround, source, side, axis) {
6787
6929
  const { margin } = side;
6788
6930
  if (margin.width < 1 || margin.height < 1) return surround;
6789
- const marginRaw = await sharp7(surround).extract(margin).removeAlpha().raw().toBuffer();
6790
- const edgeRaw = await sharp7(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6931
+ const marginRaw = await sharp19(surround).extract(margin).removeAlpha().raw().toBuffer();
6932
+ const edgeRaw = await sharp19(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6791
6933
  const W = margin.width;
6792
6934
  const H = margin.height;
6793
6935
  const along = axis === "width" ? H : W;
@@ -6841,11 +6983,11 @@ async function reconcile(surround, source, side, axis) {
6841
6983
  }
6842
6984
  }
6843
6985
  }
6844
- const patch2 = await sharp7(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6845
- return sharp7(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
6986
+ const patch2 = await sharp19(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6987
+ return sharp19(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
6846
6988
  }
6847
6989
  async function expandCanvasBedOnly(source, plan) {
6848
- return sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6990
+ return sharp19(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6849
6991
  }
6850
6992
  function medianOf(rgb, channel, from, to) {
6851
6993
  const n = to - from;
@@ -6856,17 +6998,17 @@ function medianOf(rgb, channel, from, to) {
6856
6998
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
6857
6999
  }
6858
7000
  async function reframeExpand(engineImage, plan) {
6859
- const meta = await sharp7(engineImage).metadata();
7001
+ const meta = await sharp19(engineImage).metadata();
6860
7002
  if (!(meta.width && meta.height)) return null;
6861
7003
  const want = plan.width / plan.height;
6862
7004
  const got = meta.width / meta.height;
6863
7005
  if (got >= 1 !== want >= 1) return null;
6864
7006
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
6865
7007
  const straight = Math.abs(got - want) / want <= 0.02;
6866
- return sharp7(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
7008
+ return sharp19(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
6867
7009
  }
6868
7010
  async function seamScore(image, plan, source) {
6869
- const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
7011
+ const { data, info } = await sharp19(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6870
7012
  const W = info.width;
6871
7013
  const H = info.height;
6872
7014
  const horizontal = plan.axis === "width";
@@ -6899,7 +7041,7 @@ var SEAM_VISIBLE = 2.2;
6899
7041
  var OFFSET = 4;
6900
7042
  var RESIDUAL_VISIBLE = 15;
6901
7043
  async function seamResidual(image, plan, source) {
6902
- const { data, info } = await sharp7(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7044
+ const { data, info } = await sharp19(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
6903
7045
  const W = info.width;
6904
7046
  const H = info.height;
6905
7047
  const ch = info.channels;
@@ -6934,7 +7076,7 @@ var MAX_SHARE = 0.8;
6934
7076
  async function subjectFraction(src, source, axis) {
6935
7077
  try {
6936
7078
  const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
6937
- const { info } = await sharp7(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7079
+ const { info } = await sharp19(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6938
7080
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
6939
7081
  const span = axis === "width" ? source.width : source.height;
6940
7082
  const extent = axis === "width" ? window.width : window.height;
@@ -6957,14 +7099,14 @@ function placeExpand(plan, source, fraction) {
6957
7099
  }
6958
7100
  var NEUTRAL = { r: 128, g: 128, b: 128 };
6959
7101
  async function conditioningCanvas(source, plan, fill = "edge") {
6960
- const meta = await sharp7(source).metadata();
7102
+ const meta = await sharp19(source).metadata();
6961
7103
  const sw = meta.width ?? 0;
6962
7104
  const sh = meta.height ?? 0;
6963
7105
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
6964
7106
  const layers = [];
6965
7107
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
6966
7108
  layers.push({ input: source, left: plan.left, top: plan.top });
6967
- const canvas = sharp7({
7109
+ const canvas = sharp19({
6968
7110
  create: {
6969
7111
  width: plan.width,
6970
7112
  height: plan.height,
@@ -6976,7 +7118,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
6976
7118
  }
6977
7119
  async function edgeMargins(source, plan, size) {
6978
7120
  const out = [];
6979
- const strip = async (extract, width, height) => sharp7(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
7121
+ const strip = async (extract, width, height) => sharp19(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
6980
7122
  if (plan.axis === "width") {
6981
7123
  const before = plan.left;
6982
7124
  const after = plan.width - plan.left - size.width;
@@ -7067,12 +7209,12 @@ async function resolveOutpaintRoute(all, shot) {
7067
7209
  return { engine: shot, method: "reframe", crossed: false };
7068
7210
  }
7069
7211
  async function driftDiff(a, b) {
7070
- const metaA = await sharp7(a).metadata();
7071
- const metaB = await sharp7(b).metadata();
7212
+ const metaA = await sharp19(a).metadata();
7213
+ const metaB = await sharp19(b).metadata();
7072
7214
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
7073
7215
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
7074
7216
  const [rawA, rawB] = await Promise.all(
7075
- [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7217
+ [a, b].map((buf) => sharp19(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7076
7218
  );
7077
7219
  const out = new PNG({ width, height });
7078
7220
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -7084,11 +7226,11 @@ async function driftDiff(a, b) {
7084
7226
  };
7085
7227
  }
7086
7228
  async function changeMask(a, b, cap2 = 1024) {
7087
- const metaA = await sharp7(a).metadata();
7229
+ const metaA = await sharp19(a).metadata();
7088
7230
  const width = Math.min(metaA.width ?? 1, cap2);
7089
7231
  const height = Math.min(metaA.height ?? 1, cap2);
7090
7232
  const [rawA, rawB] = await Promise.all(
7091
- [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7233
+ [a, b].map((buf) => sharp19(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7092
7234
  );
7093
7235
  const out = new PNG({ width, height });
7094
7236
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -7137,8 +7279,8 @@ function dilationFor(longEdge) {
7137
7279
  // src/localEdit.ts
7138
7280
  async function preserveOutsideChange(source, edited) {
7139
7281
  try {
7140
- const srcMeta = await sharp7(source).metadata();
7141
- const outMeta = await sharp7(edited).metadata();
7282
+ const srcMeta = await sharp19(source).metadata();
7283
+ const outMeta = await sharp19(edited).metadata();
7142
7284
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
7143
7285
  return { image: edited, outcome: "error", changed: 0 };
7144
7286
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -7148,94 +7290,24 @@ async function preserveOutsideChange(source, edited) {
7148
7290
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
7149
7291
  const r = dilationFor(Math.max(shape.width, shape.height));
7150
7292
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
7151
- const spread = await sharp7(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7152
- const dilated = await sharp7(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7153
- const feathered = await sharp7(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7154
- const grown = await sharp7(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7155
- const editedRgb = await sharp7(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7156
- const masked = await sharp7(editedRgb, {
7293
+ const spread = await sharp19(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7294
+ const dilated = await sharp19(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7295
+ const feathered = await sharp19(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7296
+ const grown = await sharp19(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7297
+ const editedRgb = await sharp19(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7298
+ const masked = await sharp19(editedRgb, {
7157
7299
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
7158
7300
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
7159
- const image = await sharp7(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7301
+ const image = await sharp19(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7160
7302
  return { image, outcome: "composited", changed: shape.changed };
7161
7303
  } catch {
7162
7304
  return { image: edited, outcome: "error", changed: 0 };
7163
7305
  }
7164
7306
  }
7165
- function joinNames(labels) {
7166
- const uniq = [...new Set(labels)];
7167
- if (uniq.length <= 1) return uniq[0] ?? "";
7168
- return `${uniq.slice(0, -1).join(", ")} and ${uniq[uniq.length - 1]}`;
7169
- }
7170
- function brandContext(core, brandId) {
7171
- const brand = core.store.getBrand(brandId);
7172
- if (!brand) throw new Error("brand not found");
7173
- const assetPaths = {};
7174
- const json = brand.json;
7175
- for (const logo of json.logos ?? []) {
7176
- const ref = String(logo.file ?? "");
7177
- if (ref.startsWith("asset:") && core.images.has(ref.slice(6))) assetPaths[ref] = core.images.pathFor(ref.slice(6));
7178
- }
7179
- return { brand: brand.json, assetPaths };
7180
- }
7181
- var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7182
- var assetHash2 = (ref) => {
7183
- const s = String(ref ?? "");
7184
- return s.startsWith("asset:") ? s.slice(6) : null;
7185
- };
7186
- var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
7187
- var LOGO_BACKGROUNDS = ["light", "dark", "any"];
7188
- var toPng = (buf) => sharp7(buf).rotate().png().toBuffer();
7189
- var COST_PROBE = {
7190
- prompt: "",
7191
- brand: { brand: {}, assetPaths: {} },
7192
- width: 1024,
7193
- height: 1024,
7194
- count: 1
7195
- };
7196
- var MARK_MAX_EDGE = 2048;
7197
- var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
7198
- var cappedRefs = /* @__PURE__ */ new Map();
7199
- async function capReferenceEdge(core, path, maxEdge) {
7200
- const key = `${path}#${maxEdge}`;
7201
- const hit = cappedRefs.get(key);
7202
- if (hit) return hit;
7203
- let out = path;
7204
- try {
7205
- const meta = await sharp7(path).metadata();
7206
- if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
7207
- const buf = await sharp7(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
7208
- out = core.images.pathFor(core.images.save(buf));
7209
- }
7210
- } catch {
7211
- }
7212
- cappedRefs.set(key, out);
7213
- return out;
7214
- }
7215
- var readImagePart = async (core, req, normalize2) => {
7216
- const part = await req.file();
7217
- if (!part) return { error: "multipart file field required" };
7218
- const buf = await part.toBuffer();
7219
- if (buf.length === 0) return { error: "empty file" };
7220
- try {
7221
- return { hash: core.images.save(await normalize2(buf)), fields: part.fields ?? {}, filename: part.filename };
7222
- } catch {
7223
- return { error: "that file is not an image we can read" };
7224
- }
7225
- };
7226
- var mtimeQS = (path) => existsSync(path) ? `?v=${Math.round(statSync(path).mtimeMs)}` : "";
7227
- var serveJpeg = (req, reply, path) => {
7228
- const etag = `"${statSync(path).mtimeMs}"`;
7229
- reply.header("cache-control", "public, max-age=31536000, immutable").header("etag", etag);
7230
- if (req.headers["if-none-match"] === etag) return reply.status(304).send();
7231
- return reply.header("content-type", "image/jpeg").send(readFileSync(path));
7232
- };
7233
-
7234
- // src/routes/logos.ts
7235
7307
  function registerLogoRoutes(app, deps) {
7236
7308
  const { core } = deps;
7237
7309
  const readLogos = (json) => Array.isArray(json.logos) ? json.logos : [];
7238
- const findLogo = (json, hash) => readLogos(json).findIndex((l) => assetHash2(l?.file) === hash);
7310
+ const findLogo = (json, hash) => readLogos(json).findIndex((l) => assetHash(l?.file) === hash);
7239
7311
  const enumField = (raw, allowed, fallback) => {
7240
7312
  const v = raw === void 0 || raw === null ? "" : String(raw);
7241
7313
  if (!v) return fallback ?? "";
@@ -7248,7 +7320,7 @@ function registerLogoRoutes(app, deps) {
7248
7320
  const logos = [...readLogos(json)];
7249
7321
  const part = await readImagePart(core, req, toMarkPng);
7250
7322
  if ("error" in part) return reply.status(400).send({ error: part.error });
7251
- const existing = logos.findIndex((l) => assetHash2(l?.file) === part.hash);
7323
+ const existing = logos.findIndex((l) => assetHash(l?.file) === part.hash);
7252
7324
  const role = enumField(
7253
7325
  part.fields?.role?.value,
7254
7326
  LOGO_ROLES,
@@ -7263,7 +7335,10 @@ function registerLogoRoutes(app, deps) {
7263
7335
  json.logos = role === "primary" ? logos.map((l) => l !== entry && l?.role === "primary" ? { ...l, role: "alternate" } : l) : logos;
7264
7336
  const v = validateBrand(json);
7265
7337
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
7266
- return core.store.updateBrand(brand.id, json);
7338
+ const row = core.store.updateBrand(brand.id, json);
7339
+ const meta = await sharp19(core.images.read(part.hash)).metadata().catch(() => null);
7340
+ const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
7341
+ return { ...row, logoHash: part.hash, logoEdge };
7267
7342
  });
7268
7343
  app.patch("/api/brands/:id/logos/:hash", async (req, reply) => {
7269
7344
  const brand = core.store.getBrand(req.params.id);
@@ -7388,7 +7463,7 @@ async function vibrantColor(input) {
7388
7463
  let data;
7389
7464
  let channels;
7390
7465
  try {
7391
- const out = await sharp7(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
7466
+ const out = await sharp19(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
7392
7467
  data = out.data;
7393
7468
  channels = out.info.channels;
7394
7469
  } catch {
@@ -7411,7 +7486,7 @@ async function vibrantColor(input) {
7411
7486
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
7412
7487
  if (best.score <= 0) {
7413
7488
  try {
7414
- const { dominant } = await sharp7(input).stats();
7489
+ const { dominant } = await sharp19(input).stats();
7415
7490
  return toHex(dominant.r, dominant.g, dominant.b);
7416
7491
  } catch {
7417
7492
  return null;
@@ -8029,7 +8104,7 @@ async function buildExportZip(image, baseName, presetIds) {
8029
8104
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
8030
8105
  if (chosen.length === 0) throw new Error("No valid export presets selected");
8031
8106
  for (const p of chosen) {
8032
- const buf = p.width && p.height ? await sharp7(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
8107
+ const buf = p.width && p.height ? await sharp19(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
8033
8108
  zip.file(`${baseName}-${p.id}.png`, buf);
8034
8109
  }
8035
8110
  return zip.generateAsync({ type: "nodebuffer" });
@@ -8190,7 +8265,8 @@ function registerImageRoutes(app, deps) {
8190
8265
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
8191
8266
  const buf = await part.toBuffer();
8192
8267
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
8193
- const png = await sharp7(buf).rotate().png().toBuffer();
8268
+ const fmt = (await sharp19(buf).metadata().catch(() => null))?.format;
8269
+ const png = fmt === "svg" ? await toMarkPng(buf) : await sharp19(buf).rotate().png().toBuffer();
8194
8270
  return { hash: core.images.save(png) };
8195
8271
  });
8196
8272
  app.post("/api/diff", async (req, reply) => {
@@ -8225,6 +8301,24 @@ function registerImageRoutes(app, deps) {
8225
8301
 
8226
8302
  // src/release/notes.data.ts
8227
8303
  var RELEASES = [
8304
+ {
8305
+ version: "0.6.13",
8306
+ date: "2026-08-30",
8307
+ sections: [
8308
+ {
8309
+ heading: "Products",
8310
+ body: "A product sold in several colours renders the colour its first photo shows, instead of blending them. Imported products now carry their own description and colour list into every shot, so scale and colourway stay true."
8311
+ },
8312
+ {
8313
+ heading: "Brand",
8314
+ body: "Attach your own logo straight from the Brand tab when composing; it joins the kit and rides the shot. Small logo files are raised to a workable resolution, the smallest lettering and its script are now part of the contract, and a logo too small to survive says so before you spend."
8315
+ },
8316
+ {
8317
+ heading: "Create",
8318
+ body: "The refine strip counts each product once and names the scene a thread keeps. A retried shot carries your recipe, never leftovers from the previous run."
8319
+ }
8320
+ ]
8321
+ },
8228
8322
  {
8229
8323
  version: "0.6.12",
8230
8324
  date: "2026-08-29",
@@ -9002,6 +9096,12 @@ function buildServer(opts) {
9002
9096
  // about its own format — broken in the marks grid, and mislabelled to any
9003
9097
  // engine it is later attached to.
9004
9098
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
9099
+ // Measured as stored (post-toMarkPng), so the scrape judges the same
9100
+ // pixels the compiler will one day attach.
9101
+ probeLongEdge: async (buf) => {
9102
+ const m = await sharp19(await toMarkPng(buf)).metadata();
9103
+ return Math.max(m.width ?? 0, m.height ?? 0) || null;
9104
+ },
9005
9105
  createdWith: `${meta.name}/${meta.version}`
9006
9106
  });
9007
9107
  const row = core.store.createBrand(brand);
@@ -9087,6 +9187,10 @@ function buildServer(opts) {
9087
9187
  const { brand: scraped, warnings } = await buildFromUrl(url, {
9088
9188
  fetchImpl: opts.fetchImpl,
9089
9189
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
9190
+ probeLongEdge: async (buf) => {
9191
+ const m = await sharp19(await toMarkPng(buf)).metadata();
9192
+ return Math.max(m.width ?? 0, m.height ?? 0) || null;
9193
+ },
9090
9194
  createdWith: `${meta.name}/${meta.version}`
9091
9195
  });
9092
9196
  const { brand: merged, suggestions } = mergeScrape(brand.json, scraped);
@@ -9189,13 +9293,17 @@ function buildServer(opts) {
9189
9293
  registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
9190
9294
  registerShowcaseRoutes(app, { templatesRoot });
9191
9295
  app.get("/api/formats", async () => FORMATS);
9296
+ function briefInputsOnly(brief) {
9297
+ const { inherited, rendered, croppedFrom, resizedFrom, resampledHops, expand, crop: crop2, sourceImage, ...inputs } = brief;
9298
+ return inputs;
9299
+ }
9192
9300
  async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
9193
9301
  const inherited = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
9194
9302
  const borrowed = inherited.tokens;
9195
9303
  const already = new Set(
9196
- brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
9304
+ brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map(identityTokenKey)
9197
9305
  );
9198
- const inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
9306
+ const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
9199
9307
  const combined = [...brief.tokens, ...inheritedTokens];
9200
9308
  const brandJson = await brandJsonWithResolvedPresenters(
9201
9309
  core,
@@ -9412,11 +9520,11 @@ function buildServer(opts) {
9412
9520
  const out = [];
9413
9521
  for (const h of images) {
9414
9522
  const buf = core.images.read(h);
9415
- const meta2 = await sharp7(buf).metadata().catch(() => null);
9523
+ const meta2 = await sharp19(buf).metadata().catch(() => null);
9416
9524
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9417
9525
  const oriented = (meta2.orientation ?? 1) !== 1;
9418
9526
  out.push(
9419
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp7(buf).rotate().png().toBuffer())
9527
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp19(buf).rotate().png().toBuffer())
9420
9528
  );
9421
9529
  }
9422
9530
  return out;
@@ -9428,7 +9536,7 @@ function buildServer(opts) {
9428
9536
  const out = [];
9429
9537
  for (const h of images) {
9430
9538
  const buf = core.images.read(h);
9431
- const meta2 = await sharp7(buf).metadata();
9539
+ const meta2 = await sharp19(buf).metadata();
9432
9540
  if (!meta2.width || !meta2.height) {
9433
9541
  out.push(h);
9434
9542
  continue;
@@ -9441,7 +9549,7 @@ function buildServer(opts) {
9441
9549
  }
9442
9550
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9443
9551
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9444
- const cropped = await sharp7(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9552
+ const cropped = await sharp19(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9445
9553
  app.log.info(
9446
9554
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9447
9555
  "canvas: cropped a drifted frame to the asked ratio"
@@ -9460,7 +9568,7 @@ function buildServer(opts) {
9460
9568
  async function assertAspect(images, expect) {
9461
9569
  const want = expect.width / expect.height;
9462
9570
  for (const h of images) {
9463
- const meta2 = await sharp7(core.images.read(h)).metadata();
9571
+ const meta2 = await sharp19(core.images.read(h)).metadata();
9464
9572
  if (!meta2.width || !meta2.height) continue;
9465
9573
  const got = meta2.width / meta2.height;
9466
9574
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -9492,7 +9600,7 @@ function buildServer(opts) {
9492
9600
  try {
9493
9601
  const sizes = [];
9494
9602
  for (const h of result.images) {
9495
- const meta2 = await sharp7(core.images.read(h)).metadata();
9603
+ const meta2 = await sharp19(core.images.read(h)).metadata();
9496
9604
  if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
9497
9605
  }
9498
9606
  const node = core.store.getNode(nodeId);
@@ -9559,7 +9667,7 @@ function buildServer(opts) {
9559
9667
  if (!srcHash || !core.images.has(String(srcHash)))
9560
9668
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
9561
9669
  const srcBuf = core.images.read(String(srcHash));
9562
- const srcMeta = await sharp7(srcBuf).metadata();
9670
+ const srcMeta = await sharp19(srcBuf).metadata();
9563
9671
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
9564
9672
  const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
9565
9673
  if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
@@ -9576,13 +9684,13 @@ function buildServer(opts) {
9576
9684
  engineId: "local"
9577
9685
  });
9578
9686
  core.store.setBrief(node2.id, {
9579
- ...brief ?? {},
9687
+ ...briefInputsOnly(brief ?? {}),
9580
9688
  sourceImage: String(srcHash),
9581
9689
  reshape: "crop",
9582
9690
  crop: window
9583
9691
  });
9584
9692
  const work2 = async () => ({
9585
- images: [core.images.save(await sharp7(srcBuf).extract(window).png().toBuffer())],
9693
+ images: [core.images.save(await sharp19(srcBuf).extract(window).png().toBuffer())],
9586
9694
  costUsd: 0
9587
9695
  });
9588
9696
  void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -9734,7 +9842,7 @@ function buildServer(opts) {
9734
9842
  const editEdge = engine.capabilities().maxReferenceEdge;
9735
9843
  if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
9736
9844
  const srcBuf = core.images.read(String(srcHash));
9737
- const srcMeta = await sharp7(srcBuf).metadata();
9845
+ const srcMeta = await sharp19(srcBuf).metadata();
9738
9846
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
9739
9847
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
9740
9848
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
@@ -9854,7 +9962,7 @@ function buildServer(opts) {
9854
9962
  });
9855
9963
  if (brief)
9856
9964
  core.store.setBrief(node.id, {
9857
- ...brief,
9965
+ ...briefInputsOnly(brief),
9858
9966
  ...editedFrom ? { sourceImage: editedFrom } : {},
9859
9967
  ...kind === "edit" && reshape ? { reshape } : {},
9860
9968
  // How the margin was actually made, and by whom. An extend may be
@@ -9879,11 +9987,11 @@ function buildServer(opts) {
9879
9987
  const original = editedFrom ? core.images.read(editedFrom) : null;
9880
9988
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9881
9989
  const enforceEditCanvas = async (images) => {
9882
- const srcMeta = await sharp7(original).metadata();
9990
+ const srcMeta = await sharp19(original).metadata();
9883
9991
  if (!srcMeta.width || !srcMeta.height) return images;
9884
9992
  const out = [];
9885
9993
  for (const h of images) {
9886
- const meta2 = await sharp7(core.images.read(h)).metadata();
9994
+ const meta2 = await sharp19(core.images.read(h)).metadata();
9887
9995
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
9888
9996
  const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got);
9889
9997
  if (verdict.action === "reject")
@@ -9897,7 +10005,7 @@ function buildServer(opts) {
9897
10005
  );
9898
10006
  out.push(
9899
10007
  core.images.save(
9900
- await sharp7(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
10008
+ await sharp19(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
9901
10009
  )
9902
10010
  );
9903
10011
  try {
@@ -9919,7 +10027,7 @@ function buildServer(opts) {
9919
10027
  const out = [];
9920
10028
  for (const h of images) {
9921
10029
  const answer = core.images.read(h);
9922
- const got = await sharp7(answer).metadata();
10030
+ const got = await sharp19(answer).metadata();
9923
10031
  if (got.width !== plan.width || got.height !== plan.height)
9924
10032
  app.log.info(
9925
10033
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -10220,8 +10328,8 @@ async function verify() {
10220
10328
  const db = new Database2(":memory:");
10221
10329
  db.pragma("user_version");
10222
10330
  db.close();
10223
- const { default: sharp19 } = await import('sharp');
10224
- await sharp19({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10331
+ const { default: sharp20 } = await import('sharp');
10332
+ await sharp20({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10225
10333
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
10226
10334
  } catch (err) {
10227
10335
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));