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 +19 -0
- package/README.md +17 -48
- package/dist/index.js +2 -0
- package/dist/serve.js +86 -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/templates/demo-products/halvard-12.json +3 -1
- package/templates/previews/demo-products/halvard-12/three-quarter.jpg +0 -0
- 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,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
|
[](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,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
|
|
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
|
|
|
66
70
|
## Run it
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
Node.js 22 or newer, then:
|
|
69
73
|
|
|
70
74
|
```bash
|
|
71
75
|
npx scenri
|
|
72
76
|
```
|
|
73
77
|
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,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.
|
|
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
|