scenri 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -1,14 +1,14 @@
1
- import { createUpdateChecker, classify, findNpm, isReleaseTriplet, stageVersion } from './chunk-YK37H6DM.js';
1
+ import { createUpdateChecker, classify, isReleaseTriplet, findNpm, stageVersion } from './chunk-YK37H6DM.js';
2
2
  import { shouldAdoptRunning, anotherScenriLines, portBusyLines } from './chunk-CMLJGPYC.js';
3
- import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, nameMatches, budgetSize, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-4PJPJGGP.js';
3
+ import { SchemaTooNewError, defaultHome, createCore, BUDGET_EXHAUSTED, SpendCapError, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, nameMatches, budgetSize, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-YNL3WQOL.js';
4
4
  import { detectInstallKind } from './chunk-PAIAXRAC.js';
5
5
  export { detectInstallKind } from './chunk-PAIAXRAC.js';
6
6
  import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
7
7
  import { compareSemver, newestStaged } from './chunk-4MAFHYAD.js';
8
8
  import { powershellPath, PS_UTF8 } from './chunk-2IGINCPU.js';
9
- import { dirname, join, normalize, basename } from 'path';
9
+ import { dirname, join, basename, normalize } from 'path';
10
10
  import { fileURLToPath } from 'url';
11
- import fs, { existsSync, readFileSync, mkdirSync, createReadStream, rmSync, readdirSync, statSync, renameSync, lstatSync, unlinkSync } from 'fs';
11
+ import fs, { existsSync, readdirSync, readFileSync, mkdirSync, createReadStream, rmSync, statSync, renameSync, lstatSync, unlinkSync } from 'fs';
12
12
  import { homedir, tmpdir, networkInterfaces } from 'os';
13
13
  import { readFile, stat, copyFile, lstat, readdir, mkdtemp, rm, access, rmdir, rename, unlink, writeFile } from 'fs/promises';
14
14
  import { spawn, execFile } from 'child_process';
@@ -229,14 +229,24 @@ var DEFAULT_MODEL2 = "black-forest-labs/flux-schnell";
229
229
  var DEFAULT_EDIT_MODEL = "black-forest-labs/flux-kontext-pro";
230
230
  var DEFAULT_EXPAND_MODEL = "bria/expand-image";
231
231
  var DEFAULT_POLL_INTERVAL_MS = 1500;
232
- var DEFAULT_TIMEOUT_MS = 12e4;
232
+ var DEFAULT_TIMEOUT_MS = 6e5;
233
+ var MIN_CANCEL_AFTER_S = 5;
234
+ var CANCEL_TIMEOUT_MS = 5e3;
233
235
  var GENERATE_COST_PER_IMAGE_USD = 3e-3;
234
236
  var EDIT_COST_USD = 0.04;
235
237
  function nearestAspectRatio(width, height) {
236
238
  const candidates = [
237
239
  ["1:1", 1],
238
240
  ["16:9", 16 / 9],
239
- ["9:16", 9 / 16]
241
+ ["21:9", 21 / 9],
242
+ ["3:2", 3 / 2],
243
+ ["2:3", 2 / 3],
244
+ ["4:5", 4 / 5],
245
+ ["5:4", 5 / 4],
246
+ ["3:4", 3 / 4],
247
+ ["4:3", 4 / 3],
248
+ ["9:16", 9 / 16],
249
+ ["9:21", 9 / 21]
240
250
  ];
241
251
  const ratio = width / height;
242
252
  let best = candidates[0];
@@ -307,7 +317,12 @@ function createReplicateEngine(opts) {
307
317
  headers: {
308
318
  Authorization: `Bearer ${key}`,
309
319
  "Content-Type": "application/json",
310
- Prefer: "wait"
320
+ Prefer: "wait",
321
+ // Replicate's own copy of the ceiling, measured from creation, so a
322
+ // prediction nobody here can cancel any more (the app quit, or a Stop
323
+ // landed while this request was still open and no id had come back)
324
+ // still stops running, and billing, on its own.
325
+ "Cancel-After": `${Math.max(MIN_CANCEL_AFTER_S, Math.ceil(timeoutMs / 1e3))}s`
311
326
  },
312
327
  body: JSON.stringify({ input }),
313
328
  signal
@@ -315,6 +330,18 @@ function createReplicateEngine(opts) {
315
330
  if (!res.ok) throw await httpError(res, "prediction create");
316
331
  return asPrediction(await res.json(), "prediction create");
317
332
  }
333
+ async function cancelPrediction(key, prediction) {
334
+ const url = typeof prediction.urls?.cancel === "string" ? prediction.urls.cancel : typeof prediction.id === "string" ? `${API_BASE}/predictions/${prediction.id}/cancel` : null;
335
+ if (!url) return;
336
+ try {
337
+ await fetchImpl(url, {
338
+ method: "POST",
339
+ headers: { Authorization: `Bearer ${key}` },
340
+ signal: AbortSignal.timeout(CANCEL_TIMEOUT_MS)
341
+ });
342
+ } catch {
343
+ }
344
+ }
318
345
  async function waitForCompletion(key, initial, signal) {
319
346
  let prediction = initial;
320
347
  if (prediction.status === "succeeded") return prediction;
@@ -327,26 +354,34 @@ function createReplicateEngine(opts) {
327
354
  }
328
355
  const getUrl = prediction.urls?.get;
329
356
  if (typeof getUrl !== "string" || getUrl.length === 0) {
357
+ await cancelPrediction(key, prediction);
330
358
  throw new Error(
331
359
  `Replicate prediction did not succeed (status: ${prediction.status ?? "unknown"}) and no polling URL was provided`
332
360
  );
333
361
  }
334
362
  const deadline = Date.now() + timeoutMs;
335
- for (; ; ) {
336
- if (Date.now() >= deadline) {
337
- throw new Error(`Replicate prediction timed out after ${timeoutMs}ms`);
363
+ try {
364
+ for (; ; ) {
365
+ if (Date.now() >= deadline) {
366
+ throw new Error(`Replicate prediction timed out after ${timeoutMs}ms`);
367
+ }
368
+ await sleep(pollIntervalMs, signal);
369
+ const res = await fetchImpl(getUrl, {
370
+ headers: { Authorization: `Bearer ${key}` },
371
+ signal
372
+ });
373
+ if (!res.ok) throw await httpError(res, "prediction poll");
374
+ prediction = asPrediction(await res.json(), "prediction poll");
375
+ if (prediction.status === "succeeded") return prediction;
376
+ if (prediction.status === "failed" || prediction.status === "canceled") {
377
+ throw failureError(prediction);
378
+ }
338
379
  }
339
- await sleep(pollIntervalMs, signal);
340
- const res = await fetchImpl(getUrl, {
341
- headers: { Authorization: `Bearer ${key}` },
342
- signal
343
- });
344
- if (!res.ok) throw await httpError(res, "prediction poll");
345
- prediction = asPrediction(await res.json(), "prediction poll");
346
- if (prediction.status === "succeeded") return prediction;
347
- if (prediction.status === "failed" || prediction.status === "canceled") {
348
- throw failureError(prediction);
380
+ } catch (err) {
381
+ if (prediction.status !== "failed" && prediction.status !== "canceled") {
382
+ await cancelPrediction(key, initial);
349
383
  }
384
+ throw err;
350
385
  }
351
386
  }
352
387
  async function downloadOutputs(prediction, signal) {
@@ -387,8 +422,8 @@ function createReplicateEngine(opts) {
387
422
  // bria/expand-image: the picture plus the canvas it belongs in.
388
423
  supportsOutpaint: true,
389
424
  // 0, deliberately — see the same note in the fal adapter. generate()
390
- // sends only prompt/num_outputs/aspect_ratio, so a declared capacity
391
- // of 1 was a promise this adapter never kept.
425
+ // sends no reference image, only its prompt and settings, so a
426
+ // declared capacity of 1 was a promise this adapter never kept.
392
427
  maxReferenceImages: 0
393
428
  };
394
429
  },
@@ -409,7 +444,13 @@ function createReplicateEngine(opts) {
409
444
  {
410
445
  prompt: req.prompt,
411
446
  num_outputs: req.count,
412
- aspect_ratio: nearestAspectRatio(req.width, req.height)
447
+ aspect_ratio: nearestAspectRatio(req.width, req.height),
448
+ // Left alone, flux-schnell answers in webp at quality 80: a lossy
449
+ // frame the server then re-encodes to png anyway. A png arrives as
450
+ // delivered. The edit models need no such line: kontext-pro already
451
+ // answers in png, and bria's expander has no format field. The
452
+ // generate input assumes the flux-schnell schema, as aspect_ratio does.
453
+ output_format: "png"
413
454
  },
414
455
  signal
415
456
  );
@@ -490,24 +531,41 @@ async function sourceImageToDataUri(source) {
490
531
  const buf = await readFile(source);
491
532
  return `data:${mimeForPath(source)};base64,${buf.toString("base64")}`;
492
533
  }
493
- function extractImageUrls(json) {
534
+ function readReply(json) {
494
535
  if (typeof json !== "object" || json === null) {
495
536
  throw new Error("fal.ai response was not a JSON object");
496
537
  }
497
- const images = json.images;
538
+ const reply = json;
539
+ if (!Array.isArray(reply.images) && typeof reply.image === "object" && reply.image !== null) {
540
+ const url = reply.image.url;
541
+ if (typeof url !== "string" || url.length === 0) {
542
+ throw new Error('fal.ai response image missing "url"');
543
+ }
544
+ return { urls: [url], slots: [0] };
545
+ }
546
+ const images = reply.images;
498
547
  if (!Array.isArray(images)) {
499
- throw new Error('fal.ai response missing "images" array');
548
+ throw new Error('fal.ai response missing "images" array or "image" object');
500
549
  }
501
550
  if (images.length === 0) {
502
551
  throw new Error("fal.ai response contained no images");
503
552
  }
504
- return images.map((img, i) => {
553
+ const urls = images.map((img, i) => {
505
554
  const url = img?.url;
506
555
  if (typeof url !== "string" || url.length === 0) {
507
556
  throw new Error(`fal.ai response images[${i}] missing "url"`);
508
557
  }
509
558
  return url;
510
559
  });
560
+ const flagged = Array.isArray(reply.has_nsfw_concepts) ? reply.has_nsfw_concepts : [];
561
+ const slots = urls.map((_, i) => i).filter((i) => flagged[i] !== true);
562
+ if (slots.length === 0) {
563
+ throw new Error("fal.ai flagged every image as unsafe and returned only black frames, so none were kept");
564
+ }
565
+ return { urls: slots.map((i) => urls[i]), slots };
566
+ }
567
+ function extractImageUrls(json) {
568
+ return readReply(json).urls;
511
569
  }
512
570
  function createFalEngine(opts) {
513
571
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -600,13 +658,31 @@ function createFalEngine(opts) {
600
658
  {
601
659
  prompt: req.prompt,
602
660
  image_size: { width: req.width, height: req.height },
603
- num_images: req.count
661
+ num_images: req.count,
662
+ // fal defaults to jpeg. png costs nothing extra, and a shot that
663
+ // may be refined or extended later should not start out lossy.
664
+ output_format: "png"
604
665
  },
605
666
  signal
606
667
  );
607
- const urls = extractImageUrls(json);
668
+ const { urls, slots } = readReply(json);
608
669
  const images = await saveAll(urls, signal);
609
- return { images, costUsd: req.count * GENERATE_COST_PER_IMAGE_USD2, raw: json };
670
+ const costUsd = req.count * GENERATE_COST_PER_IMAGE_USD2;
671
+ if (slots.length === req.count) return { images, costUsd, raw: json };
672
+ const dropped = req.count - slots.length;
673
+ return {
674
+ images,
675
+ costUsd,
676
+ raw: {
677
+ ...json,
678
+ requested: req.count,
679
+ variantIndexes: slots,
680
+ partialFailures: Array.from(
681
+ { length: dropped },
682
+ () => "fal.ai flagged this image as unsafe and returned a black frame, so it was not kept"
683
+ )
684
+ }
685
+ };
610
686
  },
611
687
  async edit(req, signal) {
612
688
  const key = requireKey();
@@ -631,7 +707,12 @@ function createFalEngine(opts) {
631
707
  const images2 = await saveAll(urls2, signal);
632
708
  return { images: images2, costUsd: EDIT_COST_USD2, raw: json2 };
633
709
  }
634
- const json = await postJson(editModel, key, { prompt: req.instruction, image_url: imageUrl }, signal);
710
+ const json = await postJson(
711
+ editModel,
712
+ key,
713
+ { prompt: req.instruction, image_url: imageUrl, output_format: "png" },
714
+ signal
715
+ );
635
716
  const urls = extractImageUrls(json);
636
717
  const images = await saveAll(urls, signal);
637
718
  return { images, costUsd: EDIT_COST_USD2, raw: json };
@@ -948,6 +1029,7 @@ function createRunner(opts = {}) {
948
1029
  async function run4(args, signal, io) {
949
1030
  const exe = await resolution();
950
1031
  const timeoutMs = io?.timeoutMs ?? defaultTimeoutMs;
1032
+ const launchedAs = args[0] === "exec" ? fingerprintFor(exe, knownVersion) : null;
951
1033
  return new Promise((resolve, reject) => {
952
1034
  let child;
953
1035
  try {
@@ -1031,7 +1113,11 @@ function createRunner(opts = {}) {
1031
1113
  });
1032
1114
  child.on("exit", (code) => {
1033
1115
  if (code === 0) {
1034
- finish("ok", resolve);
1116
+ finish("ok", () => {
1117
+ if (launchedAs && resolved && launchedAs === fingerprintFor(resolved, knownVersion))
1118
+ noteConnection("proven");
1119
+ resolve();
1120
+ });
1035
1121
  return;
1036
1122
  }
1037
1123
  const limit = /You've hit your usage limit\.(?:[^\n]*?\btry again at ([^.\n]+)\.)?/.exec(stderr);
@@ -1266,8 +1352,9 @@ function createRunner(opts = {}) {
1266
1352
  if (outcome === "unproven") return;
1267
1353
  const exe = resolved;
1268
1354
  if (!exe) return;
1355
+ const wasRefused = connCache?.outcome === "refused";
1269
1356
  connCache = { outcome, failure, at: Date.now(), fingerprint: fingerprintFor(exe, knownVersion) };
1270
- cached = null;
1357
+ if (outcome === "refused" || wasRefused) cached = null;
1271
1358
  }
1272
1359
  function invalidateProbe() {
1273
1360
  cached = null;
@@ -1357,7 +1444,7 @@ function createCodexAnalyzer(opts = {}) {
1357
1444
  if (parsed.ok) return parsed.size;
1358
1445
  problems = parsed.problems;
1359
1446
  }
1360
- throw new Error(`Codex could not size this product: ${problems.join(" ")}`);
1447
+ return null;
1361
1448
  });
1362
1449
  }
1363
1450
  };
@@ -2040,6 +2127,7 @@ function demoOptionsFromEnv(env) {
2040
2127
  if (env.SCENRI_DEMO_FAIL_EDIT === "1") out.failEdit = true;
2041
2128
  const refs = Number(env.SCENRI_DEMO_REFS);
2042
2129
  if (env.SCENRI_DEMO_REFS && Number.isInteger(refs) && refs > 0) out.maxReferenceImages = refs;
2130
+ if (env.SCENRI_DEMO_PHOTOS) out.photosDir = env.SCENRI_DEMO_PHOTOS;
2043
2131
  return out;
2044
2132
  }
2045
2133
  function sleep2(ms, signal) {
@@ -2065,7 +2153,21 @@ function createDemoEngine(saveImage, opts = {}) {
2065
2153
  return hexes.length ? hexes : ["#4B5563", "#9CA3AF"];
2066
2154
  };
2067
2155
  const esc2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").slice(0, 90);
2156
+ let calls = 0;
2157
+ const photos = (() => {
2158
+ if (!opts.photosDir) return [];
2159
+ try {
2160
+ return readdirSync(opts.photosDir).filter((f) => /\.(jpe?g|png|webp)$/i.test(f)).map((f) => join(opts.photosDir, f));
2161
+ } catch {
2162
+ return [];
2163
+ }
2164
+ })();
2068
2165
  async function render(colors, label, w, h, seed) {
2166
+ if (photos.length) {
2167
+ calls += 1;
2168
+ const file = photos[(seed * 7919 + calls * 104729) % photos.length];
2169
+ return sharp22(file).resize(w, h, { fit: "cover" }).png().toBuffer();
2170
+ }
2069
2171
  const c0 = colors[seed % colors.length];
2070
2172
  const c1 = colors[(seed + 1) % colors.length] ?? "#111111";
2071
2173
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
@@ -4479,8 +4581,10 @@ function mergeScrape(existing, scraped) {
4479
4581
  }
4480
4582
 
4481
4583
  // src/assetRecords.ts
4584
+ var TYPED_TEXT_MAX = 4e3;
4585
+ var COMPOSED_TEXT_MAX = 8e3;
4482
4586
  var IDENTITY_EDITS_MAX = 8;
4483
- var IDENTITY_EDIT_CHARS = 120;
4587
+ var IDENTITY_EDIT_CHARS = TYPED_TEXT_MAX;
4484
4588
  var PRESENTER_SOURCES = ["synthetic", "photos"];
4485
4589
  var LIKENESS_VERSION = "v1";
4486
4590
  var PRESENTER_ID_PREFIX = "up";
@@ -4550,7 +4654,7 @@ function identityEditsOf(v) {
4550
4654
  return out;
4551
4655
  }
4552
4656
  var SUBJECTS2 = /* @__PURE__ */ new Set(["product", "person", "either"]);
4553
- var SCENE_INSTRUCTION_MAX = 800;
4657
+ var SCENE_INSTRUCTION_MAX = COMPOSED_TEXT_MAX;
4554
4658
  function sceneRecordFrom(input, base) {
4555
4659
  const has = (k) => input[k] !== void 0;
4556
4660
  const prompt = has("prompt") ? phrase(input.prompt, 2e3) : base?.prompt ?? "";
@@ -5003,21 +5107,31 @@ var toMarkPng = async (buf) => {
5003
5107
  }
5004
5108
  return out;
5005
5109
  };
5110
+ var SENDABLE_FORMATS = /* @__PURE__ */ new Set(["png", "jpeg", "webp", "gif"]);
5006
5111
  var cappedRefs = /* @__PURE__ */ new Map();
5007
5112
  async function capReferenceEdge(core, path, maxEdge) {
5008
5113
  const key = `${path}#${maxEdge}`;
5009
5114
  const hit = cappedRefs.get(key);
5010
5115
  if (hit && existsSync(hit)) return hit;
5011
- let out = path;
5116
+ let format;
5117
+ let buf = null;
5012
5118
  try {
5013
5119
  const meta = await sharp22(path).metadata();
5014
- if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
5015
- const buf = await sharp22(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
5016
- out = core.images.pathFor(core.images.save(buf));
5120
+ format = meta.format;
5121
+ const oversized = (meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge;
5122
+ if (oversized || !SENDABLE_FORMATS.has(String(format))) {
5123
+ buf = await sharp22(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
5017
5124
  }
5018
5125
  } catch {
5019
- return path;
5126
+ if (!existsSync(path) || SENDABLE_FORMATS.has(String(format))) return path;
5127
+ throw Object.assign(
5128
+ new Error(
5129
+ "One of the reference pictures is in a format that cannot be read. Save it as a JPEG or PNG and add it again."
5130
+ ),
5131
+ { statusCode: 400 }
5132
+ );
5020
5133
  }
5134
+ const out = buf ? core.images.pathFor(core.images.save(buf)) : path;
5021
5135
  cappedRefs.set(key, out);
5022
5136
  return out;
5023
5137
  }
@@ -5290,8 +5404,11 @@ function productEditFidelityDirective(name) {
5290
5404
  function characterEditIdentityDirective(name) {
5291
5405
  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 capture uniform of their reference photographs (${CAPTURE_UNIFORM}) or to all-white or all-cream plain basics like it.`;
5292
5406
  }
5293
- function referenceIdentityGuard() {
5294
- return "A reference shot lends its composition, lighting and treatment, never its cast: the attached presenter is the only source of person identity in this shot, and any person visible in a reference shot is a stand-in whose place the presenter takes. The stand-in's clothes are not part of the pose: unless this shot's own words dress the presenter, dress them for this place in clothes of their own that show their own build, never in the stand-in's outfit. This holds even where the direction asks to use someone from a reference \u2014 the attached presenter is that someone.";
5407
+ function referenceIdentityGuard(people = 1) {
5408
+ return "A reference shot lends its composition, lighting and treatment, never its cast: the attached presenter is the only source of person identity in this shot, and any person visible in a reference shot is a stand-in whose place the presenter takes. The stand-in's clothes are not part of the pose: unless this shot's own words dress the presenter, dress them for this place in clothes of their own that show their own build, never in the stand-in's outfit. Not a recoloured copy of it either: each garment and the shoes are a different kind from the stand-in's, in none of the stand-in's colours. This holds even where the direction asks to use someone from a reference \u2014 the attached presenter is that someone. " + // Nothing said who else may be in the frame, and read word for word a café
5409
+ // doorway filled with customers, a barista and people on the street in 3
5410
+ // of 4 (2026-09-27). People come from presenters, never from a place.
5411
+ (people > 1 ? "The attached presenters are the only people" : "The presenter is the only person") + " in this image unless this shot's own words ask for others: no customers, staff or passers-by, and nobody glimpsed through glass or in the distance.";
5295
5412
  }
5296
5413
  function referenceProductGuard() {
5297
5414
  return "A reference shot lends its composition, lighting and treatment, never its product: the attached product photo is the only source of product identity in this shot. Any product, package or object a reference shot features in that place only marks where the attached product goes, and the attached product takes it at its own real size, with its own shape, label and colours.";
@@ -5869,6 +5986,22 @@ function compileBrief(input, ctx) {
5869
5986
  );
5870
5987
  }
5871
5988
  }
5989
+ const sent = new Set(ctx.sourceHash ? [ctx.sourceHash] : []);
5990
+ const identitySent = /* @__PURE__ */ new Set();
5991
+ const copies = /* @__PURE__ */ new Set();
5992
+ for (const a of attachments) {
5993
+ if (a.role !== "product" && a.role !== "character") continue;
5994
+ const own = `${a.role}:${a.id}:${a.hash}`;
5995
+ if (identitySent.has(own)) copies.add(a);
5996
+ identitySent.add(own);
5997
+ sent.add(a.hash);
5998
+ }
5999
+ for (const a of attachments) {
6000
+ if (a.role === "product" || a.role === "character") continue;
6001
+ if (sent.has(a.hash)) copies.add(a);
6002
+ sent.add(a.hash);
6003
+ }
6004
+ for (let i = attachments.length - 1; i >= 0; i--) if (copies.has(attachments[i])) attachments.splice(i, 1);
5872
6005
  const max = ctx.engineCaps.maxReferenceImages;
5873
6006
  const {
5874
6007
  kept,
@@ -5961,7 +6094,7 @@ function compileBrief(input, ctx) {
5961
6094
  const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
5962
6095
  return String(rec?.category ?? "").toLowerCase() === "apparel";
5963
6096
  }) ? [garmentDisplayDirective()] : [];
5964
- const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
6097
+ const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard(people)] : [];
5965
6098
  const framed = ctx.mode !== "edit" && kept.some((a) => a.role === "reference" && frameRefs.has(a.hash));
5966
6099
  const refProductGuard = ctx.mode !== "edit" && productId && kept.some((a) => a.role === "reference") ? [referenceProductGuard()] : [];
5967
6100
  const allDirectives = [
@@ -6129,6 +6262,9 @@ function createContentFetcher(deps) {
6129
6262
  const expected = deps.sha256 !== void 0 ? deps.sha256 : custom ? null : CONTENT_SHA256;
6130
6263
  const enabled = () => env.SCENRI_NO_CONTENT_FETCH !== "1" && deps.store.getSetting("content.enabled") !== "false";
6131
6264
  let inflight = null;
6265
+ let gaveUp = false;
6266
+ let installs = 0;
6267
+ let stale = null;
6132
6268
  async function download() {
6133
6269
  const root = contentCacheRoot(env);
6134
6270
  if (!deps.store.getSetting("content.disclosed")) {
@@ -6170,15 +6306,25 @@ function createContentFetcher(deps) {
6170
6306
  async function ensure() {
6171
6307
  if (!enabled()) return { ok: false, updated: false, error: null };
6172
6308
  if (!contentCacheStale(env, custom)) return { ok: true, updated: false, error: null };
6173
- inflight ??= download().finally(() => {
6309
+ inflight ??= download().then((result) => {
6310
+ if (result.updated) installs += 1;
6311
+ else if (!result.ok) gaveUp = true;
6312
+ return result;
6313
+ }).finally(() => {
6174
6314
  inflight = null;
6315
+ stale = null;
6175
6316
  });
6176
6317
  return inflight;
6177
6318
  }
6319
+ function state() {
6320
+ if (!enabled() || gaveUp) return { arriving: false, installs };
6321
+ stale ??= contentCacheStale(env, custom);
6322
+ return { arriving: stale, installs };
6323
+ }
6178
6324
  function schedule() {
6179
6325
  setTimeout(() => void ensure(), 3e3).unref();
6180
6326
  }
6181
- return { enabled, ensure, schedule };
6327
+ return { enabled, ensure, schedule, state };
6182
6328
  }
6183
6329
  async function inspectMark(buf, toPng2) {
6184
6330
  const png = await toPng2(buf);
@@ -7670,7 +7816,8 @@ async function discoverCatalog(opts) {
7670
7816
  const estimatedTotal = discovered.estimatedTotal ?? discovered.productUrls.length;
7671
7817
  let empty = null;
7672
7818
  if (!estimatedTotal) {
7673
- empty = {
7819
+ const unreachable = await neverAnswered(baseUrl, fetchImpl, opts.signal);
7820
+ empty = unreachable ? { code: "unreachable", message: unreachable, retryable: true } : {
7674
7821
  code: "empty_catalog",
7675
7822
  message: detection.platform === "generic" ? "No public product catalog found. This store may be JavaScript-rendered or require authentication." : `No products discovered on this ${detection.platform} store.`
7676
7823
  };
@@ -7702,6 +7849,17 @@ async function discoverCatalog(opts) {
7702
7849
  }
7703
7850
  };
7704
7851
  }
7852
+ async function neverAnswered(baseUrl, fetchImpl, signal) {
7853
+ try {
7854
+ const res = await httpGet(baseUrl, { fetchImpl, signal, retries: 1, timeoutMs: 15e3 });
7855
+ await res.body?.cancel().catch(() => {
7856
+ });
7857
+ return null;
7858
+ } catch {
7859
+ if (signal?.aborted) return null;
7860
+ return `Could not reach ${new URL(baseUrl).hostname}.`;
7861
+ }
7862
+ }
7705
7863
 
7706
7864
  // ../catalog/src/robots.ts
7707
7865
  var ALLOW_ALL = { rules: [], crawlDelayMs: 0 };
@@ -8000,6 +8158,16 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
8000
8158
  platform = found.detection.platform;
8001
8159
  discovered = found.estimatedTotal;
8002
8160
  warnings = found.progress.warnings;
8161
+ if (found.empty?.code === "unreachable") {
8162
+ patch3({
8163
+ stage: "failed",
8164
+ errors: [found.empty],
8165
+ warnings: found.progress.warnings,
8166
+ message: found.empty.message,
8167
+ finished: true
8168
+ });
8169
+ return;
8170
+ }
8003
8171
  if (found.empty || !found.estimatedTotal) {
8004
8172
  const source = core.catalog.upsertSource(brandId, found.baseUrl, platform);
8005
8173
  patch3({ sourceId: source.id, platform });
@@ -9830,13 +9998,17 @@ function resolveSize(dimensions, stored2) {
9830
9998
  }
9831
9999
  async function drawAtScale(o) {
9832
10000
  const span = spanCm(o.size.largestCm);
10001
+ const edge = o.engine.capabilities().maxReferenceEdge;
10002
+ const [scenePath, productPath] = await Promise.all(
10003
+ [o.plan.sceneHash, o.plan.productHash].map((h) => o.images.pathFor(h)).map((p) => edge ? capReferenceEdge({ images: o.images }, p, edge) : p)
10004
+ );
9833
10005
  const plates = {
9834
10006
  prompt: platePrompt({ span, light: o.plan.light, shot: o.plan.shot }),
9835
10007
  brand: o.brand,
9836
10008
  width: o.width,
9837
10009
  height: o.height,
9838
10010
  count: o.count,
9839
- referenceImages: [o.images.pathFor(o.plan.sceneHash)],
10011
+ referenceImages: [scenePath],
9840
10012
  referenceRoles: ["scene"]
9841
10013
  };
9842
10014
  const instruction = placeInstruction({
@@ -9876,7 +10048,7 @@ async function drawAtScale(o) {
9876
10048
  instruction,
9877
10049
  sourceImage: o.images.pathFor(plate),
9878
10050
  brand: o.brand,
9879
- referenceImages: [o.images.pathFor(o.plan.productHash)],
10051
+ referenceImages: [productPath],
9880
10052
  referenceRoles: ["product"],
9881
10053
  width: o.width,
9882
10054
  height: o.height
@@ -9918,17 +10090,28 @@ async function drawAtScale(o) {
9918
10090
  raw: { requested: o.count, variantIndexes: slots, partialFailures: reasons }
9919
10091
  };
9920
10092
  }
9921
-
9922
- // src/productSizes.ts
9923
- function createProductSizes(core, reader) {
10093
+ var MISS_MS = 24 * 60 * 60 * 1e3;
10094
+ var missKey = (brandId, productId) => `product-size-miss:${brandId}:${productId}`;
10095
+ function createProductSizes(core, reader, clock = Date.now) {
9924
10096
  const reading = /* @__PURE__ */ new Map();
9925
10097
  const stored2 = (brandId, id) => readSize(core.store.getSetting(sizeKey(brandId, id)));
9926
10098
  const known = (brandId, p) => resolveSize(p.dimensions, stored2(brandId, p.id));
10099
+ const missed = (brandId, id, photo) => {
10100
+ try {
10101
+ const miss = JSON.parse(core.store.getSetting(missKey(brandId, id)) || "null");
10102
+ const age = clock() - Number(miss?.at);
10103
+ return miss?.photo === photo && age >= 0 && age < MISS_MS;
10104
+ } catch {
10105
+ return false;
10106
+ }
10107
+ };
9927
10108
  return {
9928
10109
  known,
9929
10110
  async ensure(brandId, p, signal) {
9930
10111
  const now2 = known(brandId, p);
9931
10112
  if (now2 || !p.photo) return now2;
10113
+ const name = basename(p.photo);
10114
+ if (missed(brandId, p.id, name)) return null;
9932
10115
  const key = sizeKey(brandId, p.id);
9933
10116
  let read2 = reading.get(key);
9934
10117
  if (!read2) {
@@ -9936,13 +10119,21 @@ function createProductSizes(core, reader) {
9936
10119
  read2 = (async () => {
9937
10120
  const analyzer = await reader();
9938
10121
  if (!analyzer?.measure) return null;
9939
- const got = await analyzer.measure(
9940
- { imagePath: photo, name: p.name, ...p.description ? { description: p.description } : {} },
9941
- signal
9942
- );
10122
+ let got = null;
10123
+ try {
10124
+ got = await analyzer.measure(
10125
+ { imagePath: photo, name: p.name, ...p.description ? { description: p.description } : {} },
10126
+ signal
10127
+ );
10128
+ } catch {
10129
+ return null;
10130
+ }
9943
10131
  const n = Number(got?.largestCm);
9944
10132
  const text = String(got?.text ?? "").trim();
9945
- if (!text || !Number.isFinite(n) || n <= 0) return null;
10133
+ if (!text || !Number.isFinite(n) || n <= 0) {
10134
+ core.store.setSetting(missKey(brandId, p.id), JSON.stringify({ photo: name, at: clock() }));
10135
+ return null;
10136
+ }
9946
10137
  const size = { text, largestCm: n, by: "estimate" };
9947
10138
  core.store.setSetting(key, JSON.stringify(size));
9948
10139
  return size;
@@ -10935,12 +11126,13 @@ function registerLogoRoutes(app, deps) {
10935
11126
  return allowed.includes(v) ? v : null;
10936
11127
  };
10937
11128
  app.post("/api/brands/:id/logos", async (req, reply) => {
11129
+ if (!core.store.getBrand(req.params.id)) return reply.status(404).send({ error: "brand not found" });
11130
+ const part = await readImagePart(core, req, toMarkPng);
11131
+ if ("error" in part) return reply.status(400).send({ error: part.error });
10938
11132
  const brand = core.store.getBrand(req.params.id);
10939
11133
  if (!brand) return reply.status(404).send({ error: "brand not found" });
10940
11134
  const json = { ...brand.json };
10941
11135
  const logos = [...readLogos(json)];
10942
- const part = await readImagePart(core, req, toMarkPng);
10943
- if ("error" in part) return reply.status(400).send({ error: part.error });
10944
11136
  const existing = logos.findIndex((l) => assetHash(l?.file) === part.hash);
10945
11137
  const role = enumField(
10946
11138
  part.fields?.role?.value,
@@ -11643,8 +11835,8 @@ function sweepPresenterDrafts(core) {
11643
11835
  }
11644
11836
  return swept;
11645
11837
  }
11646
- var KEEP_ITEM_CHARS = 200;
11647
- var SETUP_CHARS = 4e4;
11838
+ var KEEP_ITEM_CHARS = TYPED_TEXT_MAX;
11839
+ var SETUP_CHARS = 64 * TYPED_TEXT_MAX;
11648
11840
  var KEEP_ITEMS_MAX = 12;
11649
11841
  var SOURCES_MAX = 8;
11650
11842
  function keepItemsOf(raw, has) {
@@ -11686,18 +11878,18 @@ async function createPresenterDraft(deps, input) {
11686
11878
  const brand = core.store.getBrand(input.brandId);
11687
11879
  if (!brand) throw fail("brand not found", 404);
11688
11880
  const source = input.source === "synthetic" ? "synthetic" : "photos";
11689
- const direction = str4(input.direction, 400);
11881
+ const direction = str4(input.direction, COMPOSED_TEXT_MAX);
11690
11882
  const keep = str4(input.keep, 240);
11691
11883
  const keepItems = keepItemsOf(input.keepItems, (h) => core.images.has(h));
11692
11884
  const sources = [
11693
11885
  ...new Set((input.imageHashes ?? []).map(String).filter((h) => HASH.test(h) && core.images.has(h)))
11694
11886
  ].slice(0, SOURCES_MAX);
11695
11887
  if (source === "synthetic" && !direction) throw fail("describe who they are in a sentence", 400);
11696
- if (source === "synthetic" && !deps.engine) throw fail("no engine here can draw a person", 400);
11697
11888
  if (source === "photos" && !sources.length) throw fail("add at least one photo of this person", 400);
11698
11889
  if (source === "photos" && !input.attestation) {
11699
11890
  throw fail("confirm you have permission to use this person's likeness", 400);
11700
11891
  }
11892
+ if (!deps.engine) throw fail("no engine here can draw a person", 400);
11701
11893
  if (source === "photos" && closing) throw restarting();
11702
11894
  const clientKey = str4(input.clientKey, 80);
11703
11895
  const made = clientKey ? listPresenterDrafts(core, brand.id).find(
@@ -11965,7 +12157,7 @@ async function generateView(deps, id, view, opts = {}) {
11965
12157
  if (closing) throw restarting();
11966
12158
  if (isExtra(view) && !rec.extras) throw fail("extra views are built on request", 400);
11967
12159
  if (rec.source === "synthetic" && !rec.direction?.trim()) {
11968
- const said = str4(opts.adjustment, 400);
12160
+ const said = str4(opts.adjustment, TYPED_TEXT_MAX);
11969
12161
  if (!said) throw fail("describe who they are in a sentence", 400);
11970
12162
  rec = mutate(core, id, (r) => {
11971
12163
  r.direction = said;
@@ -11977,7 +12169,7 @@ async function generateView(deps, id, view, opts = {}) {
11977
12169
  if (rec.views[dep].status !== "approved") throw fail(`approve the ${VIEW_LABEL[dep]} first`, 400);
11978
12170
  }
11979
12171
  if (!engine || !engine.capabilities().maxReferenceImages) throw fail("no engine here can draw a person", 400);
11980
- const adjustment = str4(opts.adjustment, 240) || void 0;
12172
+ const adjustment = str4(opts.adjustment, TYPED_TEXT_MAX) || void 0;
11981
12173
  const decide = opts.decide === "auto" ? "auto" : void 0;
11982
12174
  const before = rec.views[view].status;
11983
12175
  const saved = mutate(core, id, (r) => {
@@ -12315,35 +12507,17 @@ async function redoView(deps, id, view) {
12315
12507
  r.decisions = [...r.decisions, { view, what: "again", at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-RESULTS_MAX);
12316
12508
  });
12317
12509
  }
12318
- async function usePhotoForView(deps, id, view, hash) {
12319
- if (!isView(view)) throw fail("no such view", 400);
12320
- if (running3.has(id)) throw fail("a view is still being drawn", 409);
12321
- const rec = getPresenterDraft(deps.core, id);
12322
- if (!rec) throw fail("draft not found", 404);
12323
- if (rec.source !== "photos" || !rec.sources.includes(hash)) throw fail("that is not one of their photos", 400);
12324
- return mutate(deps.core, id, (r) => {
12325
- const slot = r.views[view];
12326
- if (slot.hash === hash && slot.origin === "photo") return;
12327
- const gone = [slot.hash, slot.prior].filter((h) => !!h && !r.sources.includes(h));
12328
- r.views[view] = {
12329
- ...emptySlot(),
12330
- attempts: slot.attempts,
12331
- rejected: [...slot.rejected, ...gone],
12332
- status: "approved",
12333
- hash,
12334
- origin: "photo"
12335
- };
12336
- staleDependents(r, view);
12337
- });
12338
- }
12339
12510
  async function updatePresenterDraft(core, id, patch3) {
12340
12511
  return mutate(core, id, (r) => {
12341
12512
  if (patch3.extras !== void 0) r.extras = patch3.extras === true;
12342
12513
  if (patch3.name !== void 0) r.name = str4(patch3.name, 60);
12343
12514
  if (Array.isArray(patch3.facets))
12344
12515
  r.facets = patch3.facets.map((f) => str4(f, 40)).filter(Boolean).slice(0, 8);
12345
- if (patch3.direction !== void 0) r.direction = str4(patch3.direction, 400) || void 0;
12346
- if (patch3.setup !== void 0) r.setup = str4(patch3.setup, SETUP_CHARS) || void 0;
12516
+ if (patch3.direction !== void 0) r.direction = str4(patch3.direction, COMPOSED_TEXT_MAX) || void 0;
12517
+ if (patch3.setup !== void 0) {
12518
+ const setup = str4(patch3.setup, Number.POSITIVE_INFINITY);
12519
+ if (setup.length <= SETUP_CHARS) r.setup = setup || void 0;
12520
+ }
12347
12521
  const items = keepItemsOf(patch3.keepItems, (h) => core.images.has(h));
12348
12522
  if (items) writeItems(r, items);
12349
12523
  else {
@@ -12376,19 +12550,15 @@ async function saveNew(deps, rec, hooks) {
12376
12550
  const { core } = deps;
12377
12551
  const id = rec.id;
12378
12552
  if (!rec.name.trim()) throw fail("give them a name", 400);
12379
- const drawnSet = CORE_VIEWS.some(
12380
- (v) => v !== "portrait" && rec.views[v].origin === "generated" && !!rec.views[v].hash
12381
- );
12382
- const blind = !deps.engine && !drawnSet;
12383
- const required = blind ? ["portrait"] : [...CORE_VIEWS, ...EXTRA_VIEWS.filter((v) => rec.views[v].status !== "empty")];
12553
+ const required = [...CORE_VIEWS, ...EXTRA_VIEWS.filter((v) => rec.views[v].status !== "empty")];
12384
12554
  for (const v of required) {
12385
12555
  const s = rec.views[v];
12386
12556
  if (s.status === "stale") throw fail(`redo the ${VIEW_LABEL[v]}: it was built on a view you changed`, 400);
12387
12557
  if (s.status !== "approved" || !s.hash) throw fail(`approve the ${VIEW_LABEL[v]} first`, 400);
12558
+ if (rec.sources.includes(s.hash)) throw fail(`draw the ${VIEW_LABEL[v]} first`, 400);
12388
12559
  }
12389
- const approved = required.map((v) => rec.views[v].hash);
12390
- const shots = blind ? [...approved, ...rec.sources.filter((h) => !approved.includes(h))] : approved;
12391
- const angles = blind ? ["portrait"] : [...required];
12560
+ const shots = required.map((v) => rec.views[v].hash);
12561
+ const angles = [...required];
12392
12562
  const portraitFile = `asset:${shots[0]}`;
12393
12563
  const sourceFiles = rec.sources.map((h) => `asset:${h}`);
12394
12564
  const mode = presenterCropMode(portraitFile, sourceFiles, "portrait");
@@ -12777,12 +12947,16 @@ function createSceneExamples(deps) {
12777
12947
  const engineId = engine.capabilities().id;
12778
12948
  const brand = deps.brandContext(brandId);
12779
12949
  const path = (h) => deps.core.images.pathFor(h);
12950
+ const edge = engine.capabilities().maxReferenceEdge;
12780
12951
  return async (source, instruction, refs, role) => {
12952
+ const referenceImages = await Promise.all(
12953
+ refs.map(path).map((p) => edge ? capReferenceEdge(deps.core, p, edge) : p)
12954
+ );
12781
12955
  const req = {
12782
12956
  instruction,
12783
12957
  sourceImage: path(source),
12784
12958
  brand,
12785
- referenceImages: refs.map(path),
12959
+ referenceImages,
12786
12960
  referenceRoles: refs.map((_, i) => Array.isArray(role) ? role[i] : role),
12787
12961
  width: scene.width,
12788
12962
  height: scene.height
@@ -13161,7 +13335,7 @@ function createSceneExamples(deps) {
13161
13335
  // src/sceneStudio.ts
13162
13336
  var SHOT_READ = `This is one of their own product shots. Read only the place in it: the surface, the architecture, the materials, the light, the atmosphere and the layout. The product and any person in it are that shot's cast, not the place: leave both out and leave "figure" empty, and never transcribe its text or logos. If the shot shows too little of its place to build a world from, say so in "coverage" and describe only what is visible`;
13163
13337
  var STUDIO_PICTURES_MAX = 4;
13164
- var ASK_MAX = 400;
13338
+ var ASK_MAX = TYPED_TEXT_MAX;
13165
13339
  var KEEP_PER_BRAND2 = 24;
13166
13340
  var KEEP_MS2 = 24 * 60 * 60 * 1e3;
13167
13341
  var jobs = /* @__PURE__ */ new Map();
@@ -13169,6 +13343,17 @@ var running4 = /* @__PURE__ */ new Map();
13169
13343
  var tasks2 = /* @__PURE__ */ new Map();
13170
13344
  var readHolds = /* @__PURE__ */ new Map();
13171
13345
  var READ_HOLDS_KEPT = 500;
13346
+ var holdsKey = (hash) => `scene-holds:${hash}`;
13347
+ function heldIn(deps, hash) {
13348
+ const known = readHolds.get(hash);
13349
+ if (known) return known;
13350
+ try {
13351
+ const kept = deps.core.store.getSetting(holdsKey(hash));
13352
+ return kept ? holdsFrom(JSON.parse(kept)) : void 0;
13353
+ } catch {
13354
+ return void 0;
13355
+ }
13356
+ }
13172
13357
  var fail2 = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
13173
13358
  var oneLine2 = (v, max) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, max);
13174
13359
  function nameFromWords(words) {
@@ -13355,7 +13540,9 @@ function startSceneStudioJob(deps, input) {
13355
13540
  const checked = readingFrom(input.reading);
13356
13541
  if (!checked.ok) throw fail2(checked.error);
13357
13542
  reading = checked.reading;
13358
- if (reading.holds?.length === 0 && !hashes.every((h) => readHolds.get(h)?.length === 0)) delete reading.holds;
13543
+ if (reading.holds?.length === 0 && !hashes.every((h) => heldIn(deps, h)?.length === 0)) delete reading.holds;
13544
+ else if (reading.holds === void 0 && hashes.length && hashes.every((h) => heldIn(deps, h)?.length === 0))
13545
+ reading.holds = [];
13359
13546
  }
13360
13547
  const ask = oneLine2(input.ask, ASK_MAX);
13361
13548
  if (kind === "make") {
@@ -13431,7 +13618,13 @@ async function read(deps, job, input, signal) {
13431
13618
  const read2 = readingOfDraft(draft, fallback);
13432
13619
  const holds = imagePaths.length ? holdsFrom(draft.holds) : prior?.holds;
13433
13620
  if (imagePaths.length && holds) {
13434
- for (const h of hashes) readHolds.set(h, holds);
13621
+ for (const h of hashes) {
13622
+ readHolds.set(h, holds);
13623
+ try {
13624
+ deps.core.store.setSetting(holdsKey(h), JSON.stringify(holds));
13625
+ } catch {
13626
+ }
13627
+ }
13435
13628
  while (readHolds.size > READ_HOLDS_KEPT) readHolds.delete(readHolds.keys().next().value);
13436
13629
  }
13437
13630
  const hero = read2.hero ?? prior?.hero;
@@ -14100,15 +14293,6 @@ function registerPresenterDraftRoutes(app, deps) {
14100
14293
  const hash = String(req.body?.hash ?? "");
14101
14294
  return answer(reply, async () => restoreView(await buildDeps(), draft.id, viewOf(req), hash));
14102
14295
  });
14103
- app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/use-photo", async (req, reply) => {
14104
- const draft = draftOr404(req, reply);
14105
- if (!draft) return;
14106
- const body = req.body ?? {};
14107
- return answer(
14108
- reply,
14109
- async () => usePhotoForView(await buildDeps(), draft.id, viewOf(req), String(body.hash ?? ""))
14110
- );
14111
- });
14112
14296
  app.post("/api/brands/:id/presenter-drafts/:draftId/save", async (req, reply) => {
14113
14297
  const draft = draftOr404(req, reply);
14114
14298
  if (!draft) return;
@@ -14487,7 +14671,11 @@ function registerProjectRoutes(app, deps) {
14487
14671
  nodes: core.store.recentActivity(brand.id, limit),
14488
14672
  jobs: core.catalog.listRecentJobs(brand.id),
14489
14673
  studio: listStudioWork(core, brand.id, deps.sceneExampleJobs?.(brand.id) ?? []),
14490
- boot: BOOT
14674
+ boot: BOOT,
14675
+ // Machine-scoped like `boot`, and read on the same tick: whether library
14676
+ // pictures are still arriving, so the studio shows them as on their way
14677
+ // rather than missing, and reads its catalogs again once they land.
14678
+ content: app.content?.state()
14491
14679
  };
14492
14680
  });
14493
14681
  app.get("/api/brands/:id/workspace", async (req, reply) => {
@@ -14891,18 +15079,45 @@ function registerImageRoutes(app, deps) {
14891
15079
  }
14892
15080
 
14893
15081
  // src/release/notes.data.ts
15082
+ var HEADLINES_KEPT = 5;
15083
+ var FIRST_PUBLIC = "0.2.0";
14894
15084
  var RELEASES = [
15085
+ {
15086
+ version: "0.20.0",
15087
+ date: "2026-09-27",
15088
+ title: "Pictures develop in place, and What's new has its own page",
15089
+ announce: true,
15090
+ sections: [
15091
+ {
15092
+ heading: "Create",
15093
+ body: "While a picture is being made, a quiet swirl holds its place, and the picture develops out of it when it is ready. Refine a shot and the new refinement opens at once, swirling until it lands.",
15094
+ image: {
15095
+ file: "0.20.0-made-in-place.webp",
15096
+ alt: "Two shots from one prompt in Create: the first has landed, and the second is still being made, a swirl with its clock."
15097
+ }
15098
+ },
15099
+ {
15100
+ heading: "What's new",
15101
+ body: "Every update is on one page now, newest first: open What's new from Help, or from Settings > Updates. After a big update, a short window introduces it once."
15102
+ },
15103
+ {
15104
+ heading: "Fixes",
15105
+ body: "Try again starts one new picture, never two, and presenter and scene chats take long descriptions. Beside a reference picture, your presenter keeps their own clothes and no strangers join the shot."
15106
+ }
15107
+ ]
15108
+ },
14895
15109
  {
14896
15110
  version: "0.19.1",
14897
15111
  date: "2026-09-26",
15112
+ title: "Codex keeps no copy of your pictures",
14898
15113
  sections: [
14899
15114
  {
14900
- heading: "Codex",
15115
+ heading: "Privacy",
14901
15116
  body: "Codex keeps no copy of a picture once Scenri has it, neither a saved conversation nor a second file, and its plugins stay off while Scenri runs it."
14902
15117
  },
14903
15118
  {
14904
15119
  heading: "Library",
14905
- body: "The library download is checked against the exact archive this version was released with, and nothing is installed unless it matches."
15120
+ body: "The library download is checked before anything is installed, and one that does not match what this version expects is never used."
14906
15121
  },
14907
15122
  {
14908
15123
  heading: "Fixes",
@@ -14913,29 +15128,31 @@ var RELEASES = [
14913
15128
  {
14914
15129
  version: "0.19.0",
14915
15130
  date: "2026-09-26",
14916
- title: "A new Scenri library.",
15131
+ title: "A new library of products, presenters and scenes",
15132
+ announce: true,
14917
15133
  sections: [
14918
15134
  {
14919
15135
  heading: "Library",
14920
- body: "Scenri's library is new: 30 products, 19 presenters and 42 scenes, with 110 examples on Home. Every view and example is 1536 by 1920, and cards read sharp at every size they are shown."
15136
+ body: "The library now holds 30 products, 19 presenters and 42 scenes, with 110 examples on Home. Cards stay sharp at every size they are shown.",
15137
+ image: {
15138
+ file: "0.19.0-home-examples.webp",
15139
+ alt: "Home in a full-size window: the row of example kinds under the top bar, and the wall of examples made with the new library."
15140
+ }
14921
15141
  },
14922
15142
  {
14923
15143
  heading: "Scenes",
14924
- body: "A scene leads with its hero. Any of its pictures can be the frame a shot follows, from the scene's page or from its chip, whose picker now opens on the scene's own pictures. Five of the Home examples follow one this way."
15144
+ body: "Scene cards show each place in use, not empty. Any picture of a scene can be the frame a shot follows: press Use this view on the scene's page, or pick it from the scene's chip in your prompt."
14925
15145
  },
14926
15146
  {
14927
15147
  heading: "Create",
14928
- body: "A presenter is dressed for the place and holds a pose real physics allows, a product carries its written size into the brief, and a close-up follows the product wherever a set has one."
14929
- },
14930
- {
14931
- heading: "Fixes",
14932
- body: "Presenter and scene conversations recover from a failed press, a retry or a restart without losing their place. A brand kit rename made in a second window stays, Tab leaves a brief that holds a chip, a shot sent right after switching brands is filed in the brand on screen, and a deleted presenter's revisions stay out of a brand export."
15148
+ body: "Presenters are dressed for the place, and lean or sit only on things that could hold them. A scene's close-up moves in on the product, keeping the person only where they wear it."
14933
15149
  }
14934
15150
  ]
14935
15151
  },
14936
15152
  {
14937
15153
  version: "0.18.2",
14938
15154
  date: "2026-09-26",
15155
+ title: "The first library download finishes on a slow connection",
14939
15156
  sections: [
14940
15157
  {
14941
15158
  heading: "Fixes",
@@ -14946,119 +15163,142 @@ var RELEASES = [
14946
15163
  {
14947
15164
  version: "0.18.1",
14948
15165
  date: "2026-09-26",
15166
+ title: "Pictures made with Codex use far less of your ChatGPT plan",
15167
+ announce: true,
14949
15168
  sections: [
14950
15169
  {
14951
15170
  heading: "Codex",
14952
- body: "Pictures made with Codex use far less of your ChatGPT plan. Every run uses gpt-6-sol, draws each picture once and no longer pastes the finished picture into its own conversation. It needs Codex CLI 0.157.1 or newer, and Scenri says when to update."
15171
+ body: "Each picture is drawn once, and the finished picture is no longer pasted back into Codex's own conversation. It needs Codex CLI 0.157.1 or newer, and Scenri says when yours is too old."
14953
15172
  },
14954
15173
  {
14955
- heading: "Phone access",
14956
- body: "A phone with the access code can no longer delete the library, change provider keys or spend caps, install an update or shut Scenri down. Those stay on the computer running Scenri, and the phone no longer offers them."
15174
+ heading: "Local access",
15175
+ body: "A phone or tablet signed in with the code can no longer delete the library, change provider keys or spend caps, install an update or shut Scenri down. Only the computer running Scenri can."
14957
15176
  },
14958
15177
  {
14959
15178
  heading: "Fixes",
14960
- body: "A library download that stalls now gives up instead of waiting for the rest of the session, and store and brand scans no longer reach private network addresses."
15179
+ body: "A library download that stalls now stops, and the library you already have keeps working."
14961
15180
  }
14962
15181
  ]
14963
15182
  },
14964
15183
  {
14965
15184
  version: "0.18.0",
14966
15185
  date: "2026-09-25",
15186
+ title: "New shots start in portrait at Standard quality",
14967
15187
  sections: [
14968
15188
  {
14969
15189
  heading: "Create",
14970
- body: "A new shot opens at portrait, one picture, standard quality. A choice already saved still wins."
15190
+ body: "A new shot starts in portrait, with one picture at Standard quality. A setting you already saved still wins.",
15191
+ image: {
15192
+ file: "0.18.0-portrait-standard.webp",
15193
+ alt: "The settings a new shot starts with, Portrait, one picture and Standard, with the shape list open on Portrait."
15194
+ }
14971
15195
  },
14972
15196
  {
14973
15197
  heading: "Library",
14974
- body: "A presenter, a product and a scene are kept from their own page. Filing and size show as chips, an empty Keepers tab offers the whole wall, and catalog cards stay a readable size as the window narrows."
15198
+ body: "Keep a product, presenter or scene with the star on its own page, yours or Scenri's. A product's size shows as a chip and changes in Details."
14975
15199
  },
14976
15200
  {
14977
15201
  heading: "Shots",
14978
- body: "Download comes before archive or delete, and Keep stays last. The remove stays the same glass as the rest of the row until the pointer is on it."
14979
- },
14980
- {
14981
- heading: "Activity",
14982
- body: "An empty Activity panel is the heading and one sentence."
15202
+ body: "On a shot, Download comes first, then Archive or Delete, then the Keepers star."
14983
15203
  }
14984
15204
  ]
14985
15205
  },
14986
15206
  {
14987
15207
  version: "0.17.2",
14988
15208
  date: "2026-09-24",
15209
+ title: "A waiting prompt uses your presenters as they are now",
14989
15210
  sections: [
14990
15211
  {
14991
15212
  heading: "Presenters",
14992
- body: "A brief left waiting in Create brings its presenters back as they are now, so a shot started after you edit someone uses their new pictures."
15213
+ body: "A prompt left waiting in Create uses each presenter as they are now, so a shot started after you edit someone gets their new pictures.",
15214
+ image: {
15215
+ file: "0.17.2-presenter-prompt.webp",
15216
+ alt: "A waiting prompt, Amara and Kwame by a window, naming two of your presenters as chips with their faces."
15217
+ }
14993
15218
  }
14994
15219
  ]
14995
15220
  },
14996
15221
  {
14997
15222
  version: "0.17.1",
14998
15223
  date: "2026-09-24",
15224
+ title: "Presenters keep their own face, hair and build",
14999
15225
  sections: [
15000
15226
  {
15001
15227
  heading: "Presenters",
15002
- body: "A presenter keeps their own face, hair, build, glasses and tattoos when a scene or a reference picture shows someone else, with every mark on its own side. A picture used for a pose no longer dresses them in that person's clothes, and their descriptions are no longer cut off mid-word."
15228
+ body: "A presenter keeps their own face, hair, build, glasses and tattoos when a scene or reference picture shows someone else. A picture used for a pose no longer dresses them in that person's clothes.",
15229
+ image: {
15230
+ file: "0.17.1-presenter-identity.webp",
15231
+ alt: "Kwame, one of your presenters, as his page opens: his face, what he is filed under, and his hair and build."
15232
+ }
15003
15233
  }
15004
15234
  ]
15005
15235
  },
15006
15236
  {
15007
15237
  version: "0.17.0",
15008
15238
  date: "2026-09-24",
15239
+ title: "Select several of your own products, presenters or scenes",
15240
+ announce: true,
15009
15241
  sections: [
15010
15242
  {
15011
15243
  heading: "Library",
15012
- body: "Products, presenters and scenes of your own can be selected and then kept or deleted together from a bar at the bottom of the page, and renamed from their menu. Bookmarks are now Keepers, on every page and in every menu."
15244
+ body: "Select your own products, presenters or scenes from their cards, then keep or delete them together from the bar at the bottom of the page. Rename one from its menu. Bookmarks are now called Keepers.",
15245
+ image: {
15246
+ file: "0.17.0-select-several.webp",
15247
+ alt: "Five of your scenes in a row, three of them picked, and the bar that keeps or deletes them together."
15248
+ }
15013
15249
  },
15014
15250
  {
15015
- heading: "Create",
15016
- body: "Home, Create and the libraries share one tab row, with search and size at its end. On a phone, Create keeps its tabs readable and puts the set and the sort in one button beside search. Choosing a tab while scrolled down starts the list from the top."
15251
+ heading: "Tabs",
15252
+ body: "Home, Create, Products, Presenters and Scenes share one tab row, with search and size at its end. On a phone, Create keeps its tabs readable and puts the set and the sort in one button beside search."
15017
15253
  },
15018
15254
  {
15019
15255
  heading: "Fixes",
15020
- body: "Tab counts on Create no longer jump while the page loads. Closing and opening the assets panel keeps the shot you were looking at in place, and alerts no longer cover the composer's buttons."
15256
+ body: "Tab counts on Create no longer jump while it loads, a tab chosen while scrolled down starts at the top, and closing and opening the Assets panel keeps the shot you were on in place."
15021
15257
  }
15022
15258
  ]
15023
15259
  },
15024
15260
  {
15025
15261
  version: "0.16.0",
15026
15262
  date: "2026-09-24",
15027
- title: "Open Scenri on your phone or tablet, and Settings with one page for each thing.",
15263
+ title: "Open Scenri on your phone or tablet",
15264
+ announce: true,
15028
15265
  sections: [
15029
15266
  {
15030
15267
  heading: "Local access",
15031
- body: "Scenri now opens on a phone, tablet or another computer on the same Wi-Fi. Scan the QR code in Settings > Local access, or type the address and enter the six-digit code. The first start may ask whether Node can accept incoming connections; allow it, and if the firewall still stands in the way, Local access says so and, where it can, Allow Scenri fixes it through your computer's own prompt."
15268
+ body: "Open Scenri on a phone, tablet or another computer on the same Wi-Fi: scan the QR code in Settings > Local access. If your computer's firewall stops it, Local access says so and offers Allow Scenri.",
15269
+ image: {
15270
+ file: "0.16.0-local-access.webp",
15271
+ alt: "The Local access card: a QR code to scan, the address and six-digit code to type, and an iPhone just connected."
15272
+ }
15032
15273
  },
15033
15274
  {
15034
15275
  heading: "Security",
15035
- body: "Only devices with the code get in, and wrong guesses are slowed to a crawl. New code, on the computer running Scenri, signs every device out."
15276
+ body: "Only devices with the six-digit code get in, and wrong guesses have to wait. Press New code to sign every device out."
15036
15277
  },
15037
15278
  {
15038
15279
  heading: "Settings",
15039
- body: "Providers, Appearance, Library, Local access, Updates and About each have their own page, and every delete sits under Danger zone. On a phone, a link to a Settings page opens that page."
15280
+ body: "One page for each thing: Providers, Appearance, Library, Local access, Updates and About, with every delete under Danger zone."
15040
15281
  }
15041
- ]
15282
+ ],
15283
+ newFeatures: ["local-access"]
15042
15284
  },
15043
15285
  {
15044
15286
  version: "0.15.1",
15045
15287
  date: "2026-09-23",
15288
+ title: "Make a scene by describing it, or by choosing from pictures",
15289
+ announce: true,
15046
15290
  sections: [
15047
15291
  {
15048
15292
  heading: "Scenes",
15049
- body: "Scenes are now made in a conversation. Describe a place, start from your own pictures or from a shot you already made, or let Guide Me offer worlds, surfaces and light as pictures to choose from. The pictures you give a scene set its mood and are never copied into it. Each scene has its own page, and setups let one world be shot several ways."
15050
- },
15051
- {
15052
- heading: "Shots",
15053
- body: "A small product alone in a scene is drawn at its real size. Glossy products such as phones and laptops are lit and staged like a campaign shoot, with the screen dark unless you ask for something on it. An attached picture now means what the words beside it say, so a screenshot can go on a device's screen, redesigned for that screen when it was made for another shape, and a refine can swap it for a different one."
15293
+ body: "Describe a place, start from your own pictures or a shot you already made, or press Guide me and choose a world, its surface and its light from pictures. Your pictures set the mood and are never copied."
15054
15294
  },
15055
15295
  {
15056
15296
  heading: "Activity",
15057
- body: "Scene and presenter work carries on after you leave the page and shows in the bell and in Activity. A scene still being made stays on the Scenes wall."
15297
+ body: "Scene and presenter work keeps going when you leave the page, and shows in the bell and in Activity. A scene still being made stays on the Scenes wall."
15058
15298
  },
15059
15299
  {
15060
- heading: "Create",
15061
- body: "Searching your shots narrows from the first letter, in any alphabet, and matches the words you wrote."
15300
+ heading: "Shots",
15301
+ body: "A small product alone in a scene is drawn at its real size. Phones, laptops and other glossy products are lit like a campaign shoot, and an attached picture can go on a device's screen when your words say so."
15062
15302
  }
15063
15303
  ]
15064
15304
  },
@@ -15073,10 +15313,11 @@ var RELEASES = [
15073
15313
  {
15074
15314
  version: "0.14.0",
15075
15315
  date: "2026-09-22",
15316
+ title: "Settings is redesigned with six pages",
15076
15317
  sections: [
15077
15318
  {
15078
15319
  heading: "Settings",
15079
- body: "Settings is redesigned with six pages instead of eight: Brand kit and Usage sit under the brand, Providers now includes the monthly caps, and General holds the theme and the library. On a phone it opens as a sheet, with the list of pages first and Back from each one."
15320
+ body: "Six pages instead of eight: Brand kit and Usage sit under the brand, Providers now includes the monthly caps, and General holds the theme and the library. On a phone it opens as a sheet, with the list of pages first and Back from each one."
15080
15321
  },
15081
15322
  {
15082
15323
  heading: "Providers",
@@ -15091,16 +15332,18 @@ var RELEASES = [
15091
15332
  {
15092
15333
  version: "0.13.2",
15093
15334
  date: "2026-09-22",
15335
+ title: "Presenters show an expression that fits the shot",
15094
15336
  sections: [
15095
15337
  {
15096
15338
  heading: "Presenters",
15097
- body: "Presenters in shots now show an expression that fits the moment and look where the shot directs, instead of a blank stare into the camera. When a presenter has a detail on one side, such as a tattoo or a prosthetic, approving their face and full body now says which side to check."
15339
+ body: "In a shot, a presenter now shows an expression that fits the moment and looks where the shot directs, instead of a blank stare into the camera. When a presenter has a detail on one side, such as a tattoo or a prosthetic, approving their face and full body now says which side to check."
15098
15340
  }
15099
15341
  ]
15100
15342
  },
15101
15343
  {
15102
15344
  version: "0.13.1",
15103
15345
  date: "2026-09-22",
15346
+ title: "Alerts work without closing the dialog beneath them",
15104
15347
  sections: [
15105
15348
  {
15106
15349
  heading: "Alerts",
@@ -15119,15 +15362,16 @@ var RELEASES = [
15119
15362
  {
15120
15363
  version: "0.13.0",
15121
15364
  date: "2026-09-21",
15122
- title: "Learn teaches Scenri one lesson at a time, alerts are quieter, and the Create feed holds still.",
15365
+ title: "Learn teaches Scenri one lesson at a time, alerts are quieter, and the Create feed holds still",
15366
+ announce: true,
15123
15367
  sections: [
15124
15368
  {
15125
15369
  heading: "Learn",
15126
- body: "Learn sits in the top bar, or under Help on a smaller screen, and teaches Scenri in six short lessons: your first shot, adding a product, a presenter, a scene, using a product again, and refining a shot. Each lesson keeps its own place, so you can set one down, take up another and come back to it. A new install is offered a guided first shot, and the guide can step back when you change your mind."
15370
+ body: "Six short lessons, in the top bar or under Help on a smaller screen, teach Scenri: your first shot, adding a product, a presenter, a scene, using a product again, and refining a shot. Each lesson keeps its own place, so you can set one down, take up another and come back to it. A new install is offered a guided first shot, and the guide can step back when you change your mind."
15127
15371
  },
15128
15372
  {
15129
15373
  heading: "Alerts",
15130
- body: "Alerts are smaller and stack at the bottom of the screen, above the composer rather than over it. The same event twice shares one card, an error stays until you close it, and an alert holds still while you point at it or tab into it."
15374
+ body: "Each alert is smaller, and they stack at the bottom of the screen, above the composer rather than over it. The same event twice shares one card, an error stays until you close it, and an alert holds still while you point at it or tab into it."
15131
15375
  },
15132
15376
  {
15133
15377
  heading: "Create",
@@ -15138,7 +15382,8 @@ var RELEASES = [
15138
15382
  {
15139
15383
  version: "0.12.0",
15140
15384
  date: "2026-09-20",
15141
- title: "Saved work shows up everywhere at once, and a selection has its own toolbar.",
15385
+ title: "Saved work shows up everywhere at once, and a selection has its own toolbar",
15386
+ announce: true,
15142
15387
  sections: [
15143
15388
  {
15144
15389
  heading: "Create",
@@ -15157,6 +15402,7 @@ var RELEASES = [
15157
15402
  {
15158
15403
  version: "0.11.1",
15159
15404
  date: "2026-09-18",
15405
+ title: "Learn and the guided first shot are paused",
15160
15406
  sections: [
15161
15407
  {
15162
15408
  heading: "Learn",
@@ -15171,7 +15417,8 @@ var RELEASES = [
15171
15417
  {
15172
15418
  version: "0.11.0",
15173
15419
  date: "2026-09-18",
15174
- title: "A new top bar, and a tutor that makes your first shot with you.",
15420
+ title: "A new top bar, and a tutor that makes your first shot with you",
15421
+ announce: true,
15175
15422
  sections: [
15176
15423
  {
15177
15424
  heading: "Top bar",
@@ -15194,6 +15441,7 @@ var RELEASES = [
15194
15441
  {
15195
15442
  version: "0.10.3",
15196
15443
  date: "2026-09-18",
15444
+ title: "Scenri no longer pauses right after the studio opens",
15197
15445
  sections: [
15198
15446
  {
15199
15447
  heading: "Fixes",
@@ -15204,6 +15452,7 @@ var RELEASES = [
15204
15452
  {
15205
15453
  version: "0.10.2",
15206
15454
  date: "2026-09-17",
15455
+ title: "A presenter draft opens where the conversation left off",
15207
15456
  sections: [
15208
15457
  {
15209
15458
  heading: "Presenters",
@@ -15214,7 +15463,8 @@ var RELEASES = [
15214
15463
  {
15215
15464
  version: "0.10.1",
15216
15465
  date: "2026-09-16",
15217
- title: "A saved presenter can be duplicated or deleted straight from its card.",
15466
+ title: "A saved presenter can be duplicated or deleted straight from its card",
15467
+ announce: true,
15218
15468
  sections: [
15219
15469
  {
15220
15470
  heading: "Presenters",
@@ -15237,7 +15487,8 @@ var RELEASES = [
15237
15487
  {
15238
15488
  version: "0.10.0",
15239
15489
  date: "2026-09-16",
15240
- title: "Presenters are cast by talking to Scenri, one question at a time.",
15490
+ title: "Presenters are cast by talking to Scenri, one question at a time",
15491
+ announce: true,
15241
15492
  sections: [
15242
15493
  {
15243
15494
  heading: "Presenters",
@@ -15252,7 +15503,8 @@ var RELEASES = [
15252
15503
  {
15253
15504
  version: "0.9.4",
15254
15505
  date: "2026-09-14",
15255
- title: "A store that asks Scenri to slow down no longer looks like an empty one.",
15506
+ title: "A store that asks Scenri to slow down no longer looks like an empty one",
15507
+ announce: true,
15256
15508
  sections: [
15257
15509
  {
15258
15510
  heading: "Products",
@@ -15267,7 +15519,8 @@ var RELEASES = [
15267
15519
  {
15268
15520
  version: "0.9.3",
15269
15521
  date: "2026-09-14",
15270
- title: "Paste a website, get the brand and its products.",
15522
+ title: "Paste a website, get the brand and its products",
15523
+ announce: true,
15271
15524
  sections: [
15272
15525
  {
15273
15526
  heading: "Brand",
@@ -15290,6 +15543,7 @@ var RELEASES = [
15290
15543
  {
15291
15544
  version: "0.9.2",
15292
15545
  date: "2026-09-07",
15546
+ title: "A Codex CLI too old for its model now says so",
15293
15547
  sections: [
15294
15548
  {
15295
15549
  heading: "Fixes",
@@ -15300,6 +15554,7 @@ var RELEASES = [
15300
15554
  {
15301
15555
  version: "0.9.1",
15302
15556
  date: "2026-09-07",
15557
+ title: "The Windows desktop icon never fails in silence",
15303
15558
  sections: [
15304
15559
  {
15305
15560
  heading: "Desktop",
@@ -15311,6 +15566,7 @@ var RELEASES = [
15311
15566
  version: "0.9.0",
15312
15567
  date: "2026-09-06",
15313
15568
  title: "Scenri on your desktop",
15569
+ announce: true,
15314
15570
  sections: [
15315
15571
  {
15316
15572
  heading: "Desktop",
@@ -15329,6 +15585,7 @@ var RELEASES = [
15329
15585
  {
15330
15586
  version: "0.8.3",
15331
15587
  date: "2026-09-06",
15588
+ title: "One picker for everything you add to a shot",
15332
15589
  sections: [
15333
15590
  {
15334
15591
  heading: "Create",
@@ -15351,6 +15608,7 @@ var RELEASES = [
15351
15608
  {
15352
15609
  version: "0.8.2",
15353
15610
  date: "2026-09-03",
15611
+ title: "Security updates to the server and its URL parser",
15354
15612
  sections: [
15355
15613
  {
15356
15614
  heading: "Security",
@@ -15361,7 +15619,8 @@ var RELEASES = [
15361
15619
  {
15362
15620
  version: "0.8.1",
15363
15621
  date: "2026-09-03",
15364
- title: "The same speed at any size.",
15622
+ title: "The same speed at any size",
15623
+ announce: true,
15365
15624
  sections: [
15366
15625
  {
15367
15626
  heading: "Create",
@@ -15380,7 +15639,8 @@ var RELEASES = [
15380
15639
  {
15381
15640
  version: "0.8.0",
15382
15641
  date: "2026-09-02",
15383
- title: "The composer, rebuilt around what a shot is made of.",
15642
+ title: "The composer, rebuilt around what a shot is made of",
15643
+ announce: true,
15384
15644
  sections: [
15385
15645
  {
15386
15646
  heading: "Create",
@@ -15403,7 +15663,8 @@ var RELEASES = [
15403
15663
  {
15404
15664
  version: "0.7.5",
15405
15665
  date: "2026-09-01",
15406
- title: "Every shot is one card.",
15666
+ title: "Every shot is one card",
15667
+ announce: true,
15407
15668
  sections: [
15408
15669
  {
15409
15670
  heading: "Create",
@@ -15422,6 +15683,7 @@ var RELEASES = [
15422
15683
  {
15423
15684
  version: "0.7.4",
15424
15685
  date: "2026-08-31",
15686
+ title: "A failed Codex shot says why it failed",
15425
15687
  sections: [
15426
15688
  {
15427
15689
  heading: "Fixes",
@@ -15432,6 +15694,7 @@ var RELEASES = [
15432
15694
  {
15433
15695
  version: "0.7.3",
15434
15696
  date: "2026-08-31",
15697
+ title: "A finished tile no longer guesses its shape",
15435
15698
  sections: [
15436
15699
  {
15437
15700
  heading: "Fixes",
@@ -15442,7 +15705,8 @@ var RELEASES = [
15442
15705
  {
15443
15706
  version: "0.7.2",
15444
15707
  date: "2026-08-30",
15445
- title: "Changing a shot to a new shape keeps the photograph.",
15708
+ title: "Changing a shot to a new shape keeps the photograph",
15709
+ announce: true,
15446
15710
  sections: [
15447
15711
  {
15448
15712
  heading: "Create",
@@ -15457,7 +15721,8 @@ var RELEASES = [
15457
15721
  {
15458
15722
  version: "0.7.1",
15459
15723
  date: "2026-08-30",
15460
- title: "Four images from one brief are one set.",
15724
+ title: "Four images from one prompt are one set",
15725
+ announce: true,
15461
15726
  sections: [
15462
15727
  {
15463
15728
  heading: "Create",
@@ -15476,7 +15741,8 @@ var RELEASES = [
15476
15741
  {
15477
15742
  version: "0.7.0",
15478
15743
  date: "2026-08-30",
15479
- title: "Your presenter stays your presenter.",
15744
+ title: "Your presenter stays your presenter",
15745
+ announce: true,
15480
15746
  sections: [
15481
15747
  {
15482
15748
  heading: "Scenes",
@@ -15495,6 +15761,7 @@ var RELEASES = [
15495
15761
  {
15496
15762
  version: "0.6.13",
15497
15763
  date: "2026-08-30",
15764
+ title: "Products keep their colour, and your logo joins the shot",
15498
15765
  sections: [
15499
15766
  {
15500
15767
  heading: "Products",
@@ -15513,6 +15780,7 @@ var RELEASES = [
15513
15780
  {
15514
15781
  version: "0.6.12",
15515
15782
  date: "2026-08-29",
15783
+ title: "Reference images show what they are",
15516
15784
  sections: [
15517
15785
  {
15518
15786
  heading: "Create",
@@ -15523,6 +15791,7 @@ var RELEASES = [
15523
15791
  {
15524
15792
  version: "0.6.11",
15525
15793
  date: "2026-08-29",
15794
+ title: "Hitting your Codex usage limit says when it comes back",
15526
15795
  sections: [
15527
15796
  {
15528
15797
  heading: "Create",
@@ -15533,7 +15802,8 @@ var RELEASES = [
15533
15802
  {
15534
15803
  version: "0.6.10",
15535
15804
  date: "2026-08-29",
15536
- title: "Pictures keep their shape.",
15805
+ title: "Pictures keep their shape",
15806
+ announce: true,
15537
15807
  sections: [
15538
15808
  {
15539
15809
  heading: "Create",
@@ -15548,7 +15818,8 @@ var RELEASES = [
15548
15818
  {
15549
15819
  version: "0.6.9",
15550
15820
  date: "2026-08-29",
15551
- title: "Refinements keep the shot.",
15821
+ title: "Refinements keep the shot",
15822
+ announce: true,
15552
15823
  sections: [
15553
15824
  {
15554
15825
  heading: "Refine",
@@ -15567,6 +15838,7 @@ var RELEASES = [
15567
15838
  {
15568
15839
  version: "0.6.8",
15569
15840
  date: "2026-08-29",
15841
+ title: "Closing a new form ends it, with Undo if you did not mean it",
15570
15842
  sections: [
15571
15843
  {
15572
15844
  heading: "Create dialogs",
@@ -15581,6 +15853,7 @@ var RELEASES = [
15581
15853
  {
15582
15854
  version: "0.6.7",
15583
15855
  date: "2026-08-29",
15856
+ title: "The prompt starts empty, with no scene you did not pick",
15584
15857
  sections: [
15585
15858
  {
15586
15859
  heading: "Create",
@@ -15591,6 +15864,7 @@ var RELEASES = [
15591
15864
  {
15592
15865
  version: "0.6.6",
15593
15866
  date: "2026-08-29",
15867
+ title: "Removing a chip works on the first click",
15594
15868
  sections: [
15595
15869
  {
15596
15870
  heading: "Create",
@@ -15605,6 +15879,7 @@ var RELEASES = [
15605
15879
  {
15606
15880
  version: "0.6.5",
15607
15881
  date: "2026-08-29",
15882
+ title: "A scene no longer brings its own demo object",
15608
15883
  sections: [
15609
15884
  {
15610
15885
  heading: "Scenes",
@@ -15615,6 +15890,7 @@ var RELEASES = [
15615
15890
  {
15616
15891
  version: "0.6.4",
15617
15892
  date: "2026-08-29",
15893
+ title: "New forms no longer carry photos from something else",
15618
15894
  sections: [
15619
15895
  {
15620
15896
  heading: "Create",
@@ -15625,7 +15901,8 @@ var RELEASES = [
15625
15901
  {
15626
15902
  version: "0.6.3",
15627
15903
  date: "2026-08-28",
15628
- title: "Your logo stays your logo, and every page opens like a page.",
15904
+ title: "Your logo stays your logo, and every page opens like a page",
15905
+ announce: true,
15629
15906
  sections: [
15630
15907
  {
15631
15908
  heading: "Brand",
@@ -15640,7 +15917,8 @@ var RELEASES = [
15640
15917
  {
15641
15918
  version: "0.6.2",
15642
15919
  date: "2026-08-28",
15643
- title: "Scenes keep what makes them distinctive.",
15920
+ title: "Scenes keep what makes them distinctive",
15921
+ announce: true,
15644
15922
  sections: [
15645
15923
  {
15646
15924
  heading: "Scenes",
@@ -15663,6 +15941,7 @@ var RELEASES = [
15663
15941
  {
15664
15942
  version: "0.6.1",
15665
15943
  date: "2026-08-26",
15944
+ title: "Refining a shot opens at its own shape",
15666
15945
  sections: [
15667
15946
  {
15668
15947
  heading: "Shots",
@@ -15673,6 +15952,7 @@ var RELEASES = [
15673
15952
  {
15674
15953
  version: "0.6.0",
15675
15954
  date: "2026-08-26",
15955
+ title: "A wider or taller shot keeps the original photograph",
15676
15956
  sections: [
15677
15957
  {
15678
15958
  heading: "Shots",
@@ -15687,6 +15967,7 @@ var RELEASES = [
15687
15967
  {
15688
15968
  version: "0.5.1",
15689
15969
  date: "2026-08-26",
15970
+ title: "An extended shot keeps its subject where it was",
15690
15971
  sections: [
15691
15972
  {
15692
15973
  heading: "Shots",
@@ -15697,7 +15978,8 @@ var RELEASES = [
15697
15978
  {
15698
15979
  version: "0.5.0",
15699
15980
  date: "2026-08-26",
15700
- title: "A finished shot can change shape, and a presenter you attach is a requirement rather than a suggestion.",
15981
+ title: "A finished shot can change shape, and a presenter you attach is a requirement rather than a suggestion",
15982
+ announce: true,
15701
15983
  sections: [
15702
15984
  {
15703
15985
  heading: "Create",
@@ -15720,7 +16002,8 @@ var RELEASES = [
15720
16002
  {
15721
16003
  version: "0.4.7",
15722
16004
  date: "2026-08-24",
15723
- title: "The feed shows the work, and the controls wait until you point at it.",
16005
+ title: "The feed shows the work, and the controls wait until you point at it",
16006
+ announce: true,
15724
16007
  sections: [
15725
16008
  {
15726
16009
  heading: "Create",
@@ -15739,6 +16022,7 @@ var RELEASES = [
15739
16022
  {
15740
16023
  version: "0.4.6",
15741
16024
  date: "2026-08-24",
16025
+ title: "Removing something from a shot leaves no outline",
15742
16026
  sections: [
15743
16027
  {
15744
16028
  heading: "Refining",
@@ -15753,7 +16037,8 @@ var RELEASES = [
15753
16037
  {
15754
16038
  version: "0.4.5",
15755
16039
  date: "2026-08-23",
15756
- title: "New versions find you while Scenri runs.",
16040
+ title: "New versions find you while Scenri runs",
16041
+ announce: true,
15757
16042
  sections: [
15758
16043
  {
15759
16044
  heading: "Updates",
@@ -15768,7 +16053,8 @@ var RELEASES = [
15768
16053
  {
15769
16054
  version: "0.4.4",
15770
16055
  date: "2026-08-23",
15771
- title: "Codex setup tells the truth, and a stuck generation fails instead of running forever.",
16056
+ title: "Codex setup tells the truth, and a stuck generation fails instead of running forever",
16057
+ announce: true,
15772
16058
  sections: [
15773
16059
  {
15774
16060
  heading: "Codex",
@@ -15783,7 +16069,8 @@ var RELEASES = [
15783
16069
  {
15784
16070
  version: "0.4.3",
15785
16071
  date: "2026-08-23",
15786
- title: "Controls hold still when you press them.",
16072
+ title: "Controls hold still when you press them",
16073
+ announce: true,
15787
16074
  sections: [
15788
16075
  {
15789
16076
  heading: "Fixes",
@@ -15794,6 +16081,7 @@ var RELEASES = [
15794
16081
  {
15795
16082
  version: "0.4.2",
15796
16083
  date: "2026-08-23",
16084
+ title: "Settings tells the update story in one row",
15797
16085
  sections: [
15798
16086
  {
15799
16087
  heading: "Updates",
@@ -15804,7 +16092,8 @@ var RELEASES = [
15804
16092
  {
15805
16093
  version: "0.4.1",
15806
16094
  date: "2026-08-23",
15807
- title: "Updates now arrive by themselves.",
16095
+ title: "Updates now arrive by themselves",
16096
+ announce: true,
15808
16097
  sections: [
15809
16098
  {
15810
16099
  heading: "Updates",
@@ -15819,7 +16108,8 @@ var RELEASES = [
15819
16108
  {
15820
16109
  version: "0.4.0",
15821
16110
  date: "2026-08-23",
15822
- title: "Refining a shot keeps the shot.",
16111
+ title: "Refining a shot keeps the shot",
16112
+ announce: true,
15823
16113
  sections: [
15824
16114
  {
15825
16115
  heading: "Refining",
@@ -15842,6 +16132,7 @@ var RELEASES = [
15842
16132
  {
15843
16133
  version: "0.3.5",
15844
16134
  date: "2026-08-21",
16135
+ title: "Codex setup on Windows sees a successful install",
15845
16136
  sections: [
15846
16137
  {
15847
16138
  heading: "Fixes",
@@ -15852,6 +16143,7 @@ var RELEASES = [
15852
16143
  {
15853
16144
  version: "0.3.4",
15854
16145
  date: "2026-08-21",
16146
+ title: "A presenter's photos define who they are, not what they wear",
15855
16147
  sections: [
15856
16148
  {
15857
16149
  heading: "Presenters",
@@ -15873,6 +16165,7 @@ var RELEASES = [
15873
16165
  {
15874
16166
  version: "0.3.2",
15875
16167
  date: "2026-08-20",
16168
+ title: "Codex CLI setup offers the command that gets past a refusal",
15876
16169
  sections: [
15877
16170
  {
15878
16171
  heading: "Fixes",
@@ -15883,6 +16176,7 @@ var RELEASES = [
15883
16176
  {
15884
16177
  version: "0.3.1",
15885
16178
  date: "2026-08-20",
16179
+ title: "Starting Scenri explains what went wrong in plain words",
15886
16180
  sections: [
15887
16181
  {
15888
16182
  heading: "Fixes",
@@ -15893,7 +16187,8 @@ var RELEASES = [
15893
16187
  {
15894
16188
  version: "0.3.0",
15895
16189
  date: "2026-08-20",
15896
- title: "The name is Scenri.",
16190
+ title: "The name is Scenri",
16191
+ announce: true,
15897
16192
  sections: [
15898
16193
  {
15899
16194
  heading: "The name",
@@ -15908,26 +16203,29 @@ var RELEASES = [
15908
16203
  {
15909
16204
  version: "0.2.3",
15910
16205
  date: "2026-08-20",
16206
+ title: "The last advisory against a build dependency is closed",
15911
16207
  sections: [
15912
16208
  {
15913
16209
  heading: "Security",
15914
- body: "The last advisory against a build dependency is closed. Nothing about how scenri runs changes, and nothing in the published package moves."
16210
+ body: "The last advisory against a build dependency is closed. Nothing about how Scenri runs changes, and nothing in the published package moves."
15915
16211
  }
15916
16212
  ]
15917
16213
  },
15918
16214
  {
15919
16215
  version: "0.2.2",
15920
16216
  date: "2026-08-20",
16217
+ title: "Two dependencies are on their patched releases",
15921
16218
  sections: [
15922
16219
  {
15923
16220
  heading: "Security",
15924
- body: "Two dependencies are on their patched releases. The file server that serves the studio is updated for a path traversal advisory, and the image library scenri re-encodes every upload through is updated for the libvips advisories. Nothing about how scenri works changes."
16221
+ body: "Two dependencies are on their patched releases. The file server that serves the studio is updated for a path traversal advisory, and the image library Scenri re-encodes every upload through is updated for the libvips advisories. Nothing about how Scenri works changes."
15925
16222
  }
15926
16223
  ]
15927
16224
  },
15928
16225
  {
15929
16226
  version: "0.2.1",
15930
16227
  date: "2026-08-20",
16228
+ title: "The top bar and app icons carry the real mark",
15931
16229
  sections: [
15932
16230
  {
15933
16231
  heading: "Fixes",
@@ -15938,7 +16236,8 @@ var RELEASES = [
15938
16236
  {
15939
16237
  version: "0.2.0",
15940
16238
  date: "2026-08-20",
15941
- title: "scenri goes public.",
16239
+ title: "Scenri goes public",
16240
+ announce: true,
15942
16241
  sections: [
15943
16242
  {
15944
16243
  heading: "Compose",
@@ -15954,7 +16253,7 @@ var RELEASES = [
15954
16253
  },
15955
16254
  {
15956
16255
  heading: "The studio",
15957
- body: "The scenri mark sits in the top bar, the browser tab names the screen you are on, and About says who builds this and under which license."
16256
+ body: "The Scenri mark sits in the top bar, the browser tab names the screen you are on, and About says who builds this and under which license."
15958
16257
  }
15959
16258
  ]
15960
16259
  },
@@ -15972,6 +16271,7 @@ var RELEASES = [
15972
16271
  version: "0.1.0",
15973
16272
  date: "2026-08-16",
15974
16273
  title: "The first complete scenri, released internally.",
16274
+ announce: true,
15975
16275
  sections: [
15976
16276
  {
15977
16277
  heading: "Create",
@@ -15998,6 +16298,35 @@ function releaseFor(version) {
15998
16298
  function isNewsworthy(entry) {
15999
16299
  return (entry?.sections.length ?? 0) > 0;
16000
16300
  }
16301
+ function whatsNewWindow(releases, running5, seen = null) {
16302
+ const released = running5 !== "0.0.0";
16303
+ const recent = [];
16304
+ let headlines = 0;
16305
+ for (const r of releases) {
16306
+ if (headlines === HEADLINES_KEPT) break;
16307
+ if (!isNewsworthy(r)) continue;
16308
+ if (released && compareSemver(r.version, running5) > 0) continue;
16309
+ recent.push(r);
16310
+ if (r.announce) headlines++;
16311
+ }
16312
+ const unseen = seen === null || !released ? [] : recent.filter((r) => compareSemver(seen, r.version) < 0 && compareSemver(r.version, running5) <= 0).map((r) => r.version);
16313
+ const lead = recent.find((r) => r.announce && unseen.includes(r.version))?.version ?? null;
16314
+ const history = releases.filter(
16315
+ (r) => isNewsworthy(r) && compareSemver(r.version, FIRST_PUBLIC) >= 0 && (!released || compareSemver(r.version, running5) <= 0)
16316
+ );
16317
+ return { recent, history, unseen, lead };
16318
+ }
16319
+ var NEW_FOR_DAYS = 30;
16320
+ var DAY_MS = 864e5;
16321
+ var dayOf = (entry) => Date.parse(`${entry.date}T00:00:00Z`);
16322
+ function newUntil(entry) {
16323
+ return dayOf(entry) + NEW_FOR_DAYS * DAY_MS;
16324
+ }
16325
+ function newFeaturesFor(releases, install) {
16326
+ const { firstVersion, used, now: now2 } = install;
16327
+ if (!firstVersion) return [];
16328
+ return releases.filter((r) => compareSemver(r.version, firstVersion) > 0 && now2 < newUntil(r)).flatMap((r) => r.newFeatures ?? []).filter((f) => !used.includes(f));
16329
+ }
16001
16330
 
16002
16331
  // src/routes/updates.ts
16003
16332
  function registerUpdateRoutes(app, deps) {
@@ -16156,38 +16485,68 @@ function registerUpdateRoutes(app, deps) {
16156
16485
  });
16157
16486
  }, 50);
16158
16487
  });
16488
+ const raiseSeen = (v) => {
16489
+ const was = core.store.getSetting("whatsnew.seen");
16490
+ if (!was || compareSemver(v, was) > 0) core.store.setSetting("whatsnew.seen", v);
16491
+ };
16159
16492
  app.get("/api/release/notes", async () => {
16160
- if (!core.store.getSetting("install.firstVersion")) {
16161
- core.store.setSetting("install.firstVersion", meta.version);
16162
- core.store.setSetting("whatsnew.seen", meta.version);
16163
- }
16493
+ const unmarked = !core.store.getSetting("install.firstVersion");
16494
+ if (unmarked) core.store.setSetting("install.firstVersion", meta.version);
16495
+ if (unmarked || !core.store.getSetting("whatsnew.seen")) raiseSeen(meta.version);
16496
+ const seen = core.store.getSetting("whatsnew.seen") || null;
16164
16497
  return {
16165
16498
  version: meta.version,
16166
16499
  entry: releaseFor(meta.version),
16167
- seen: core.store.getSetting("whatsnew.seen") || null,
16500
+ seen,
16501
+ // The in-app history (`recent`), what in it is new here (`unseen`), and
16502
+ // the one headline update allowed to open by itself (`lead`). All of it
16503
+ // is in the build: nothing here reaches the network.
16504
+ ...whatsNewWindow(RELEASES, meta.version, seen),
16168
16505
  // 0.0.0 is the placeholder release-please has not bumped yet. No tag has
16169
16506
  // ever existed for it, and the releases index of a project that has not
16170
16507
  // released is an empty page, so there is nowhere honest to point: null.
16171
- // Its absence is also what tells the dialog it is looking at a
16172
- // development build rather than a release nobody wrote notes for.
16173
16508
  // Every other version a user can be running was published, and
16174
16509
  // publishing is what creates the tag. The exceptions are the internal
16175
16510
  // 0.1.x builds: published, unpublished, never tagged. Their numbers are
16176
16511
  // burned on npm and a tag link would 404.
16177
16512
  changelogUrl: ghSlug && meta.version !== UNRELEASED && !UNTAGGED.has(meta.version) ? `https://github.com/${ghSlug}/releases/tag/v${meta.version}` : null,
16178
- // Where everything before the three in the dialog lives. The index, not
16179
- // a tag: this one is the archive, and it outlives the version running.
16180
- // Gated on there being an archive at all, so a project that has never
16181
- // published does not offer a link to an empty page. Never use it to
16182
- // decide whether *this build* was released — `changelogUrl` answers that.
16183
- releasesUrl: ghSlug && RELEASES.some(isNewsworthy) ? `https://github.com/${ghSlug}/releases` : null
16513
+ // Full release notes: everything older than the in-app window, and every
16514
+ // fix a record left out. The index, not a tag: this one is the archive,
16515
+ // and it outlives the version running. Gated on there being an archive
16516
+ // at all, so a project that has never published does not offer a link
16517
+ // to an empty page.
16518
+ releasesUrl: ghSlug && RELEASES.some(isNewsworthy) ? `https://github.com/${ghSlug}/releases` : null,
16519
+ // The few features saying New on this install, from the same record
16520
+ // (DESIGN.md, "New"). Read after the stamping above, so a new home's
16521
+ // first read has none: everything is new to it.
16522
+ newFeatures: newFeaturesFor(RELEASES, {
16523
+ firstVersion: core.store.getSetting("install.firstVersion"),
16524
+ used: usedFeatures(),
16525
+ now: Date.now()
16526
+ })
16184
16527
  };
16185
16528
  });
16186
16529
  app.post("/api/release/seen", async (req) => {
16187
16530
  const v = req.body?.version;
16188
- core.store.setSetting("whatsnew.seen", typeof v === "string" && v ? v : meta.version);
16531
+ raiseSeen(typeof v === "string" && isReleaseTriplet(v) ? v : meta.version);
16189
16532
  return { ok: true };
16190
16533
  });
16534
+ app.post("/api/release/used", async (req, reply) => {
16535
+ const f = req.body?.feature;
16536
+ if (typeof f !== "string" || !RELEASES.some((r) => r.newFeatures?.includes(f)))
16537
+ return reply.status(400).send({ error: "not a feature any release marked New" });
16538
+ const used = usedFeatures();
16539
+ if (!used.includes(f)) core.store.setSetting("features.used", JSON.stringify([...used, f]));
16540
+ return { ok: true };
16541
+ });
16542
+ function usedFeatures() {
16543
+ try {
16544
+ const v = JSON.parse(core.store.getSetting("features.used") ?? "[]");
16545
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
16546
+ } catch {
16547
+ return [];
16548
+ }
16549
+ }
16191
16550
  }
16192
16551
  function registerSystemRoutes(app, deps) {
16193
16552
  const { core, thumbs } = deps;
@@ -16210,10 +16569,19 @@ function registerSystemRoutes(app, deps) {
16210
16569
  app.post("/api/system/reveal", async (req, reply) => {
16211
16570
  if (!fromThisComputer(req)) return reply.status(403).send({ error: "Only on the computer running Scenri." });
16212
16571
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
16213
- try {
16214
- spawn(cmd, [core.home], { detached: true, stdio: "ignore" }).unref();
16215
- } catch {
16216
- }
16572
+ const opened = await new Promise((resolve) => {
16573
+ try {
16574
+ const child = spawn(cmd, [core.home], { detached: true, stdio: "ignore" });
16575
+ child.on("error", () => resolve(false));
16576
+ child.once("spawn", () => {
16577
+ child.unref();
16578
+ resolve(true);
16579
+ });
16580
+ } catch {
16581
+ resolve(false);
16582
+ }
16583
+ });
16584
+ if (!opened) return reply.status(501).send({ error: "No file manager could be opened on this computer." });
16217
16585
  return reply.send({ ok: true });
16218
16586
  });
16219
16587
  app.get("/api/export/all", async (_req, reply) => {
@@ -16243,6 +16611,7 @@ function registerSystemRoutes(app, deps) {
16243
16611
  const scope = String(req.query?.scope ?? "");
16244
16612
  if (scope !== "shots" && scope !== "all") return reply.status(400).send({ error: "scope must be shots or all" });
16245
16613
  if (scope === "shots") {
16614
+ deps.stopAllRuns();
16246
16615
  let removed = 0;
16247
16616
  for (const brand of core.store.listBrands()) {
16248
16617
  for (const set of core.store.listSets(brand.id)) core.store.deleteSet(set.id);
@@ -16513,7 +16882,7 @@ function registerDesktopRoutes(app, deps) {
16513
16882
  record: null
16514
16883
  };
16515
16884
  }
16516
- const { installDeps } = await import('./cli-6LLQ5INA.js');
16885
+ const { installDeps } = await import('./cli-WRNWT6P7.js');
16517
16886
  const { desktopStatus } = await import('./install-VXJPLYWM.js');
16518
16887
  return desktopStatus(installDeps(runtime.entry));
16519
16888
  });
@@ -16521,7 +16890,7 @@ function registerDesktopRoutes(app, deps) {
16521
16890
  if (!runtime.entry) {
16522
16891
  return { ok: false, reason: "unsupported", message: "Desktop shortcuts are not available on this system yet." };
16523
16892
  }
16524
- const { addToDesktop } = await import('./cli-6LLQ5INA.js');
16893
+ const { addToDesktop } = await import('./cli-WRNWT6P7.js');
16525
16894
  return addToDesktop(runtime.entry);
16526
16895
  });
16527
16896
  app.get("/api/desktop", async (req) => {
@@ -16632,6 +17001,16 @@ function buildServer(opts) {
16632
17001
  });
16633
17002
  const reserved = /* @__PURE__ */ new Map();
16634
17003
  const runningGenerations = /* @__PURE__ */ new Map();
17004
+ const stopRuns = (belongs) => {
17005
+ const stopped = /* @__PURE__ */ new Set();
17006
+ for (const [id, ctrl] of runningGenerations) {
17007
+ if (stopped.has(ctrl)) continue;
17008
+ const node = core.store.getNode(id);
17009
+ if (!node || !belongs(node)) continue;
17010
+ ctrl.abort();
17011
+ stopped.add(ctrl);
17012
+ }
17013
+ };
16635
17014
  core.catalog.reconcileInterruptedJobs();
16636
17015
  const thumbs = createThumbStore(core);
16637
17016
  const { scenes } = loadScenes(opts.templatesDir);
@@ -16698,6 +17077,8 @@ function buildServer(opts) {
16698
17077
  const quiet = {
16699
17078
  core};
16700
17079
  for (const d of listPresenterDrafts(core, id)) await discardPresenterDraft(quiet, d.id, hooks);
17080
+ stopRuns((node) => core.store.getProject(node.projectId)?.brandId === id);
17081
+ for (const job of core.catalog.listJobs(id)) cancelCatalogImport(job.id);
16701
17082
  }
16702
17083
  core.store.deleteBrand(id);
16703
17084
  if (brand) {
@@ -16742,7 +17123,9 @@ function buildServer(opts) {
16742
17123
  ).slice(0, 80).join("").trim() || spec.fallback;
16743
17124
  }
16744
17125
  const id = `${spec.prefix}-${randomUUID().slice(0, 8)}`;
16745
- const json = { ...brand.json };
17126
+ const current = core.store.getBrand(brand.id);
17127
+ if (!current) return reply.status(404).send({ error: "brand not found" });
17128
+ const json = { ...current.json };
16746
17129
  json[spec.key] = [
16747
17130
  ...json[spec.key] ?? [],
16748
17131
  {
@@ -16780,7 +17163,9 @@ function buildServer(opts) {
16780
17163
  inspectMark: (buf) => inspectMark(buf, toMarkPng),
16781
17164
  createdWith: `${meta.name}/${meta.version}`
16782
17165
  });
16783
- const { brand: merged, suggestions } = mergeScrape(brand.json, scraped);
17166
+ const current = core.store.getBrand(brand.id);
17167
+ if (!current) return reply.status(404).send({ error: "brand not found" });
17168
+ const { brand: merged, suggestions } = mergeScrape(current.json, scraped);
16784
17169
  const v = validateBrand(merged);
16785
17170
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
16786
17171
  const row = core.store.updateBrand(brand.id, merged);
@@ -16809,11 +17194,9 @@ function buildServer(opts) {
16809
17194
  if (!brand) return reply.status(404).send({ error: "brand not found" });
16810
17195
  const productId = String(req.params.productId);
16811
17196
  const catalogId2 = productId.startsWith("cat-") ? productId.slice(4) : null;
16812
- const json = { ...brand.json };
16813
- const products = json.products ?? [];
16814
- const idx = catalogId2 ? -1 : products.findIndex((p) => p.id === productId);
17197
+ const owned = (json2) => (json2.products ?? []).findIndex((p) => p.id === productId);
16815
17198
  const catalogRow = catalogId2 ? core.catalog.getProduct(catalogId2) : null;
16816
- if (idx === -1 && (!catalogRow || catalogRow.brandId !== brand.id)) {
17199
+ if ((catalogId2 ? -1 : owned(brand.json)) === -1 && (!catalogRow || catalogRow.brandId !== brand.id)) {
16817
17200
  return reply.status(404).send({ error: "product not found" });
16818
17201
  }
16819
17202
  const part = await readImagePart(core, req, toPng);
@@ -16824,6 +17207,11 @@ function buildServer(opts) {
16824
17207
  core.catalog.addLocalImage(catalogId2, `asset:${hash}`, angle ?? null);
16825
17208
  return core.store.getBrand(brand.id);
16826
17209
  }
17210
+ const current = core.store.getBrand(brand.id);
17211
+ const json = { ...current?.json };
17212
+ const products = json.products ?? [];
17213
+ const idx = current ? owned(json) : -1;
17214
+ if (idx === -1) return reply.status(404).send({ error: "product not found" });
16827
17215
  const shot = { file: `asset:${hash}`, locked: true };
16828
17216
  if (angle) shot.angle = angle;
16829
17217
  json.products = products.map((p, i) => i === idx ? { ...p, shots: [...p.shots ?? [], shot] } : p);
@@ -17075,6 +17463,8 @@ function buildServer(opts) {
17075
17463
  }
17076
17464
  }
17077
17465
  if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
17466
+ const parentImages = core.store.getNode(parentId)?.images ?? [];
17467
+ const source = opts2?.source ?? (parentImages.length === 1 ? parentImages[0] : void 0);
17078
17468
  const compileCtx = {
17079
17469
  brand: brandJson,
17080
17470
  images: core.images,
@@ -17083,6 +17473,9 @@ function buildServer(opts) {
17083
17473
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
17084
17474
  templateById: sceneById,
17085
17475
  mode: "edit",
17476
+ // The frame rides on its own, so a picture this refine attaches that is
17477
+ // the same bytes is never sent a second time beside it.
17478
+ ...source ? { sourceHash: String(source) } : {},
17086
17479
  editScope: verdict.scope,
17087
17480
  editRemoval: verdict.removal ?? false,
17088
17481
  // Kinds, not a count: the identity claim speaks only about the kinds
@@ -17146,7 +17539,8 @@ function buildServer(opts) {
17146
17539
  if (parentId && core.store.getNode(String(parentId))) {
17147
17540
  const previewReshape = req.body.reshape;
17148
17541
  const edit = await compileEditBrief(brand.id, String(parentId), brief, engine.capabilities(), {
17149
- reshape: previewReshape === "extend" ? "extend" : previewReshape === "crop" ? "crop" : void 0
17542
+ reshape: previewReshape === "extend" ? "extend" : previewReshape === "crop" ? "crop" : void 0,
17543
+ source: req.body.sourceImage
17150
17544
  });
17151
17545
  const { referenceImages: referenceImages2, ...rest2 } = edit.compiled;
17152
17546
  return {
@@ -17446,6 +17840,8 @@ function buildServer(opts) {
17446
17840
  let { width = 1024, height = 1024 } = req.body;
17447
17841
  const project = core.store.getProject(String(projectId));
17448
17842
  if (!project) return reply.status(404).send({ error: "project not found" });
17843
+ if (parentId && core.store.getNode(String(parentId))?.projectId !== project.id)
17844
+ return reply.status(404).send({ error: "That shot is no longer there." });
17449
17845
  const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
17450
17846
  const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
17451
17847
  const runCropNode = async (args) => {
@@ -17476,7 +17872,8 @@ function buildServer(opts) {
17476
17872
  void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
17477
17873
  (err) => app.log.error({ err }, "crop run failed")
17478
17874
  );
17479
- return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
17875
+ const sent2 = core.store.getNode(node2.id) ?? node2;
17876
+ return reply.status(202).send(args.note ? { ...sent2, warnings: [args.note] } : sent2);
17480
17877
  };
17481
17878
  if (kind === "edit" && reshape === "crop") {
17482
17879
  const rootForCrop = core.store.rootFor(project.id);
@@ -17533,7 +17930,8 @@ function buildServer(opts) {
17533
17930
  if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
17534
17931
  if (kind === "edit") {
17535
17932
  const edit = await compileEditBrief(project.brandId, resolvedParentId, brief, engine.capabilities(), {
17536
- reshape
17933
+ reshape,
17934
+ source: req.body.sourceImage
17537
17935
  });
17538
17936
  compiled2 = edit.compiled;
17539
17937
  inheritedTokens = edit.inheritedTokens;
@@ -17659,13 +18057,13 @@ function buildServer(opts) {
17659
18057
  const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
17660
18058
  const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
17661
18059
  if (process.env.SCENRI_DEBUG) {
17662
- const sent = {};
17663
- for (const r of sentRoles) sent[r] = (sent[r] ?? 0) + 1;
18060
+ const sent2 = {};
18061
+ for (const r of sentRoles) sent2[r] = (sent2[r] ?? 0) + 1;
17664
18062
  app.log.info(
17665
18063
  {
17666
18064
  engine: engine.capabilities().id,
17667
18065
  cap: cap2,
17668
- sent,
18066
+ sent: sent2,
17669
18067
  dropped: (compiled2?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
17670
18068
  },
17671
18069
  "reference transport"
@@ -17738,14 +18136,14 @@ function buildServer(opts) {
17738
18136
  const editEdge = engine.capabilities().maxReferenceEdge;
17739
18137
  if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
17740
18138
  if (process.env.SCENRI_DEBUG) {
17741
- const sent = {};
17742
- for (const r of editRefs) sent[String(r.role ?? "reference")] = (sent[String(r.role ?? "reference")] ?? 0) + 1;
18139
+ const sent2 = {};
18140
+ for (const r of editRefs) sent2[String(r.role ?? "reference")] = (sent2[String(r.role ?? "reference")] ?? 0) + 1;
17743
18141
  app.log.info(
17744
18142
  {
17745
18143
  engine: engine.capabilities().id,
17746
18144
  cap: Math.max(0, engine.capabilities().maxReferenceImages - 1),
17747
18145
  sourceFrame: true,
17748
- sent,
18146
+ sent: sent2,
17749
18147
  dropped: (mergedEdit?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
17750
18148
  },
17751
18149
  "reference transport"
@@ -18054,10 +18452,10 @@ function buildServer(opts) {
18054
18452
  } : kind === "edit" && original ? async (images) => {
18055
18453
  let staged = images;
18056
18454
  if (gradeOnlyAsk && !expandPlan) {
18057
- const sent = budgetSourceHash ? core.images.read(budgetSourceHash) : original;
18455
+ const sent2 = budgetSourceHash ? core.images.read(budgetSourceHash) : original;
18058
18456
  const out = [];
18059
18457
  for (const h of staged) {
18060
- const g = await gradeComposite(original, sent, core.images.read(h));
18458
+ const g = await gradeComposite(original, sent2, core.images.read(h));
18061
18459
  if (g) {
18062
18460
  app.log.info(
18063
18461
  { nodeId: node.id, residual: Number(g.residual.toFixed(2)) },
@@ -18102,14 +18500,16 @@ function buildServer(opts) {
18102
18500
  nodeBudgetMs
18103
18501
  ).catch((err) => app.log.error({ err }, "node run failed"));
18104
18502
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
18105
- return reply.status(202).send({ ...node, siblings: nodes, ...allWarnings.length ? { warnings: allWarnings } : {} });
18503
+ const sent = nodes.map((n) => core.store.getNode(n.id) ?? n);
18504
+ return reply.status(202).send({ ...sent[0], siblings: sent, ...allWarnings.length ? { warnings: allWarnings } : {} });
18106
18505
  };
18107
18506
  app.post("/api/nodes", async (req, reply) => startNodeRun(req, reply));
18108
18507
  app.post("/api/nodes/:id/retry", async (req, reply) => {
18109
18508
  const id = req.params.id;
18110
18509
  const n = core.store.getNode(id);
18111
18510
  if (!n) return reply.status(404).send({ error: "node not found" });
18112
- if (n.status === "running") return reply.status(409).send({ error: "already running" });
18511
+ if (n.status === "running" || runningGenerations.has(id))
18512
+ return reply.status(409).send({ error: "already running" });
18113
18513
  if (n.status === "done") return reply.status(400).send({ error: "finished shots start a new take" });
18114
18514
  if (n.status !== "error" && n.status !== "cancelled") {
18115
18515
  return reply.status(400).send({ error: "cannot retry this shot" });
@@ -18231,7 +18631,7 @@ function buildServer(opts) {
18231
18631
  })();
18232
18632
  return drained;
18233
18633
  });
18234
- registerSystemRoutes(app, { core, thumbs });
18634
+ registerSystemRoutes(app, { core, thumbs, stopAllRuns: () => stopRuns(() => true) });
18235
18635
  registerPhoneRoutes(app, { phone });
18236
18636
  registerGuideRoutes(app, { core, version: meta.version });
18237
18637
  registerDesktopRoutes(app, {
@@ -18331,6 +18731,12 @@ async function serve() {
18331
18731
  }
18332
18732
  }
18333
18733
  async function run3() {
18734
+ const supervised = process.env.SCENRI_SUPERVISED === "1";
18735
+ const installKind = detectInstallKind(fileURLToPath(import.meta.url), defaultHome());
18736
+ const theirs = await runningScenri();
18737
+ if (theirs && shouldAdoptRunning({ installKind, ourHome: defaultHome(), theirHome: theirs.home })) {
18738
+ await adoptRunning();
18739
+ }
18334
18740
  const core = createCore();
18335
18741
  const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
18336
18742
  const engines = createEngineRegistry(core, stubs);
@@ -18343,8 +18749,6 @@ async function run3() {
18343
18749
  const here = dirname(fileURLToPath(import.meta.url));
18344
18750
  const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];
18345
18751
  const studioDist = candidates.find((p) => existsSync(p));
18346
- const supervised = process.env.SCENRI_SUPERVISED === "1";
18347
- const installKind = detectInstallKind(fileURLToPath(import.meta.url), core.home);
18348
18752
  const named = isLoopbackName(HOST) || isWildcardHost(HOST) || isIPv4Literal(HOST) ? [] : [HOST];
18349
18753
  const app = buildServer({
18350
18754
  core,
@@ -18371,34 +18775,17 @@ async function run3() {
18371
18775
  await new Promise((r) => setTimeout(r, 300));
18372
18776
  continue;
18373
18777
  }
18374
- try {
18375
- const res = await fetch(`http://127.0.0.1:${PORT}/api/version`, {
18376
- signal: AbortSignal.timeout(2e3)
18377
- });
18378
- const info = await res.json();
18379
- if (info.name === readMeta().name) {
18380
- if (!shouldAdoptRunning({ installKind, ourHome: core.home, theirHome: info.home })) {
18381
- console.error("");
18382
- for (const line of anotherScenriLines(PORT, info.home ?? "another library", core.home)) {
18383
- console.error(` ${line}`);
18384
- }
18385
- console.error("");
18386
- process.exit(1);
18778
+ const theirs2 = await runningScenri();
18779
+ if (theirs2) {
18780
+ if (!shouldAdoptRunning({ installKind, ourHome: core.home, theirHome: theirs2.home })) {
18781
+ console.error("");
18782
+ for (const line of anotherScenriLines(PORT, theirs2.home ?? "another library", core.home)) {
18783
+ console.error(` ${line}`);
18387
18784
  }
18388
- const url = `http://127.0.0.1:${PORT}`;
18389
- console.log(`
18390
- Scenri is already running \u2192 ${url}
18391
- `);
18392
- if (process.env.SCENRI_NO_OPEN !== "1") {
18393
- try {
18394
- const { default: open } = await import('open');
18395
- await open(url);
18396
- } catch {
18397
- }
18398
- }
18399
- process.exit(0);
18785
+ console.error("");
18786
+ process.exit(1);
18400
18787
  }
18401
- } catch {
18788
+ await adoptRunning();
18402
18789
  }
18403
18790
  console.error("");
18404
18791
  for (const line of portBusyLines(PORT)) console.error(` ${line}`);
@@ -18412,13 +18799,14 @@ async function run3() {
18412
18799
  const shutdown = (signal) => {
18413
18800
  if (closing2) return;
18414
18801
  closing2 = true;
18415
- console.log(`
18802
+ if (signal !== "SIGHUP") console.log(`
18416
18803
  ${signal}: closing Scenri\u2026`);
18417
18804
  setTimeout(() => process.exit(1), 5e3).unref();
18418
18805
  app.drain().then(() => process.exit(0)).catch(() => process.exit(1));
18419
18806
  };
18420
18807
  process.on("SIGINT", shutdown);
18421
18808
  process.on("SIGTERM", shutdown);
18809
+ process.on("SIGHUP", shutdown);
18422
18810
  void repairPresenterCrops(core, (line) => console.log(line)).catch(() => void 0);
18423
18811
  app.updates.schedule();
18424
18812
  app.content.schedule();
@@ -18452,7 +18840,7 @@ async function run3() {
18452
18840
  }
18453
18841
  }
18454
18842
  const ownEntry = fileURLToPath(import.meta.url);
18455
- const { addToDesktop, installDeps } = await import('./cli-6LLQ5INA.js');
18843
+ const { addToDesktop, installDeps } = await import('./cli-WRNWT6P7.js');
18456
18844
  const { refreshLauncher } = await import('./refresh-5HBDLLLB.js');
18457
18845
  const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-W6NJXMWT.js');
18458
18846
  const { launcherInstalled } = await import('./paths-6ZXRZUHY.js');
@@ -18494,6 +18882,29 @@ async function verify() {
18494
18882
  process.exit(1);
18495
18883
  }
18496
18884
  }
18885
+ async function runningScenri() {
18886
+ try {
18887
+ const res = await fetch(`http://127.0.0.1:${PORT}/api/version`, { signal: AbortSignal.timeout(2e3) });
18888
+ const info = await res.json();
18889
+ return info.name === readMeta().name ? info : null;
18890
+ } catch {
18891
+ return null;
18892
+ }
18893
+ }
18894
+ async function adoptRunning() {
18895
+ const url = `http://127.0.0.1:${PORT}`;
18896
+ console.log(`
18897
+ Scenri is already running \u2192 ${url}
18898
+ `);
18899
+ if (process.env.SCENRI_NO_OPEN !== "1") {
18900
+ try {
18901
+ const { default: open } = await import('open');
18902
+ await open(url);
18903
+ } catch {
18904
+ }
18905
+ }
18906
+ process.exit(0);
18907
+ }
18497
18908
 
18498
18909
  export { serve, verify };
18499
18910
  //# sourceMappingURL=serve.js.map