scenri 0.20.3 → 0.20.5

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.20.5](https://github.com/tonygorb/Scenri/compare/v0.20.4...v0.20.5) (2026-09-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **deps:** lift undici to 7.30.0 for the WebSocket inflate DoS ([7a819c5](https://github.com/tonygorb/Scenri/commit/7a819c544ab486ac22565547da6b54e1efea4099))
9
+ * **deps:** lift undici to 7.30.0 for the WebSocket inflate DoS ([b93f415](https://github.com/tonygorb/Scenri/commit/b93f415d8272a3184e6aa50d7f3579d95a48cd70))
10
+
11
+ ## [0.20.4](https://github.com/tonygorb/Scenri/compare/v0.20.3...v0.20.4) (2026-09-28)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **codex:** run on the Codex default when the plan lacks gpt-6-sol ([e7dc36d](https://github.com/tonygorb/Scenri/commit/e7dc36df2698e4ce22de66c8a997252af04c42f0))
17
+ * **codex:** run on the Codex default when the plan lacks gpt-6-sol, keep only drawn pictures ([7851c59](https://github.com/tonygorb/Scenri/commit/7851c5944c5e4cbfd17ef7ac12cd6068f57e84b7))
18
+ * **codex:** store only a picture the image tool drew ([4573de7](https://github.com/tonygorb/Scenri/commit/4573de7944a479b0585af1fe62ec9cd9bce470b2))
19
+ * **studio:** reload a tab whose server came back as another version ([22c5b6d](https://github.com/tonygorb/Scenri/commit/22c5b6d7f730d37e8435923feec1266fb3fe11ca))
20
+ * **studio:** reload a tab whose server came back as another version ([06629f0](https://github.com/tonygorb/Scenri/commit/06629f09dcb5b47551ae18ce2a88ed3985e3579d))
21
+
3
22
  ## [0.20.3](https://github.com/tonygorb/Scenri/compare/v0.20.2...v0.20.3) (2026-09-27)
4
23
 
5
24
 
package/README.md CHANGED
@@ -6,20 +6,22 @@
6
6
  </picture>
7
7
  <br><br>
8
8
 
9
- **The open studio for brand-consistent AI visuals.**<br>
10
- Define a client's brand once. Then put its products and people into scenes, art-direct each shot in plain words, and refine it step by step. Open source, running entirely on your own machine and your own AI accounts.
9
+ **The open-source AI photo studio that remembers your products, people and places.**<br>
10
+ 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
11
 
12
12
  [![CI](https://github.com/tonygorb/scenri/actions/workflows/ci.yml/badge.svg)](https://github.com/tonygorb/scenri/actions/workflows/ci.yml)
13
13
  [![npm](https://img.shields.io/npm/v/scenri)](https://www.npmjs.com/package/scenri)
14
14
  [![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0-blue)](https://github.com/tonygorb/scenri/blob/main/LICENSE)
15
15
 
16
- **Get started:** one command, no account.
16
+ **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
+
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>
17
19
 
18
20
  ```bash
19
21
  npx scenri
20
22
  ```
21
23
 
22
- <sub>Needs [Node.js](https://nodejs.org) 22 or newer. New to any of this? The [install guide](https://github.com/tonygorb/scenri/blob/main/docs/INSTALL.md) walks through every step.</sub>
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>
23
25
 
24
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">
25
27
 
@@ -33,10 +35,16 @@ npx scenri
33
35
 
34
36
  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**.
35
37
 
36
- Every shot keeps its history. Open it, say what to change, and each refinement joins a trail under the picture, so the original and every step stay one click apart. Refine any step again, or use **Reuse setup** to start a new shot from the same prompt and chips.
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.
39
+
40
+ 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
+
42
+ Scenri trains nothing. It sends your saved photos with each shot as reference images, and the image model draws the product and the person from them, so a shot comes close to the real thing rather than pasting it in, and small lettering can drift. When you refine one part of a finished shot, or change its shape, Scenri keeps the original picture's own pixels wherever it can.
37
43
 
38
44
  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.
39
45
 
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).
47
+
40
48
  ## What a prompt produces
41
49
 
42
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:
package/dist/serve.js CHANGED
@@ -967,6 +967,9 @@ function codexFailureDetail(stderr, stdout) {
967
967
  return tailOf(marked) || tailOf(body) || tailOf(stdout.trim()) || tailOf(stderr.trim());
968
968
  }
969
969
  var MODEL_NEEDS_NEWER_CODEX = /The '([^']+)' model requires a newer version of Codex/;
970
+ var MODEL_NOT_ON_PLAN = /The '([^']+)' model is not supported when using Codex with a ChatGPT account/;
971
+ var ModelNotOnPlan = class extends Error {
972
+ };
970
973
  var BAD_KEY_401 = /incorrect api key provided|invalid_api_key|invalid api key/i;
971
974
  function tooOldForModel(version, model) {
972
975
  return `Codex CLI ${version ?? "on this computer"} is too old for the model it is set to, ${model}.`;
@@ -1020,18 +1023,33 @@ function createRunner(opts = {}) {
1020
1023
  }
1021
1024
  let knownVersion = null;
1022
1025
  let tooOldFor = null;
1026
+ const offPlan = /* @__PURE__ */ new Set();
1027
+ const withoutOffPlan = (rest) => {
1028
+ const i = rest.indexOf("-m");
1029
+ return i >= 0 && offPlan.has(rest[i + 1]) ? [...rest.slice(0, i), ...rest.slice(i + 2)] : rest;
1030
+ };
1023
1031
  const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
1024
1032
  const spawnCodex = (exe, args, stdinOpen) => {
1025
1033
  const stdio = [stdinOpen ? "pipe" : "ignore", "pipe", "pipe"];
1026
1034
  const env = buildChildEnv(parentEnv, [...ignoreEnvKeys(), ...SIBLING_PROVIDER_KEYS]);
1027
- if (args[0] === "exec") args = ["exec", ...extensionsOffArgs(parentEnv), ...args.slice(1)];
1035
+ if (args[0] === "exec") args = ["exec", ...extensionsOffArgs(parentEnv), ...withoutOffPlan(args.slice(1))];
1028
1036
  return exe.direct ? spawnImpl(exe.command, args, { stdio, env, ...platform !== "win32" ? { detached: true } : {} }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, env, shell: true });
1029
1037
  };
1030
1038
  async function run4(args, signal, io) {
1039
+ try {
1040
+ await runOnce(args, signal, io);
1041
+ } catch (err) {
1042
+ if (!(err instanceof ModelNotOnPlan)) throw err;
1043
+ await runOnce(args, signal, io);
1044
+ }
1045
+ }
1046
+ async function runOnce(args, signal, io) {
1031
1047
  const exe = await resolution();
1032
1048
  const timeoutMs = io?.timeoutMs ?? defaultTimeoutMs;
1033
1049
  const launchedAs = args[0] === "exec" ? fingerprintFor(exe, knownVersion) : null;
1034
1050
  return new Promise((resolve, reject) => {
1051
+ const sent = withoutOffPlan(args.slice(1));
1052
+ const sentModel = sent.includes("-m") ? sent[sent.indexOf("-m") + 1] : null;
1035
1053
  let child;
1036
1054
  try {
1037
1055
  child = spawnCodex(exe, args, io?.stdin != null);
@@ -1145,6 +1163,26 @@ function createRunner(opts = {}) {
1145
1163
  );
1146
1164
  return;
1147
1165
  }
1166
+ const refused = MODEL_NOT_ON_PLAN.exec(stderr);
1167
+ if (refused) {
1168
+ const model = refused[1];
1169
+ if (sentModel === model) {
1170
+ if (!offPlan.has(model))
1171
+ console.warn(`codex: ${model} is not on this ChatGPT plan, running on the Codex default`);
1172
+ offPlan.add(model);
1173
+ finish("model-not-on-plan", () => reject(new ModelNotOnPlan(`${model} is not on this ChatGPT plan`)));
1174
+ return;
1175
+ }
1176
+ finish(
1177
+ `exit-${code ?? "unknown"}`,
1178
+ () => reject(
1179
+ new Error(
1180
+ `Your ChatGPT plan cannot run ${model} in Codex. Choose another model in Codex, then run this again.`
1181
+ )
1182
+ )
1183
+ );
1184
+ return;
1185
+ }
1148
1186
  const conflicting = presentConflictKeys(parentEnv, ignoreEnvKeys());
1149
1187
  if (conflicting.length > 0 && /\b401\b|unauthorized/i.test(stderr) && BAD_KEY_401.test(stderr)) {
1150
1188
  noteConnection("refused", {
@@ -1780,14 +1818,57 @@ function createCodexEngine(opts) {
1780
1818
  const withWorkDir = runner.withWorkDir;
1781
1819
  const generatedImagesDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "generated_images");
1782
1820
  async function snapshotGenerated() {
1783
- if (platform !== "win32") return null;
1784
1821
  try {
1785
1822
  return new Set(await readdir(generatedImagesDir()));
1786
1823
  } catch {
1787
1824
  return /* @__PURE__ */ new Set();
1788
1825
  }
1789
1826
  }
1790
- async function collectImages(dir, before = null, claimed) {
1827
+ async function drawnSince(before, claimed) {
1828
+ const home = generatedImagesDir();
1829
+ let names;
1830
+ try {
1831
+ names = await readdir(home);
1832
+ } catch {
1833
+ return [];
1834
+ }
1835
+ const found = [];
1836
+ const consider = async (path) => {
1837
+ const st = await lstat(path);
1838
+ if (st.isFile() && /\.png$/i.test(path) && !claimed.has(path)) found.push({ path, mtime: st.mtimeMs });
1839
+ };
1840
+ for (const name of names) {
1841
+ if (before.has(name)) continue;
1842
+ const at = join(home, name);
1843
+ try {
1844
+ const st = await lstat(at);
1845
+ if (st.isDirectory()) for (const file of await readdir(at)) await consider(join(at, file));
1846
+ else await consider(at);
1847
+ } catch {
1848
+ }
1849
+ }
1850
+ return found.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
1851
+ }
1852
+ async function claimCopy(drawn, buf, claimed) {
1853
+ for (const path of drawn) {
1854
+ try {
1855
+ if ((await stat(path)).size !== buf.length || !(await readFile(path)).equals(buf)) continue;
1856
+ } catch {
1857
+ continue;
1858
+ }
1859
+ claimed.add(path);
1860
+ return true;
1861
+ }
1862
+ return false;
1863
+ }
1864
+ async function takeDrawn(path, claimed) {
1865
+ claimed.add(path);
1866
+ const buf = await readLeftFile(path, basename(path));
1867
+ const hash = saveImage(buf);
1868
+ await forgetGenerated(buf);
1869
+ return hash;
1870
+ }
1871
+ async function collectImages(dir, before, claimed = /* @__PURE__ */ new Set()) {
1791
1872
  const entries = await readdir(dir);
1792
1873
  const outFiles = entries.filter((name) => /^out-.*\.png$/.test(name)).sort((a, b) => {
1793
1874
  const na = Number(/^out-(\d+)\.png$/.exec(a)?.[1] ?? NaN);
@@ -1796,9 +1877,12 @@ function createCodexEngine(opts) {
1796
1877
  return a.localeCompare(b);
1797
1878
  });
1798
1879
  if (outFiles.length === 0) {
1799
- if (before) {
1800
- const recovered = await recoverFromGenerated(before, claimed);
1801
- if (recovered) return [recovered];
1880
+ if (platform === "win32") {
1881
+ const [newest] = await drawnSince(before, claimed);
1882
+ if (newest) {
1883
+ console.warn(`codex: workdir empty, recovered ${newest}`);
1884
+ return [await takeDrawn(newest, claimed)];
1885
+ }
1802
1886
  }
1803
1887
  throw new Error("Codex finished but produced no images");
1804
1888
  }
@@ -1806,6 +1890,12 @@ function createCodexEngine(opts) {
1806
1890
  for (const name of outFiles) {
1807
1891
  const buf = await readLeftFile(join(dir, name), name);
1808
1892
  if (buf.length === 0) throw new Error(`codex: ${name} is empty`);
1893
+ const drawn = platform === "win32" ? [] : await drawnSince(before, claimed);
1894
+ if (drawn.length > 0 && !await claimCopy(drawn, buf, claimed)) {
1895
+ console.warn(`codex: ${name} is not a picture the image tool drew, taking the tool's own file`);
1896
+ hashes.push(await takeDrawn(drawn[0], claimed));
1897
+ continue;
1898
+ }
1809
1899
  hashes.push(saveImage(buf));
1810
1900
  await forgetGenerated(buf);
1811
1901
  }
@@ -1844,22 +1934,6 @@ function createCodexEngine(opts) {
1844
1934
  }
1845
1935
  }
1846
1936
  }
1847
- async function recoverFromGenerated(before, claimed) {
1848
- const home = generatedImagesDir();
1849
- let names;
1850
- try {
1851
- names = (await readdir(home)).filter((n) => !before.has(n) && !claimed?.has(n));
1852
- } catch {
1853
- return null;
1854
- }
1855
- if (!names.length) return null;
1856
- const stamped = await Promise.all(names.map(async (n) => ({ n, mtime: (await stat(join(home, n))).mtimeMs })));
1857
- stamped.sort((a, b) => b.mtime - a.mtime);
1858
- const pick2 = stamped[0].n;
1859
- claimed?.add(pick2);
1860
- console.warn(`codex: workdir empty, recovered ${pick2} from ${home}`);
1861
- return saveImage(await readLeftFile(join(home, pick2), pick2));
1862
- }
1863
1937
  return {
1864
1938
  capabilities() {
1865
1939
  return {
@@ -16312,6 +16386,32 @@ function registerImageRoutes(app, deps) {
16312
16386
  var HEADLINES_KEPT = 5;
16313
16387
  var FIRST_PUBLIC = "0.2.0";
16314
16388
  var RELEASES = [
16389
+ {
16390
+ version: "0.20.5",
16391
+ date: "2026-09-29",
16392
+ title: "One dependency is on its patched release",
16393
+ sections: [
16394
+ {
16395
+ heading: "Security",
16396
+ body: "The network library under the page reader Scenri reads websites with is on its patched release, for a WebSocket advisory Scenri never reached. Nothing about how Scenri works changes."
16397
+ }
16398
+ ]
16399
+ },
16400
+ {
16401
+ version: "0.20.4",
16402
+ date: "2026-09-28",
16403
+ title: "Shots made with Codex work on every ChatGPT plan",
16404
+ sections: [
16405
+ {
16406
+ heading: "Create",
16407
+ body: "When your ChatGPT plan does not offer the model Scenri asks Codex for, the shot now runs on the model your Codex is set to, with every reference picture still attached."
16408
+ },
16409
+ {
16410
+ heading: "Fixes",
16411
+ body: "On Mac and Linux a shot keeps only the picture Codex drew, never a stray file. An open tab reloads itself when Scenri restarts on a new version."
16412
+ }
16413
+ ]
16414
+ },
16315
16415
  {
16316
16416
  version: "0.20.3",
16317
16417
  date: "2026-09-27",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -79,5 +79,5 @@
79
79
  "design",
80
80
  "cli"
81
81
  ],
82
- "description": "The open studio for brand-consistent AI visuals. Runs locally, on your own AI accounts."
82
+ "description": "The open-source AI photo studio that remembers your products, people and places. Runs locally, on your own AI accounts."
83
83
  }