scenri 0.17.0 → 0.17.1

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.1](https://github.com/tonygorb/Scenri/compare/v0.17.0...v0.17.1) (2026-09-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * keep the presenter's own body, marks and accessories in every shot ([51ecbf0](https://github.com/tonygorb/Scenri/commit/51ecbf0744e81939e10e04559ffabeff583547e9))
9
+ * keep the presenter's own body, marks and accessories in every shot ([6b6af6a](https://github.com/tonygorb/Scenri/commit/6b6af6a72338871de74d0b24bbf2c0af3dc0cc15))
10
+ * mend a cut trait to its last full sentence, not a fragment ([3c31a67](https://github.com/tonygorb/Scenri/commit/3c31a6725c20818683772131ccfae2fd41168634))
11
+
3
12
  ## [0.17.0](https://github.com/tonygorb/Scenri/compare/v0.16.0...v0.17.0) (2026-09-24)
4
13
 
5
14
 
package/dist/serve.js CHANGED
@@ -1355,7 +1355,7 @@ function presenterBody(req, refCount) {
1355
1355
  const same = refCount > 1 ? " Every attached photograph is the same person." : "";
1356
1356
  const ask = req.instruction ? ` The person who supplied these adds: ${req.instruction}.` : "";
1357
1357
  const categories = req.vocabulary?.categories?.length ? ` Choose "suitableCategories" only from this list: ${req.vocabulary.categories.join(", ")}.` : "";
1358
- return `These are photographs of one person, supplied by the person casting them.${same}${ask} Write a casting sheet: what a photographer would need in order to recognise this person again in a different shot. Do not name, identify, or guess who this person is, and do not use any proper name anywhere in your answer. Describe the person, not the photograph: their build, face and hair belong to them, but the background, crop, pose, clothing and lighting of these pictures belong to a shoot and must be left out. ${OUT_FILE} must be a JSON object with exactly these keys: "promptName": a short noun phrase a generator can be handed, such as "a woman in her early thirties with dark shoulder-length waves"; "presentation": either "woman" or "man"; "descriptor": a three-beat casting caption joined by " \xB7 ", such as "Warm editorial \xB7 dark waves \xB7 composed"; "ageRange": an approximate range such as "early 30s"; "hair": colour, length, texture and how it is worn; "identityNotes": one paragraph naming the two or three features that must survive every generation, drawn from face shape, eyes, nose, mouth, jaw, skin, distinctive marks, and build where it is visible; "negativeConstraints": an array of short refusals for the drift these photographs invite, such as "no youth-smoothing that erases the natural lines"; "suitableCategories": the industries this person would be cast for.${categories} "coverage": an array of at most two short sentences naming a view that is missing and would make this person more consistent, such as "A three-quarter photo would pin the cheekbones down." Use an empty array when the coverage is already good; "facial": bone structure in words, the face shape, jaw, cheekbones, eyes and brows a photographer would need to recognise them again; "skin": their skin tone and texture exactly as the photographs show it, never smoothed; "build": their body type and proportions where the photographs show them.` + (req.classifyPhotos ? photosClause(refCount) : "");
1358
+ return `These are photographs of one person, supplied by the person casting them.${same}${ask} Write a casting sheet: what a photographer would need in order to recognise this person again in a different shot. Do not name, identify, or guess who this person is, and do not use any proper name anywhere in your answer. Describe the person, not the photograph: their build, face and hair belong to them, but the background, crop, pose, clothing and lighting of these pictures belong to a shoot and must be left out. ${OUT_FILE} must be a JSON object with exactly these keys: "promptName": a short noun phrase a generator can be handed, such as "a woman in her early thirties with dark shoulder-length waves"; "presentation": either "woman" or "man"; "descriptor": a three-beat casting caption joined by " \xB7 ", such as "Warm editorial \xB7 dark waves \xB7 composed"; "ageRange": an approximate range such as "early 30s"; "hair": colour, length, texture and how it is worn; "identityNotes": one paragraph naming the two or three features that must survive every generation, drawn from face shape, eyes, nose, mouth, jaw, skin, distinctive marks, and build where it is visible. Name every tattoo, scar, birthmark, piercing and anything they always wear, such as glasses, by what it is, exactly where it sits and which side, as the person's own left or right; "negativeConstraints": an array of short refusals for the drift these photographs invite, such as "no youth-smoothing that erases the natural lines"; "suitableCategories": the industries this person would be cast for.${categories} "coverage": an array of at most two short sentences naming a view that is missing and would make this person more consistent, such as "A three-quarter photo would pin the cheekbones down." Use an empty array when the coverage is already good; "facial": bone structure in words, the face shape, jaw, cheekbones, eyes and brows a photographer would need to recognise them again; "skin": their skin tone and texture exactly as the photographs show it, never smoothed; "build": their body type and proportions where the photographs show them.` + (req.classifyPhotos ? photosClause(refCount) : "");
1359
1359
  }
1360
1360
  function photosClause(refCount) {
1361
1361
  return ` Also write "photos": an array with one entry per attached image in attachment order (ref-1.png is index 0, up to index ${Math.max(0, refCount - 1)}), each an object {"index", "view", "usable", "note"}, where "view" is exactly one of "portrait" (head and shoulders, the face large and facing the camera), "front" (full length, standing, facing the camera), "three-quarter" (turned about forty-five degrees, both eyes visible), "back" (full length, facing away from the camera), "left" (full length, the left side of the body to the camera), "right" (full length, the right side of the body to the camera), or "other"; "usable" is true only when the image is sharp, evenly lit, unobstructed, and shows this person clearly enough to stand in as that view; "note" is a few words on why. If the photographs appear to show more than one person, also write "conflict": one sentence saying which images disagree; otherwise leave "conflict" out.`;
@@ -1397,16 +1397,18 @@ function parsePresenter(req, o) {
1397
1397
  presentation,
1398
1398
  descriptor: cap(str(o.descriptor), 120),
1399
1399
  ageRange: cap(str(o.ageRange), 40),
1400
- hair: cap(str(o.hair), 120),
1400
+ // Cut at a clause, never mid-word: every shot of a person is told these,
1401
+ // and a hard slice sent "full-body proporti." with each one.
1402
+ hair: trait(o.hair, 120),
1401
1403
  identityNotes: cap(identityNotes, 900),
1402
1404
  negativeConstraints: list(o.negativeConstraints, 6, 160),
1403
1405
  suitableCategories: pick(o.suitableCategories, req.vocabulary?.categories, 6),
1404
1406
  coverage: sentences(o.coverage, 2, 240),
1405
1407
  // Non-blocking, like scene's `camera`: a model that omits or fumbles
1406
1408
  // these must not burn the single retry that exists for a broken contract.
1407
- ...optional("facial", cap(str(o.facial), 300)),
1408
- ...optional("skin", cap(str(o.skin), 200)),
1409
- ...optional("build", cap(str(o.build), 200)),
1409
+ ...optional("facial", trait(o.facial, 300)),
1410
+ ...optional("skin", trait(o.skin, 200)),
1411
+ ...optional("build", trait(o.build, 200)),
1410
1412
  ...req.classifyPhotos ? optional("photos", photoFilings(o.photos, req.imagePaths.length)) : {},
1411
1413
  ...req.classifyPhotos ? optional("conflict", cap(str(o.conflict), 200)) : {}
1412
1414
  }
@@ -1474,6 +1476,16 @@ function oneLine(v, max) {
1474
1476
  return (/\s/.test(one[max] ?? "") ? cut : cut.replace(/\s+\S*$/, "")).replace(/[\s,;:-]+$/, "");
1475
1477
  }
1476
1478
  var cap = (v, max) => v.length > max ? v.slice(0, max).trim() : v;
1479
+ function trait(v, max) {
1480
+ const one = str(v).replace(/\s+/g, " ");
1481
+ if (one.length <= max) return one;
1482
+ const cut = one.slice(0, max);
1483
+ const sentence = cut.lastIndexOf(". ");
1484
+ if (sentence >= max * 0.6) return cut.slice(0, sentence + 1);
1485
+ const clause = Math.max(cut.lastIndexOf(", "), cut.lastIndexOf("; "));
1486
+ if (clause > max / 3) return cut.slice(0, clause).trim();
1487
+ return cut.replace(/\s+\S*$/, "").replace(/[\s,;:-]+$/, "");
1488
+ }
1477
1489
  function sentences(v, max, each) {
1478
1490
  if (!Array.isArray(v)) return [];
1479
1491
  return v.map((x) => oneLine(x, each)).filter(Boolean).slice(0, max);
@@ -2274,6 +2286,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
2274
2286
  ...presenter.negativeConstraints?.length ? { negativeConstraints: presenter.negativeConstraints } : {},
2275
2287
  ...presenter.skin ? { skin: presenter.skin } : {},
2276
2288
  ...presenter.facial ? { facial: presenter.facial } : {},
2289
+ ...presenter.hair ? { hair: presenter.hair } : {},
2277
2290
  ...presenter.build ? { build: presenter.build } : {},
2278
2291
  shots
2279
2292
  };
@@ -4469,11 +4482,11 @@ function presenterRecordFrom(input, base) {
4469
4482
  if (source && PRESENTER_SOURCES.includes(source)) presenter.source = source;
4470
4483
  const likeness = has("likeness") ? likenessOf(input.likeness) : base?.likeness;
4471
4484
  if (likeness) presenter.likeness = likeness;
4472
- const facial = has("facial") ? str3(input.facial, 300) : base?.facial;
4485
+ const facial = has("facial") ? phrase(input.facial, 300) : base?.facial;
4473
4486
  if (facial) presenter.facial = facial;
4474
- const skin = has("skin") ? str3(input.skin, 200) : base?.skin;
4487
+ const skin = has("skin") ? phrase(input.skin, 200) : base?.skin;
4475
4488
  if (skin) presenter.skin = skin;
4476
- const build = has("build") ? str3(input.build, 200) : base?.build;
4489
+ const build = has("build") ? phrase(input.build, 200) : base?.build;
4477
4490
  if (build) presenter.build = build;
4478
4491
  const edits = has("identityEdits") ? identityEditsOf(input.identityEdits) : base?.identityEdits;
4479
4492
  if (edits?.length) presenter.identityEdits = edits;
@@ -4577,6 +4590,7 @@ var SEAT_TIER = {
4577
4590
  composition: 1,
4578
4591
  style: 2
4579
4592
  };
4593
+ var PRESENTER_FLOOR = 2;
4580
4594
  var ROLE_PRIORITY = {
4581
4595
  product: 0,
4582
4596
  character: 1,
@@ -4587,7 +4601,7 @@ var ROLE_PRIORITY = {
4587
4601
  composition: 6,
4588
4602
  style: 7
4589
4603
  };
4590
- function allocateAttachments(attachments, cap2) {
4604
+ function allocateAttachments(attachments, cap2, opts = {}) {
4591
4605
  const max = Math.max(0, cap2);
4592
4606
  const indexed = attachments.map((a, i) => ({ a, i }));
4593
4607
  const legacyOrder = [...indexed].sort(
@@ -4595,12 +4609,22 @@ function allocateAttachments(attachments, cap2) {
4595
4609
  );
4596
4610
  const groupOf = (a) => `${a.role}:${a.id ?? a.hash}`;
4597
4611
  const kept = /* @__PURE__ */ new Set();
4598
- const keptGroups = /* @__PURE__ */ new Set();
4612
+ const keptGroups = /* @__PURE__ */ new Map();
4599
4613
  const admit = (x) => {
4600
4614
  kept.add(x.i);
4601
- keptGroups.add(groupOf(x.a));
4615
+ keptGroups.set(groupOf(x.a), (keptGroups.get(groupOf(x.a)) ?? 0) + 1);
4602
4616
  };
4603
4617
  const seatOrder = [...indexed].sort((x, y) => SEAT_TIER[x.a.role] - SEAT_TIER[y.a.role] || x.i - y.i);
4618
+ if (opts.identityFloor) {
4619
+ for (const x of seatOrder) {
4620
+ if (kept.size >= max) break;
4621
+ if ((x.a.role === "product" || x.a.role === "character") && !keptGroups.has(groupOf(x.a))) admit(x);
4622
+ }
4623
+ for (const x of seatOrder) {
4624
+ if (kept.size >= max) break;
4625
+ if (x.a.role === "character" && !kept.has(x.i) && (keptGroups.get(groupOf(x.a)) ?? 0) < PRESENTER_FLOOR) admit(x);
4626
+ }
4627
+ }
4604
4628
  for (const x of seatOrder) {
4605
4629
  if (kept.size >= max) break;
4606
4630
  if (!kept.has(x.i) && !keptGroups.has(groupOf(x.a))) admit(x);
@@ -4866,7 +4890,7 @@ async function pickBuildEngine(engines, opts = {}) {
4866
4890
  // src/briefDirectives.ts
4867
4891
  function productFidelityDirective(attached) {
4868
4892
  if (attached <= 1) {
4869
- return "The attached product image is the exact product: preserve its label, shape, colors and proportions faithfully, and do not redesign it. Where the image shows it more than once, side by side from the front and the back or at several angles, those are views of one product: this shot contains exactly one of it. It is also the only view of this product that exists. Any face, side or detail not visible in it is unknown \u2014 keep those plain and consistent with the visible materials and color, and do not invent hardware, text, seams, closures, ornament or branding on them. Prefer a composition that shows the product from the view the reference gives, unless this shot's own direction asks for another view.";
4893
+ return "The attached product image is the exact product: preserve its label, shape, colors and proportions faithfully, and do not redesign it. Where the image shows it more than once, side by side from the front and the back or at several angles, those are views of one product: this shot contains exactly one of it. It is the only view of this product in this shot. Any face, side or detail not visible in it is unknown \u2014 keep those plain and consistent with the visible materials and color, and do not invent hardware, text, seams, closures, ornament or branding on them. Prefer a composition that shows the product from the view the reference gives, unless this shot's own direction asks for another view.";
4870
4894
  }
4871
4895
  return "The attached product images all show the exact product to feature: preserve its label, shape and proportions faithfully, do not redesign it, and never treat an extra image as an additional product. The first product image is the authority for its color, finish and material. Where another image differs in color or finish, it shows the same product in another colorway \u2014 never blend colorways, and render the one the first image shows. Any face not visible in them is unknown \u2014 keep it plain and consistent with the materials the first image shows, and do not invent detail on it. If the direction above explicitly asks for more than one colorway, that explicit request wins.";
4872
4896
  }
@@ -4909,7 +4933,12 @@ function productFactDirectives(p) {
4909
4933
  }
4910
4934
  function characterFactDirectives(c) {
4911
4935
  const out = [];
4912
- if (c.identityNotes) out.push(String(c.identityNotes));
4936
+ if (c.identityNotes) {
4937
+ const notes = String(c.identityNotes).trim().replace(/[\s,;:]+$/, "");
4938
+ out.push(
4939
+ `${c.promptName ?? c.name}'s own, in every shot and never capture context, as part of who they are: ${notes}${/[.!?]$/.test(notes) ? "" : "."}`
4940
+ );
4941
+ }
4913
4942
  if (c.negativeConstraints?.length) out.push(`Avoid: ${[].concat(c.negativeConstraints).join(", ")}`);
4914
4943
  return out;
4915
4944
  }
@@ -4920,7 +4949,25 @@ function characterEditIdentityDirective(name) {
4920
4949
  return `${name} is the person in this photograph: keep them present and clearly visible. Match their face, facial structure, skin, hair and build to the attached person reference exactly. The reference's plain outfit and studio backdrop are capture conditions, not direction: keep the styling this photograph already has unless the instruction itself changes it, and never return them to the plain base layers they were photographed in.`;
4921
4950
  }
4922
4951
  function referenceIdentityGuard() {
4923
- return "A reference shot lends its composition, lighting and treatment, never its cast: the attached presenter is the only source of person identity in this shot, and any person visible in a reference shot is a stand-in whose place the presenter takes. This holds even where the direction asks to use someone from a reference \u2014 the attached presenter is that someone.";
4952
+ return "A reference shot lends its composition, lighting and treatment, never its cast: the attached presenter is the only source of person identity in this shot, and any person visible in a reference shot is a stand-in whose place the presenter takes. The stand-in's clothes are not part of the pose: unless this shot's own words dress the presenter, dress them for this place in clothes of their own that show their own build, never in the stand-in's outfit. This holds even where the direction asks to use someone from a reference \u2014 the attached presenter is that someone.";
4953
+ }
4954
+ var HAIR_STYLED = /\b(hair|hairstyle|updo|bun|ponytail|braids?|braided|plaits?|fringe|bangs|slicked|cornrows)\b/i;
4955
+ function hairDirective(who, hair, userWords) {
4956
+ const h = hair.trim().replace(/[\s.,;:]+$/, "");
4957
+ return HAIR_STYLED.test(userWords) ? `${who}'s hair keeps its own colour, length and texture (${h}), styled the way this shot's words ask.` : `${who}'s hair, exactly as their references show it: ${h}.`;
4958
+ }
4959
+ function mendedTrait(text, cap2) {
4960
+ if (!text) return text;
4961
+ const t = text.trim();
4962
+ if (t.length < cap2 - 1 || /[.!?]$/.test(t)) return t;
4963
+ return wholeClauses(t);
4964
+ }
4965
+ function wholeClauses(t) {
4966
+ const sentence = t.lastIndexOf(". ");
4967
+ if (sentence >= t.length * 0.6) return t.slice(0, sentence + 1);
4968
+ const clause = Math.max(t.lastIndexOf(", "), t.lastIndexOf("; "));
4969
+ if (clause > t.length / 3) return `${t.slice(0, clause).replace(/[\s,;:]+$/, "")}.`;
4970
+ return sentence > 0 ? t.slice(0, sentence + 1) : t;
4924
4971
  }
4925
4972
  function markEditDirective() {
4926
4973
  return "The attached brand mark is this brand's own mark: wherever the logo appears or the instruction asks for it, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions \u2014 never redrawn or re-lettered. Every character it carries stays intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled.";
@@ -5269,15 +5316,20 @@ function compileBrief(brief, ctx) {
5269
5316
  "Their expression and gaze in the reference are neutral capture conditions too, not a look to reproduce: give them the genuine expression this exact moment and place call for, the effort, delight, focus, calm or ease a real person would show doing exactly this, and let their eyes go where the direction and the moment put them, never a copy of the reference's straight-to-lens look, while their face stays unmistakably theirs."
5270
5317
  );
5271
5318
  personDirectives.push(...characterFactDirectives(c));
5272
- if (c.skin)
5319
+ const skin = mendedTrait(c.skin, 200);
5320
+ const facial = mendedTrait(c.facial, 300);
5321
+ const build = mendedTrait(c.build, 200);
5322
+ const hair = mendedTrait(c.hair, 120);
5323
+ if (skin)
5273
5324
  personDirectives.push(
5274
- `${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
5325
+ `${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${skin.replace(/[.\s]+$/, "")}.`
5275
5326
  );
5276
- if (c.facial)
5327
+ if (facial)
5277
5328
  personDirectives.push(
5278
- `${c.promptName ?? c.name}'s face, which must survive every generation unchanged: ${c.facial}.`
5329
+ `${c.promptName ?? c.name}'s face, which must survive every generation unchanged: ${facial.replace(/[.\s]+$/, "")}.`
5279
5330
  );
5280
- if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
5331
+ if (hair) personDirectives.push(hairDirective(c.promptName ?? c.name, hair, userWords));
5332
+ if (build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${build.replace(/[.\s]+$/, "")}.`);
5281
5333
  } else {
5282
5334
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
5283
5335
  unattachable.push({
@@ -5442,7 +5494,13 @@ function compileBrief(brief, ctx) {
5442
5494
  }
5443
5495
  }
5444
5496
  const max = ctx.engineCaps.maxReferenceImages;
5445
- const { kept, dropped: budgetDropped, seated } = allocateAttachments(attachments, max);
5497
+ const {
5498
+ kept,
5499
+ dropped: budgetDropped,
5500
+ seated
5501
+ } = allocateAttachments(attachments, max, {
5502
+ identityFloor: ctx.mode !== "edit"
5503
+ });
5446
5504
  const presentKeys = new Set((ctx.presentAttachments ?? kept).map((a) => `${a.role}:${a.hash}`));
5447
5505
  const resolveDirective = (d) => {
5448
5506
  if (typeof d === "string") return d;
@@ -5461,7 +5519,7 @@ function compileBrief(brief, ctx) {
5461
5519
  if (presentKeys.has(key) || absentSeen.has(key)) continue;
5462
5520
  if (a.role === "reference") {
5463
5521
  absentSeen.add(key);
5464
- const words = ctx.wordsFor?.(a.hash) ?? null;
5522
+ const words = hasPerson ? null : ctx.wordsFor?.(a.hash) ?? null;
5465
5523
  absentDirectives.push(
5466
5524
  words ? `A reference shot was not attached this time; it showed ${words}. Match that composition, lighting and treatment.` : "A reference image was attached but not sent this time."
5467
5525
  );
@@ -11361,8 +11419,8 @@ function traitOf(sentence) {
11361
11419
  function mergeIdentityEdits(existing, adjustment) {
11362
11420
  const next = str4(adjustment, IDENTITY_EDIT_CHARS).replace(/\s+/g, " ");
11363
11421
  if (!next) return existing.slice(-IDENTITY_EDITS_MAX);
11364
- const trait = traitOf(next);
11365
- const kept = existing.filter((e) => e.toLowerCase() !== next.toLowerCase() && (!trait || traitOf(e) !== trait));
11422
+ const trait2 = traitOf(next);
11423
+ const kept = existing.filter((e) => e.toLowerCase() !== next.toLowerCase() && (!trait2 || traitOf(e) !== trait2));
11366
11424
  return [...kept, next].slice(-IDENTITY_EDITS_MAX);
11367
11425
  }
11368
11426
  async function filePhotos(deps, id, signal) {
@@ -13942,6 +14000,16 @@ function registerImageRoutes(app, deps) {
13942
14000
 
13943
14001
  // src/release/notes.data.ts
13944
14002
  var RELEASES = [
14003
+ {
14004
+ version: "0.17.1",
14005
+ date: "2026-09-24",
14006
+ sections: [
14007
+ {
14008
+ heading: "Presenters",
14009
+ body: "A presenter keeps their own face, hair, build, glasses and tattoos when a scene or a reference picture shows someone else, with every mark on its own side. A picture used for a pose no longer dresses them in that person's clothes, and their descriptions are no longer cut off mid-word."
14010
+ }
14011
+ ]
14012
+ },
13945
14013
  {
13946
14014
  version: "0.17.0",
13947
14015
  date: "2026-09-24",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",