scenri 0.21.1 → 0.22.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.22.0](https://github.com/tonygorb/Scenri/compare/v0.21.2...v0.22.0) (2026-10-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * People and Places replace Presenters and Scenes ([6423c40](https://github.com/tonygorb/Scenri/commit/6423c40f3b19ff5e02b2fc780edbba13a0f17f92))
9
+ * People and Places replace Presenters and Scenes in the studio ([593071a](https://github.com/tonygorb/Scenri/commit/593071a909ea4b8210e5ae9de426452454d24711))
10
+ * Scenri points to scenri.co and its docs ([7ad12c5](https://github.com/tonygorb/Scenri/commit/7ad12c5379906adef0223fb8f0dfe8ccda76d1e0))
11
+ * Scenri points to scenri.co and its docs ([a8b2e61](https://github.com/tonygorb/Scenri/commit/a8b2e6130905ad3c9f5316e63d6d43e802dc34f1))
12
+
13
+ ## [0.21.2](https://github.com/tonygorb/Scenri/compare/v0.21.1...v0.21.2) (2026-10-05)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * declare the docs-impact script's types so its test typechecks ([f484a2d](https://github.com/tonygorb/Scenri/commit/f484a2daaca5e1d1fae65112d5066c6402a270a4))
19
+ * **library:** replace the Halvard 12 product photo ([2a2172d](https://github.com/tonygorb/Scenri/commit/2a2172da4358935c2faf43f646a163d976fa4db1))
20
+ * **library:** replace the Halvard 12 product photo ([1d06193](https://github.com/tonygorb/Scenri/commit/1d0619397bbf5a39c4b982db5cde1c7d9b8bbf3b))
21
+
3
22
  ## [0.21.1](https://github.com/tonygorb/Scenri/compare/v0.21.0...v0.21.1) (2026-10-04)
4
23
 
5
24
 
package/README.md CHANGED
@@ -1,31 +1,35 @@
1
1
  <div align="center">
2
2
 
3
+ <a href="https://scenri.co">
3
4
  <picture>
4
5
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tonygorb/scenri/main/docs/media/logo-on-dark.svg">
5
6
  <img src="https://raw.githubusercontent.com/tonygorb/scenri/main/docs/media/logo-on-light.svg" alt="Scenri" width="200">
6
7
  </picture>
8
+ </a>
7
9
  <br><br>
8
10
 
9
11
  **The open-source AI photo studio that remembers your products, people and places.**<br>
10
12
  Save a brand's products from real photos, the people in its shots and its places once. Then each shot is one line, and every shot keeps its recipe, so you can refine it or make the next one match. It runs on your own computer and sends your pictures to the image model you already use.
11
13
 
14
+ **[scenri.co](https://scenri.co)** · [Docs](https://scenri.co/docs)
15
+
12
16
  [![CI](https://github.com/tonygorb/scenri/actions/workflows/ci.yml/badge.svg)](https://github.com/tonygorb/scenri/actions/workflows/ci.yml)
13
17
  [![npm](https://img.shields.io/npm/v/scenri)](https://www.npmjs.com/package/scenri)
14
18
  [![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0-blue)](https://github.com/tonygorb/scenri/blob/main/LICENSE)
15
19
 
16
20
  **Get started.** You need [Node.js](https://nodejs.org) 22 or newer, and a paid ChatGPT plan (used through Codex CLI, which Scenri sets up for you) or an [OpenRouter](https://openrouter.ai) API key to make pictures. Scenri itself has no account and no fees.
17
21
 
18
- <sub>If `npm --version` prints 12, run `npm config set allow-scripts=better-sqlite3 --location=user` once first ([why](https://github.com/tonygorb/scenri/blob/main/docs/INSTALL.md#troubleshooting)). Then:</sub>
22
+ <sub>If `npm --version` prints 12, run `npm config set allow-scripts=better-sqlite3 --location=user` once first ([why](https://scenri.co/docs/troubleshooting/)). Then:</sub>
19
23
 
20
24
  ```bash
21
25
  npx scenri
22
26
  ```
23
27
 
24
- <sub>The first start downloads the Scenri library of example imagery, about 155 MB. New to any of this? The [install guide](https://github.com/tonygorb/scenri/blob/main/docs/INSTALL.md) walks through every step.</sub>
28
+ <sub>The first start downloads the Scenri library of example imagery, about 155 MB. New to any of this? The [install guide](https://scenri.co/docs/install/) walks through every step.</sub>
25
29
 
26
- <img src="https://raw.githubusercontent.com/tonygorb/scenri/main/docs/media/demo.gif" alt="Scenri's Create page: in the composer a dollar sign picks the product Cropped Puffer, an at sign picks the presenter Kwame, a slash picks the Container Blue scene, and a line of written direction follows the three chips. Generate turns a new card into a dithered swirl where the picture is being made, the swirl develops into a man in an orange puffer jacket leaning on a cobalt shipping container, and the shot opens beside the prompt that made it" width="820">
30
+ <img src="https://raw.githubusercontent.com/tonygorb/scenri/main/docs/media/demo.gif" alt="Scenri's Create page: in the composer a dollar sign picks the product Cropped Puffer, an at sign picks the person Kwame, a slash picks the place Container Blue, and a line of written direction follows the three chips. Generate turns a new card into a dithered swirl where the picture is being made, the swirl develops into a man in an orange puffer jacket leaning on a cobalt shipping container, and the shot opens beside the prompt that made it" width="820">
27
31
 
28
- <sub>Pick a product, pick a presenter, pick a scene, then write the direction. That is the prompt.</sub>
32
+ <sub>Pick a product, pick a person, pick a place, then write the direction. That is the prompt.</sub>
29
33
 
30
34
  </div>
31
35
 
@@ -35,7 +39,7 @@ npx scenri
35
39
 
36
40
  Most AI image tools give you a prompt box and a slot machine. Scenri gives you the part that actually takes the time: **art direction**.
37
41
 
38
- You save a brand's ingredients once: its products from real photos, the people who appear in its shots (Scenri calls them Presenters), its places (Scenes) and its brand kit. Then a shot is one line. In the composer, `$` picks a product (what), `@` a person (who) and `/` a scene (where), and you write the direction in the same line. Open the shot, say what to change, and **Refine** keeps every step on a trail under the picture, so the original and every step stay one click apart.
42
+ You save a brand's ingredients once: its products from real photos, the people who appear in its shots, its places and its brand kit. Then a shot is one line. In the composer, `$` picks a product (what), `@` a person (who) and `/` a place (where), and you write the direction in the same line. Open the shot, say what to change, and **Refine** keeps every step on a trail under the picture, so the original and every step stay one click apart.
39
43
 
40
44
  Every shot also keeps its recipe: the prompt, the chips and the settings that made it. Refine any step again, or use **Reuse setup** to start a new shot from the same prompt and chips, so the next one matches.
41
45
 
@@ -43,11 +47,11 @@ Scenri trains nothing. It sends your saved photos with each shot as reference im
43
47
 
44
48
  It runs as a local server on your own computer. Your brands, your images, and your keys stay on your disk. To use it from a phone or tablet on the same Wi-Fi, open Settings, Local access, and scan the QR code.
45
49
 
46
- The words Scenri uses are defined once in [Concepts](https://github.com/tonygorb/scenri/blob/main/docs/CONCEPTS.md), and the questions people ask first, from LoRAs to cost, are answered in the [FAQ](https://github.com/tonygorb/scenri/blob/main/docs/FAQ.md).
50
+ The words Scenri uses are defined once in [Concepts](https://scenri.co/docs/concepts/), and the questions people ask first, from LoRAs to cost, are answered in the [FAQ](https://scenri.co/docs/faq/).
47
51
 
48
52
  ## What a prompt produces
49
53
 
50
- The film above ends on one of the examples from the Home wall: the Cropped Puffer product, the presenter Kwame, the Container Blue scene, and a short paragraph of written direction, of which the film types the first line. The chips carry the identity; the words do the art direction. Here are eight more shots from the Home wall, each built the same way from chips and written direction:
54
+ The film above ends on one of the examples from the Home wall: the Cropped Puffer product, the person Kwame, the place Container Blue, and a short paragraph of written direction, of which the film types the first line. The chips carry the identity; the words do the art direction. Here are eight more shots from the Home wall, each built the same way from chips and written direction:
51
55
 
52
56
  <!-- One paragraph, no whitespace between the tags: a newline here renders as
53
57
  a fixed word-space, and 4 x 24.5% plus three spaces overflows a phone screen
@@ -57,29 +61,21 @@ All 110 examples on the Home wall work the same way. Open one and it loads back
57
61
 
58
62
  ## What you get out of the box
59
63
 
60
- <img src="https://raw.githubusercontent.com/tonygorb/scenri/main/docs/media/library.gif" alt="Scenri's Home for a new brand: the four ways to start and the example tabs over the wall of finished shots, a slow scroll down the wall, then the Presenters library and the Scenes library, each opening with its offer to make your own above the catalog" width="820">
64
+ <img src="https://raw.githubusercontent.com/tonygorb/scenri/main/docs/media/library.gif" alt="Scenri's Home for a new brand: the four ways to start and the example tabs over the wall of finished shots, a slow scroll down the wall, then the People library and the Places library, each opening with its offer to make your own above the catalog" width="820">
61
65
 
62
- A library of 42 scenes, 19 presenters and 30 demo products, so the app is useful before you have uploaded anything of your own. Filter by category and star what fits the brand. Add your own products from their photos, and make your own presenters and scenes in a short conversation: describe a person or a place, or start from photos, and approve what Scenri draws before it is saved.
66
+ A library of 42 places, 19 people and 30 demo products, so the app is useful before you have uploaded anything of your own. Filter by category and star what fits the brand. Add your own products from their photos, and make your own people and places in a short conversation: describe a person or a place, or start from photos, and approve what Scenri draws before it is saved.
63
67
 
64
68
  The brand kit lives in Settings and is the part that keeps output consistent. Name, mark, palette and rules, set once and carried into every shot that asks for them.
65
69
 
66
70
  ## Run it
67
71
 
68
- Scenri runs on [Node.js](https://nodejs.org), version 22 or newer. If you are not sure you have it, or terminals are not part of your day, the **[install guide](https://github.com/tonygorb/scenri/blob/main/docs/INSTALL.md)** covers every step for macOS, Windows and Linux. With Node in place:
72
+ Node.js 22 or newer, then:
69
73
 
70
74
  ```bash
71
75
  npx scenri
72
76
  ```
73
77
 
74
- That is the whole install. npm asks once whether to proceed, downloads the current release, and opens `http://127.0.0.1:4747`. Keep the terminal window open while you work; closing it stops Scenri. Tomorrow the same command opens it again, with everything where you left it. Nothing here needs an administrator.
75
-
76
- On Windows, if PowerShell answers `npx.ps1 cannot be loaded because running scripts is disabled on this system`, that is a stock Windows setting, not a fault: type `npx.cmd scenri` instead, or use Command Prompt. Still no administrator required.
77
-
78
- On macOS and Windows, Scenri then asks once whether to put a **Scenri icon on your desktop**. Say yes and from then on a double-click starts Scenri and opens it in your browser, no terminal needed; the icon works offline and keeps working across updates. Said no? Settings, then Local access, then **Add to desktop**, or `npx scenri desktop` in a terminal. Scenri stays what it is, a local server and your browser: the icon is a launcher, not an app.
79
-
80
- Generation runs on **Codex CLI**, an official helper from OpenAI that draws on your own paid ChatGPT plan (Codex image generation is not part of the Free plan). No API key to paste, and Scenri never charges you. Each image spends some of your plan's Codex usage. You do not have to set it up by hand: if it is missing, Scenri offers to install it and to sign you in, both from the app. No ChatGPT plan? Add your own key from an image provider in Settings instead, see [Engines](#engines).
81
-
82
- One dependency, `better-sqlite3`, runs an install script that fetches its native binary. The npm that ships with current Node releases (npm 11) runs it without asking. npm 12 blocks dependency install scripts by default, so if `npm --version` prints 12, allow this one once with `npm config set allow-scripts=better-sqlite3 --location=user` before you run `npx scenri`. Something not starting? See [troubleshooting](https://github.com/tonygorb/scenri/blob/main/docs/INSTALL.md#troubleshooting).
78
+ The [install guide](https://scenri.co/docs/install/) covers macOS, Windows, and Linux, including the desktop icon and the PowerShell case. The [quick start](https://scenri.co/docs/quick-start/) is the shortest path to one Shot. Generation is a separate step: [connect Codex or a provider key](https://scenri.co/docs/connect/). If `npm --version` prints 12, see [troubleshooting](https://scenri.co/docs/troubleshooting/) before the first run.
83
79
 
84
80
  <details>
85
81
  <summary>Run from source</summary>
@@ -94,41 +90,14 @@ pnpm dev # starts the server on 127.0.0.1:4747
94
90
 
95
91
  </details>
96
92
 
97
- ## First five minutes
98
-
99
- Scenri offers to walk you through your first shot, one step at a time, and Learn holds the rest. The short version:
100
-
101
- 1. **Paste a website URL.** Scenri reads the public pages and drafts the kit: name, logo, palette. If the site sells things, it offers to add the products too.
102
- 2. **Describe a shot.** In the composer, `$` reaches for a product, `@` for a presenter, `/` for a scene, and `#` for a colour. Everything between them is your own words.
103
- 3. **Generate on Codex CLI.** Runs on your own paid ChatGPT plan, so there is no key and no per-image charge from us.
104
- 4. **Refine it.** Open the shot and say what to change. Each refinement joins the trail under the picture.
105
- 5. **Add your own key** in Settings, Providers to run on OpenRouter, Replicate or fal instead.
106
-
107
93
  ## Why it is built this way
108
94
 
109
- - **Iteration is the product.** Not a prompt box: every shot keeps its trail of refinements, and the ones you star collect in Keepers.
95
+ - **Iteration is the product.** Not a prompt box: every shot keeps its trail of refinements, and the ones you mark collect in Keepers.
110
96
  - **Your brands are files, not hostages.** `.brand` is an open, documented format under a permissive license. Email one to a client. Any tool can adopt it.
111
97
  - **Your AI, your cost.** Bring your own ChatGPT plan through Codex CLI, or an API key. You pay the provider directly, at its own price. Scenri sells no credits and adds no markup.
112
98
  - **Local first, and it means it.** No account, no telemetry, nothing uploaded to us. Scenri runs on this computer, and answers other devices on your own network only when they bring its six-digit code (Settings, Local access). It makes exactly two requests on its own behalf: a version-number check against npm every six hours so updates can announce themselves (and, when one is found, the download of that release from npm, staged locally until you choose to restart), and the download of the library imagery archive from GitHub, once for each library version a release pins. Nothing about you or your work is sent in either, and both turn off: in Settings or `SCENRI_NO_UPDATE_CHECK=1` for the first, `SCENRI_NO_CONTENT_FETCH=1` for the second ([how updates work](https://github.com/tonygorb/scenri/blob/main/docs/updates.md), [what goes where](https://github.com/tonygorb/scenri/blob/main/PRIVACY.md)).
113
99
 
114
- ## Engines
115
-
116
- | Engine | What it costs you | Needs | Carries a Product or Presenter |
117
- |---|---|---|---|
118
- | **Codex CLI** | your ChatGPT plan's Codex usage | a paid ChatGPT plan; the app installs Codex and signs you in | yes, up to 5 references |
119
- | OpenRouter | about $0.04 an image | API key | yes, up to 4 references |
120
- | Replicate | about $0.003 an image | API token | no |
121
- | fal | about $0.006 an image (fal bills per megapixel) | API key | no |
122
-
123
- Codex CLI is the default because it needs no key and because it carries the most reference images: a shot that has to keep both a product and a person accurate needs the room.
124
-
125
- It is not free. Paid ChatGPT plans include Codex usage, and each image spends some of it: OpenAI says image generation uses it three to five times faster than a plain turn. OpenAI meters that, not Scenri, so spend caps do not apply to this engine.
126
-
127
- **Without a paid ChatGPT plan**, use your own provider key. OpenRouter is the one to pick if your shots name a Product or a Presenter. Replicate and fal take no reference images, so Scenri refuses those shots on them rather than generating something that only looks right. Edits on Replicate and fal cost more than a new image.
128
-
129
- Keys are stored in your local library folder, sent only to that provider, and never returned by the API. Set a monthly spend cap per engine in Settings, Providers.
130
-
131
- Your Codex session is yours: Scenri runs the official `codex` commands on your machine and never reads, copies or stores the credential. That is also why Scenri never pools user plans: a plan is licensed to the person who pays for it, not to a service reselling it to other people. Any hosted version of Scenri, if one ever exists, would run API-priced engines only.
100
+ Providers, costs, and what each one can carry are in [Connect image generation](https://scenri.co/docs/connect/).
132
101
 
133
102
  ## Configuration
134
103
 
package/dist/index.js CHANGED
@@ -50,6 +50,8 @@ Usage
50
50
  Configuration is via environment variables: SCENRI_PORT, SCENRI_HOST,
51
51
  SCENRI_HOME, SCENRI_NO_OPEN, SCENRI_NO_UPDATE_CHECK, SCENRI_NO_CONTENT_FETCH,
52
52
  SCENRI_CONTENT_URL, SCENRI_CONTENT_PIN, SCENRI_NO_DESKTOP.
53
+
54
+ Docs: https://scenri.co/docs
53
55
  `;
54
56
  }
55
57
 
package/dist/serve.js CHANGED
@@ -4761,10 +4761,10 @@ var SCENE_INSTRUCTION_MAX = COMPOSED_TEXT_MAX;
4761
4761
  function sceneRecordFrom(input, base) {
4762
4762
  const has = (k) => input[k] !== void 0;
4763
4763
  const prompt = has("prompt") ? phrase(input.prompt, 2e3) : base?.prompt ?? "";
4764
- if (!prompt) return { ok: false, error: "a scene needs a prompt describing the place" };
4765
- if (/\{[^}]*\}/.test(prompt)) return { ok: false, error: "a scene prompt cannot contain a {placeholder}" };
4764
+ if (!prompt) return { ok: false, error: "a place needs a prompt describing it" };
4765
+ if (/\{[^}]*\}/.test(prompt)) return { ok: false, error: "a place prompt cannot contain a {placeholder}" };
4766
4766
  const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
4767
- if (!name) return { ok: false, error: "a scene needs a name" };
4767
+ if (!name) return { ok: false, error: "a place needs a name" };
4768
4768
  const subjectRaw = has("subject") ? str3(input.subject, 20).toLowerCase() : base?.subject ?? "either";
4769
4769
  if (!SUBJECTS2.has(subjectRaw)) {
4770
4770
  return { ok: false, error: "subject must be product, person or either" };
@@ -4834,14 +4834,14 @@ function sceneRecordFrom(input, base) {
4834
4834
  function presenterRecordFrom(input, base) {
4835
4835
  const has = (k) => input[k] !== void 0;
4836
4836
  const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
4837
- if (!name) return { ok: false, error: "a presenter needs a name" };
4837
+ if (!name) return { ok: false, error: "a person needs a name" };
4838
4838
  const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
4839
4839
  const knownAngle = (file) => base?.shots?.find((s) => s.file === file)?.angle;
4840
4840
  const shots = has("shotHashes") ? strList(input.shotHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file, i) => {
4841
4841
  const angle = angles[i] ?? knownAngle(file);
4842
4842
  return angle ? { file, angle, locked: true } : { file, locked: true };
4843
4843
  }) : base?.shots ?? [];
4844
- if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
4844
+ if (!shots.length) return { ok: false, error: "a person needs at least one photo" };
4845
4845
  const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
4846
4846
  const presenter = {
4847
4847
  id: base?.id ?? mintId(PRESENTER_ID_PREFIX),
@@ -4927,8 +4927,8 @@ function duplicatePresenter(core, brandId, presenterId, name) {
4927
4927
  if (!brand) return { ok: false, error: "brand not found", status: 404 };
4928
4928
  const id = headOf(brand.json, presenterId);
4929
4929
  const source = brandCharacters(brand.json).find((c) => c?.id === id);
4930
- if (!source) return { ok: false, error: "presenter not found", status: 404 };
4931
- if (!isCustomPresenter(source)) return { ok: false, error: "this presenter is not editable", status: 400 };
4930
+ if (!source) return { ok: false, error: "person not found", status: 404 };
4931
+ if (!isCustomPresenter(source)) return { ok: false, error: "this person is not editable", status: 400 };
4932
4932
  const built = duplicatePresenterRecord(source, name);
4933
4933
  if (!built.ok) return { ok: false, error: built.error, status: 400 };
4934
4934
  commit(core, brand.id, (json) => {
@@ -4955,7 +4955,7 @@ function lintSceneProse(brandJson, scene) {
4955
4955
  for (const p of brandJson?.products ?? []) consider(p?.name);
4956
4956
  for (const c of brandCharacters(brandJson)) consider(c?.name);
4957
4957
  return named.size ? [
4958
- `This scene names ${[...named].join(", ")}. A scene is a place, so whatever you stage in it later has to argue with that.`
4958
+ `This place names ${[...named].join(", ")}. A place is a setting, so whatever you stage in it later has to argue with that.`
4959
4959
  ] : [];
4960
4960
  }
4961
4961
  function commit(core, brandId, mutate2, dropDraftId) {
@@ -5872,7 +5872,7 @@ function compileBrief(input, ctx) {
5872
5872
  case "character": {
5873
5873
  const c = characters.find((x) => x.id === tok.id);
5874
5874
  if (!c) {
5875
- warnings.push("A presenter in this brief is no longer in your roster.");
5875
+ warnings.push("A person in this brief is no longer in your roster.");
5876
5876
  break;
5877
5877
  }
5878
5878
  hasPerson = true;
@@ -5990,11 +5990,13 @@ function compileBrief(input, ctx) {
5990
5990
  case "template": {
5991
5991
  const t = ctx.templateById?.(tok.id);
5992
5992
  if (!t) {
5993
- warnings.push("A template in this brief is no longer installed.");
5993
+ warnings.push("A place in this brief is no longer installed.");
5994
5994
  break;
5995
5995
  }
5996
5996
  if (inlineTemplates.length) {
5997
- warnings.push(`${t.name} was ignored: a brief runs one template, and ${inlineTemplates[0].name} came first.`);
5997
+ warnings.push(
5998
+ `${t.name} was ignored: a shot is made in one place, and ${inlineTemplates[0].name} came first.`
5999
+ );
5998
6000
  break;
5999
6001
  }
6000
6002
  inlineTemplates.push(t);
@@ -9891,7 +9893,7 @@ function startAssetBuild(deps, input) {
9891
9893
  const brand = core.store.getBrand(input.brandId);
9892
9894
  if (!brand) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
9893
9895
  const prior = input.sceneId ? brandSceneById(brand.json, input.sceneId) : void 0;
9894
- if (input.sceneId && !prior) throw Object.assign(new Error("scene not found"), { statusCode: 404 });
9896
+ if (input.sceneId && !prior) throw Object.assign(new Error("place not found"), { statusCode: 404 });
9895
9897
  const supplied = input.imageHashes.length ? input.imageHashes : (prior?.refs ?? []).map((r) => String(r?.file ?? "").replace(/^asset:/, ""));
9896
9898
  const hashes = [...new Set(supplied)].filter((h) => /^[a-f0-9]{32}$/.test(h) && core.images.has(h)).slice(0, 8);
9897
9899
  if (!hashes.length && !input.instruction?.trim()) {
@@ -9901,7 +9903,7 @@ function startAssetBuild(deps, input) {
9901
9903
  id: `ab-${randomUUID().slice(0, 8)}`,
9902
9904
  brandId: brand.id,
9903
9905
  kind: input.kind,
9904
- name: str3(input.name, 60) || "New scene",
9906
+ name: str3(input.name, 60) || "New place",
9905
9907
  stage: "queued",
9906
9908
  step: 0,
9907
9909
  steps: 1,
@@ -10206,7 +10208,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
10206
10208
  if (signal.aborted) throw new Error("cancelled");
10207
10209
  const built = sceneRecordFrom(
10208
10210
  {
10209
- name: job.name || draft?.name || "New scene",
10211
+ name: job.name || draft?.name || "New place",
10210
10212
  promptName: draft?.promptName,
10211
10213
  lighting: draft?.lighting,
10212
10214
  description: draft?.description ?? instruction,
@@ -10240,7 +10242,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
10240
10242
  patch(job, { step: 1, previewHash });
10241
10243
  } catch (err) {
10242
10244
  if (signal.aborted) throw err;
10243
- patch(job, { warnings: [...job.warnings, "The preview could not be drawn. The scene is still usable."] });
10245
+ patch(job, { warnings: [...job.warnings, "The preview could not be drawn. The place is still usable."] });
10244
10246
  }
10245
10247
  }
10246
10248
  if (signal.aborted) throw new Error("cancelled");
@@ -10250,7 +10252,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
10250
10252
  commit(core, job.brandId, (json) => {
10251
10253
  const rows = brandScenes(json);
10252
10254
  const at = rows.findIndex((s) => s?.id === scene.id);
10253
- if (at < 0 && job.sceneId) throw new Error("This scene was deleted while it was being read again.");
10255
+ if (at < 0 && job.sceneId) throw new Error("This place was deleted while it was being read again.");
10254
10256
  json.scenes = at >= 0 ? rows.map((s, i) => i === at ? scene : s) : [...rows, scene];
10255
10257
  });
10256
10258
  patch(job, {
@@ -12856,9 +12858,9 @@ function registerSceneRoutes(app, deps) {
12856
12858
  const id = /^[a-z0-9-]+$/.exec(String(p.id))?.[0];
12857
12859
  const view = isSceneView(p.view) ? p.view : null;
12858
12860
  const slot = view ? slotOfView(view) : null;
12859
- if (!id || !scenes.some((s) => s.id === id)) return reply.status(404).send({ error: "scene not found" });
12861
+ if (!id || !scenes.some((s) => s.id === id)) return reply.status(404).send({ error: "place not found" });
12860
12862
  if (!slot || !existsSync(refPath(id, slot)))
12861
- return reply.status(404).send({ error: "this scene has no such view" });
12863
+ return reply.status(404).send({ error: "this place has no such view" });
12862
12864
  const hash = core.images.save(
12863
12865
  await sharp22(readFileSync(refPath(id, slot))).png().toBuffer()
12864
12866
  );
@@ -13335,8 +13337,8 @@ function openPresenterEdit(core, brandId, presenterId) {
13335
13337
  if (!brand) throw fail("brand not found", 404);
13336
13338
  const rows = brandCharacters(brand.json);
13337
13339
  const asked = rows.find((c) => c?.id === presenterId);
13338
- if (!asked) throw fail("presenter not found", 404);
13339
- if (!isCustomPresenter(asked)) throw fail("this presenter is not editable", 400);
13340
+ if (!asked) throw fail("person not found", 404);
13341
+ if (!isCustomPresenter(asked)) throw fail("this person is not editable", 400);
13340
13342
  const headId = headOf(brand.json, presenterId);
13341
13343
  const head = rows.find((c) => c?.id === headId);
13342
13344
  return listPresenterDrafts(core, brandId).find((d) => d.presenterId === headId) ?? seedDraftFromPresenter(core, brandId, head);
@@ -13436,7 +13438,7 @@ async function filePhotos(deps, id, signal) {
13436
13438
  {
13437
13439
  kind: "presenter",
13438
13440
  imagePaths: rec.sources.map((h) => core.images.pathFor(h)),
13439
- name: rec.name || "New presenter",
13441
+ name: rec.name || "New person",
13440
13442
  instruction: rec.direction || void 0,
13441
13443
  vocabulary: deps.vocabulary,
13442
13444
  classifyPhotos: true
@@ -13554,7 +13556,7 @@ async function drawView(deps, id, view, before, adjustment, decide, signal) {
13554
13556
  {
13555
13557
  kind: "presenter",
13556
13558
  imagePaths: [core.images.pathFor(face)],
13557
- name: rec.name || "New presenter",
13559
+ name: rec.name || "New person",
13558
13560
  instruction: rec.direction || void 0,
13559
13561
  vocabulary: deps.vocabulary
13560
13562
  },
@@ -13957,8 +13959,8 @@ async function saveEdit(deps, rec, hooks) {
13957
13959
  const brand = core.store.getBrand(rec.brandId);
13958
13960
  if (!brand) throw fail("brand not found", 404);
13959
13961
  const base = brandCharacters(brand.json).find((c) => c?.id === rec.presenterId);
13960
- if (!base) throw fail("this presenter no longer exists", 404);
13961
- const moved = () => fail("this presenter changed elsewhere; reload to continue", 409);
13962
+ if (!base) throw fail("this person no longer exists", 404);
13963
+ const moved = () => fail("this person changed elsewhere; reload to continue", 409);
13962
13964
  const baseId = rec.baseId ?? "";
13963
13965
  if (!baseId || headOf(brand.json, baseId) !== baseId) throw moved();
13964
13966
  if (!rec.name.trim()) throw fail("give them a name", 400);
@@ -14364,7 +14366,7 @@ function createSceneExamples(deps) {
14364
14366
  );
14365
14367
  const who = (json?.characters ?? []).find((c) => c?.id === presenterId);
14366
14368
  const refs = (who?.shots ?? []).map((s) => hashOf2(s.file)).filter((h) => !!h && deps.core.images.has(h)).slice(0, 3);
14367
- if (!refs.length) throw new Error("Scenri's library of demo presenters has not downloaded yet.");
14369
+ if (!refs.length) throw new Error("Scenri's library of demo people has not downloaded yet.");
14368
14370
  return {
14369
14371
  refs,
14370
14372
  name: String(who?.promptName ?? who?.name ?? "the person"),
@@ -14584,12 +14586,12 @@ function createSceneExamples(deps) {
14584
14586
  placeChanged,
14585
14587
  start(brandId, sceneId, roles, named = false) {
14586
14588
  const scene = sceneOf(brandId, sceneId);
14587
- if (!scene) throw Object.assign(new Error("scene not found"), { statusCode: 404 });
14589
+ if (!scene) throw Object.assign(new Error("place not found"), { statusCode: 404 });
14588
14590
  if (!scene.preview)
14589
- throw Object.assign(new Error("this scene has no picture to draw from yet"), { statusCode: 409 });
14591
+ throw Object.assign(new Error("this place has no picture to draw from yet"), { statusCode: 409 });
14590
14592
  const job = begin(brandId, sceneId, [...new Set(roles)], scene, named);
14591
14593
  if (!job) {
14592
- const why = standInsOf(scene, deps.demoProducts, deps.presenters) ? "Scenri's library has not downloaded yet, so it cannot be shown in use for now." : "Nothing in Scenri's library fits this scene yet.";
14594
+ const why = standInsOf(scene, deps.demoProducts, deps.presenters) ? "Scenri's library has not downloaded yet, so it cannot be shown in use for now." : "Nothing in Scenri's library fits this place yet.";
14593
14595
  throw Object.assign(new Error(why), { statusCode: 409 });
14594
14596
  }
14595
14597
  return job;
@@ -14696,13 +14698,13 @@ var fail2 = (message, statusCode = 400) => Object.assign(new Error(message), { s
14696
14698
  var oneLine2 = (v, max) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, max);
14697
14699
  function nameFromWords(words) {
14698
14700
  const cut = oneLine2(words, 200).split(/[.,;:!?\n]/)[0].split(" ").filter(Boolean).slice(0, 4).join(" ");
14699
- if (!cut) return "New scene";
14701
+ if (!cut) return "New place";
14700
14702
  return (cut.charAt(0).toUpperCase() + cut.slice(1)).slice(0, 60);
14701
14703
  }
14702
14704
  function readingFrom(raw) {
14703
14705
  const r = raw ?? {};
14704
14706
  const built = sceneRecordFrom({
14705
- name: oneLine2(r.name, 60) || "New scene",
14707
+ name: oneLine2(r.name, 60) || "New place",
14706
14708
  promptName: r.promptName,
14707
14709
  prompt: r.prompt,
14708
14710
  lighting: r.lighting,
@@ -14918,7 +14920,7 @@ function startSceneStudioJob(deps, input) {
14918
14920
  attachFrom: null,
14919
14921
  conversation,
14920
14922
  sceneId: oneLine2(input.sceneId, 80) || null,
14921
- label: oneLine2(input.label, 60) || reading?.name || nameFromWords(instruction) || "New scene"
14923
+ label: oneLine2(input.label, 60) || reading?.name || nameFromWords(instruction) || "New place"
14922
14924
  };
14923
14925
  jobs.set(job.id, job);
14924
14926
  prune2(input.brandId);
@@ -15038,9 +15040,9 @@ async function run2(deps, job, input, signal) {
15038
15040
  if (job.attachTo) {
15039
15041
  const landed = landOn(deps, job.brandId, job.attachTo, hash, job.attachFrom, job.anchor, heroOf(job));
15040
15042
  if (landed === "gone")
15041
- patch2(job, { warnings: [...job.warnings, "The scene was gone before its picture landed."] });
15043
+ patch2(job, { warnings: [...job.warnings, "The place was gone before its picture landed."] });
15042
15044
  if (landed === "moved")
15043
- patch2(job, { warnings: [...job.warnings, "The scene had a new picture by then, so it kept that one."] });
15045
+ patch2(job, { warnings: [...job.warnings, "The place had a new picture by then, so it kept that one."] });
15044
15046
  }
15045
15047
  }
15046
15048
  patch2(job, { status: "done", phase: null, finishedAt: now() });
@@ -15244,8 +15246,8 @@ function registerAssetBuildRoutes(app, deps) {
15244
15246
  if (!brand) return;
15245
15247
  const id = String(req.params.presenterId);
15246
15248
  const base = brandCharacters(brand.json).find((c) => c.id === id);
15247
- if (!base) return reply.status(404).send({ error: "presenter not found" });
15248
- if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this presenter is not editable" });
15249
+ if (!base) return reply.status(404).send({ error: "person not found" });
15250
+ if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this person is not editable" });
15249
15251
  const built = presenterRecordFrom(await withDerivedCrops(core, storedOnly(core, req.body), base), base);
15250
15252
  if (!built.ok) return reply.status(400).send({ error: built.error });
15251
15253
  try {
@@ -15263,8 +15265,8 @@ function registerAssetBuildRoutes(app, deps) {
15263
15265
  const id = String(req.params.presenterId);
15264
15266
  const rows = brandCharacters(brand.json);
15265
15267
  const current = rows.find((c) => c.id === id);
15266
- if (!current) return reply.status(404).send({ error: "presenter not found" });
15267
- if (!isCustomPresenter(current)) return reply.status(400).send({ error: "this presenter is not editable" });
15268
+ if (!current) return reply.status(404).send({ error: "person not found" });
15269
+ if (!isCustomPresenter(current)) return reply.status(400).send({ error: "this person is not editable" });
15268
15270
  const older = current.revisionOf && !current.supersededBy ? rows.find((c) => c.id === current.revisionOf) : void 0;
15269
15271
  if (!older) return reply.status(400).send({ error: "nothing to revert" });
15270
15272
  try {
@@ -15297,8 +15299,8 @@ function registerAssetBuildRoutes(app, deps) {
15297
15299
  const id = String(req.params.presenterId);
15298
15300
  const rows = brandCharacters(brand.json);
15299
15301
  const base = rows.find((c) => c.id === id);
15300
- if (!base) return reply.status(404).send({ error: "presenter not found" });
15301
- if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this presenter is not editable" });
15302
+ if (!base) return reply.status(404).send({ error: "person not found" });
15303
+ if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this person is not editable" });
15302
15304
  const gone = rows.filter((c) => c.id === id || headOf(brand.json, c.id) === id);
15303
15305
  const goneIds = new Set(gone.map((c) => c.id));
15304
15306
  commit(core, brand.id, (json) => {
@@ -15340,7 +15342,7 @@ function registerAssetBuildRoutes(app, deps) {
15340
15342
  if (!brand) return;
15341
15343
  const id = String(req.params.sceneId);
15342
15344
  const base = brandScenes(brand.json).find((s) => s.id === id);
15343
- if (!base) return reply.status(404).send({ error: "scene not found" });
15345
+ if (!base) return reply.status(404).send({ error: "place not found" });
15344
15346
  const body = sceneBody2(core, req.body);
15345
15347
  if ("error" in body) return reply.status(400).send({ error: body.error });
15346
15348
  const built = sceneRecordFrom(body, base);
@@ -15367,7 +15369,7 @@ function registerAssetBuildRoutes(app, deps) {
15367
15369
  if (!brand) return;
15368
15370
  const id = String(req.params.sceneId);
15369
15371
  const gone = brandScenes(brand.json).find((s) => s.id === id);
15370
- if (!gone) return reply.status(404).send({ error: "scene not found" });
15372
+ if (!gone) return reply.status(404).send({ error: "place not found" });
15371
15373
  cancelSceneBuilds(brand.id, id);
15372
15374
  cancelSceneStudioFor(brand.id, id);
15373
15375
  let row;
@@ -15387,11 +15389,11 @@ function registerAssetBuildRoutes(app, deps) {
15387
15389
  if (!brand) return;
15388
15390
  const id = String(req.params.sceneId);
15389
15391
  const scene = brandScenes(brand.json).find((s) => s.id === id);
15390
- if (!scene) return reply.status(404).send({ error: "scene not found" });
15392
+ if (!scene) return reply.status(404).send({ error: "place not found" });
15391
15393
  if (!(scene.refs ?? []).length)
15392
- return reply.status(400).send({ error: "this scene was written from words, so there is nothing to read again" });
15394
+ return reply.status(400).send({ error: "this place was written from words, so there is nothing to read again" });
15393
15395
  if (sceneBuildRunning(brand.id, id))
15394
- return reply.status(409).send({ error: "this scene is already being read again" });
15396
+ return reply.status(409).send({ error: "this place is already being read again" });
15395
15397
  const body = req.body ?? {};
15396
15398
  try {
15397
15399
  return startAssetBuild(await buildDeps(), {
@@ -15415,7 +15417,7 @@ function registerAssetBuildRoutes(app, deps) {
15415
15417
  if (!brand) return;
15416
15418
  const id = String(req.params.sceneId);
15417
15419
  const scene = brandScenes(brand.json).find((s) => s.id === id);
15418
- if (!scene) return reply.status(404).send({ error: "scene not found" });
15420
+ if (!scene) return reply.status(404).send({ error: "place not found" });
15419
15421
  const engine = await buildEngine();
15420
15422
  if (!engine) return reply.status(400).send({ error: "no engine here can draw a preview" });
15421
15423
  const own = scene;
@@ -15732,7 +15734,7 @@ function registerSceneStudioRoutes(app, deps) {
15732
15734
  const job = jobOr404(req, reply, brand.id);
15733
15735
  if (!job) return;
15734
15736
  const sceneId = String((req.body ?? {}).sceneId ?? "");
15735
- if (!sceneId) return reply.status(400).send({ error: "which scene?" });
15737
+ if (!sceneId) return reply.status(400).send({ error: "which place?" });
15736
15738
  const state = attachSceneStudioJob(await buildDeps(), job.id, sceneId);
15737
15739
  return { state, brand: core.store.getBrand(brand.id) };
15738
15740
  });
@@ -15746,7 +15748,7 @@ function registerSceneExampleRoutes(app, deps) {
15746
15748
  const brand = core.store.getBrand(String(req.params.id));
15747
15749
  const scene = brand ? brandScenes(brand.json).find((s) => s.id === String(req.params.sceneId)) : void 0;
15748
15750
  if (!brand || !scene) {
15749
- reply.status(404).send({ error: "scene not found" });
15751
+ reply.status(404).send({ error: "place not found" });
15750
15752
  return null;
15751
15753
  }
15752
15754
  return { brandId: brand.id, scene };
@@ -16301,6 +16303,7 @@ function readme(json, missing) {
16301
16303
  "brand.json holds the whole kit; assets/ holds every image it references,",
16302
16304
  "and the file fields inside brand.json are paths relative to brand.json.",
16303
16305
  "Format: https://scenri.co/schema/brand-0.1.schema.json",
16306
+ "Scenri shows characters[] as People and scenes[] as Places.",
16304
16307
  "",
16305
16308
  "Products imported from a store catalog are not included: this bundle is the",
16306
16309
  "brand document as stored, and catalog products are resolved at generation",
@@ -16383,6 +16386,41 @@ function registerImageRoutes(app, deps) {
16383
16386
  var HEADLINES_KEPT = 5;
16384
16387
  var FIRST_PUBLIC = "0.2.0";
16385
16388
  var RELEASES = [
16389
+ {
16390
+ version: "0.22.0",
16391
+ date: "2026-10-07",
16392
+ title: "Presenters are now People, and Scenes are now Places",
16393
+ announce: true,
16394
+ sections: [
16395
+ {
16396
+ heading: "People",
16397
+ body: "Presenters are called People now. Everyone you made is where you left them, with the same face, and a link to an old Presenters page still opens.",
16398
+ image: {
16399
+ file: "0.22.0-people-places.webp",
16400
+ alt: "Two chips side by side, People and Places, each with its picture and a caret to open it."
16401
+ }
16402
+ },
16403
+ {
16404
+ heading: "Places",
16405
+ body: "Scenes are called Places now, with the same pictures and light. A place's empty picture is called Empty, and old links to Scenes still open."
16406
+ },
16407
+ {
16408
+ heading: "Help",
16409
+ body: "The Help menu now opens Scenri's website and the guides on it."
16410
+ }
16411
+ ]
16412
+ },
16413
+ {
16414
+ version: "0.21.2",
16415
+ date: "2026-10-05",
16416
+ title: "Halvard 12 has a full-size product photo",
16417
+ sections: [
16418
+ {
16419
+ heading: "Examples",
16420
+ body: "The whisky bottle now has a photo as large as the other products, so its label stays readable, and a shot made from it is told every word on that label."
16421
+ }
16422
+ ]
16423
+ },
16386
16424
  {
16387
16425
  version: "0.21.1",
16388
16426
  date: "2026-10-04",
@@ -16693,11 +16731,7 @@ var RELEASES = [
16693
16731
  sections: [
16694
16732
  {
16695
16733
  heading: "Local access",
16696
- 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.",
16697
- image: {
16698
- file: "0.16.0-local-access.webp",
16699
- alt: "The Local access card: a QR code to scan, the address and six-digit code to type, and an iPhone just connected."
16700
- }
16734
+ 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."
16701
16735
  },
16702
16736
  {
16703
16737
  heading: "Security",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "url": "git+https://github.com/tonygorb/scenri.git",
67
67
  "directory": "packages/cli"
68
68
  },
69
- "homepage": "https://github.com/tonygorb/scenri#readme",
69
+ "homepage": "https://scenri.co",
70
70
  "bugs": {
71
71
  "url": "https://github.com/tonygorb/scenri/issues"
72
72
  },