scenri 0.21.2 → 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 +10 -0
- package/README.md +15 -11
- package/dist/index.js +2 -0
- package/dist/serve.js +75 -52
- package/package.json +2 -2
- package/studio-dist/assets/0.22.0-people-places-DBgKia30.webp +0 -0
- package/studio-dist/assets/index-BH6xQdzN.js +108 -0
- package/studio-dist/index.html +1 -1
- package/studio-dist/assets/0.16.0-local-access-D7XQonLd.webp +0 -0
- package/studio-dist/assets/index-Ce9knTi7.js +0 -108
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## [0.21.2](https://github.com/tonygorb/Scenri/compare/v0.21.1...v0.21.2) (2026-10-05)
|
|
4
14
|
|
|
5
15
|
|
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
|
[](https://github.com/tonygorb/scenri/actions/workflows/ci.yml)
|
|
13
17
|
[](https://www.npmjs.com/package/scenri)
|
|
14
18
|
[](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://
|
|
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://
|
|
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
|
|
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
|
|
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
|
|
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://
|
|
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
|
|
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,9 +61,9 @@ 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
|
|
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
|
|
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
|
|
|
@@ -71,7 +75,7 @@ Node.js 22 or newer, then:
|
|
|
71
75
|
npx scenri
|
|
72
76
|
```
|
|
73
77
|
|
|
74
|
-
The [install guide](https://
|
|
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.
|
|
75
79
|
|
|
76
80
|
<details>
|
|
77
81
|
<summary>Run from source</summary>
|
|
@@ -93,7 +97,7 @@ pnpm dev # starts the server on 127.0.0.1:4747
|
|
|
93
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.
|
|
94
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)).
|
|
95
99
|
|
|
96
|
-
Providers, costs, and what each one can carry are in [Connect image generation](https://
|
|
100
|
+
Providers, costs, and what each one can carry are in [Connect image generation](https://scenri.co/docs/connect/).
|
|
97
101
|
|
|
98
102
|
## Configuration
|
|
99
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
|
|
4765
|
-
if (/\{[^}]*\}/.test(prompt)) return { ok: false, error: "a
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
4931
|
-
if (!isCustomPresenter(source)) return { ok: false, error: "this
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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("
|
|
13339
|
-
if (!isCustomPresenter(asked)) throw fail("this
|
|
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
|
|
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
|
|
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
|
|
13961
|
-
const moved = () => fail("this
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
15248
|
-
if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this
|
|
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: "
|
|
15267
|
-
if (!isCustomPresenter(current)) return reply.status(400).send({ error: "this
|
|
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: "
|
|
15301
|
-
if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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: "
|
|
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,30 @@ 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
|
+
},
|
|
16386
16413
|
{
|
|
16387
16414
|
version: "0.21.2",
|
|
16388
16415
|
date: "2026-10-05",
|
|
@@ -16704,11 +16731,7 @@ var RELEASES = [
|
|
|
16704
16731
|
sections: [
|
|
16705
16732
|
{
|
|
16706
16733
|
heading: "Local access",
|
|
16707
|
-
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."
|
|
16708
|
-
image: {
|
|
16709
|
-
file: "0.16.0-local-access.webp",
|
|
16710
|
-
alt: "The Local access card: a QR code to scan, the address and six-digit code to type, and an iPhone just connected."
|
|
16711
|
-
}
|
|
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."
|
|
16712
16735
|
},
|
|
16713
16736
|
{
|
|
16714
16737
|
heading: "Security",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenri",
|
|
3
|
-
"version": "0.
|
|
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://
|
|
69
|
+
"homepage": "https://scenri.co",
|
|
70
70
|
"bugs": {
|
|
71
71
|
"url": "https://github.com/tonygorb/scenri/issues"
|
|
72
72
|
},
|
|
Binary file
|