scenri 0.6.1 → 0.6.3
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 +18 -0
- package/dist/serve.js +227 -37
- package/package.json +1 -1
- package/studio-dist/assets/index-DCNiIIP7.js +103 -0
- package/studio-dist/assets/{index-DdyktISx.css → index-DWWdEXLo.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-CYdC4kte.js +0 -103
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.3](https://github.com/tonygorb/Scenri/compare/v0.6.2...v0.6.3) (2026-08-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* brand mark carriage is consistent and honest end to end ([3ebd733](https://github.com/tonygorb/Scenri/commit/3ebd7337615a390215c3da297ecfb6f21074acdf))
|
|
9
|
+
* brand mark carriage is consistent and honest end to end ([bf1927e](https://github.com/tonygorb/Scenri/commit/bf1927e0ae95a86cf1666c4efb70306b7c4e2d6b))
|
|
10
|
+
* route-navigation surfaces are real links ([1c8c331](https://github.com/tonygorb/Scenri/commit/1c8c3311c7e7caa88bdbb2190f1bd37445d44444))
|
|
11
|
+
* route-navigation surfaces are real links ([816cf8c](https://github.com/tonygorb/Scenri/commit/816cf8c1485879c50fb4b7e9a85cf67aced152b1))
|
|
12
|
+
|
|
13
|
+
## [0.6.2](https://github.com/tonygorb/Scenri/compare/v0.6.1...v0.6.2) (2026-08-28)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* a custom scene keeps the figure and treatment its references show ([667cede](https://github.com/tonygorb/Scenri/commit/667cedeb4ebac53bf3d7c5cbcf0f8f80745c7431))
|
|
19
|
+
* a custom scene keeps the figure and treatment its references show ([b6532c9](https://github.com/tonygorb/Scenri/commit/b6532c9b47c404243a3ad0994bda4dd634e109e7))
|
|
20
|
+
|
|
3
21
|
## [0.6.1](https://github.com/tonygorb/Scenri/compare/v0.6.0...v0.6.1) (2026-08-26)
|
|
4
22
|
|
|
5
23
|
|
package/dist/serve.js
CHANGED
|
@@ -1286,7 +1286,13 @@ var REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1286
1286
|
product: "the exact product \u2014 preserve its label, shape, colors and design faithfully; do not redesign it",
|
|
1287
1287
|
character: "the exact person \u2014 match their face, facial structure, skin, hair and build exactly; their clothing, pose and background are capture context, not styling to reproduce",
|
|
1288
1288
|
brand: "the brand's own mark \u2014 if the direction calls for the mark to appear, reproduce it exactly as drawn, same colours, letterforms and proportions; otherwise take only its colour and treatment, and never its subject, geometry or composition",
|
|
1289
|
-
|
|
1289
|
+
// Only a figure-led scene attaches one of these now, so this says what that
|
|
1290
|
+
// case actually needs. It used to read "environment and light only - take no
|
|
1291
|
+
// subject or person from it", which handed the model a photograph of a face
|
|
1292
|
+
// treatment and told it to ignore the treatment. A hand-attached reference
|
|
1293
|
+
// says "match ... treatment" and works; this now says the same thing, with the
|
|
1294
|
+
// identity carve-out a scene needs and a lone reference does not.
|
|
1295
|
+
scene: "a reference for this world and for the treatment applied to the figure in it \u2014 match the environment, the light, and the material, density, scale, finish and spread of that treatment, including which parts of the form it covers and how far it reaches; take no identity from the person in it, and no product or brand",
|
|
1290
1296
|
composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
|
|
1291
1297
|
style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
|
|
1292
1298
|
reference: "a reference to match in composition, lighting and treatment"
|
|
@@ -2241,10 +2247,10 @@ function presenterBody(req, refCount) {
|
|
|
2241
2247
|
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.`;
|
|
2242
2248
|
}
|
|
2243
2249
|
function sceneBody(req) {
|
|
2244
|
-
const ask = req.instruction ? `
|
|
2250
|
+
const ask = req.instruction ? ` The person who chose these references says what matters in them: ${req.instruction}. Treat that as the deciding word: whatever it calls essential IS essential even if only one reference shows it, and whatever it tells you to ignore stays out even if every reference contains it.` : "";
|
|
2245
2251
|
const collections = req.vocabulary?.collections?.length ? ` Choose "collections" only from this list: ${req.vocabulary.collections.join(", ")}.` : "";
|
|
2246
2252
|
const verticals = req.vocabulary?.verticals?.length ? ` Choose "verticals" only from this list: ${req.vocabulary.verticals.join(", ")}.` : "";
|
|
2247
|
-
return `These are references for a place, not a picture to copy.${ask} Extract the reusable visual world behind them: environment, architecture, surfaces and materials, the character of the light, atmosphere, palette, depth, and the photographic language. Never name or describe a brand, a logo, a product, or a person.
|
|
2253
|
+
return `These are references for a place, not a picture to copy.${ask} Extract the reusable visual world behind them: environment, architecture, surfaces and materials, the character of the light, atmosphere, palette, depth, and the photographic language. Name materials rather than colours - travertine, cracked clay, waxed canvas, brushed steel, raw concrete - and say how the space layers from foreground through middle ground to background. Say so when a surface is reflective or transmissive: a mirror, a wet floor, chrome, glass, still water. Those govern how everything in the frame is lit, and they are the first thing lost to a generic description. Treat signage and lettering as typographic texture belonging to the environment: say that it is there and what it is made of, and never transcribe the words or name the brand. What you leave out is identity, not presence. Never name or describe a brand, a logo, a product model or a wordmark, and do not use any proper name anywhere in your answer. A person in a reference is recorded only as a figure: their scale in the frame, their distance, their posture, the kind of act the space is arranged around, and how a body catches this light. Never their face, hair, age, wardrobe, or anything that would identify them, and never as a particular person - "a figure at the water's edge", never "a woman in a red coat". A figure is not always something standing in a world. Sometimes the figure IS the world: a close portrait whose entire art direction is what has been done to the person - a face covered in stickers, painted skin, a veil, a mask, fabric wrapped over the head, a body reduced to a silhouette. When that is what the reference is, the treatment is the single most important thing to record, not a detail to drop along with the identity. Record what was done; never who it was done to. Only a living person counts as a figure. A mannequin, a statue, a face on a poster, a billboard or a screen, a reflection with nobody outside it, or a cast shadow is a thing in the environment: describe it in the set, not as a figure. Where several people appear, only the one the composition is built around is the figure; the rest are crowd, and belong in the set with everything else. A product, garment or mark staged in a reference is a visitor: leave the object itself out, but keep what it tells you about the place - the surface it sat on, the scale it implies, how densely the space is dressed. Where several references are attached, the world is what they share; whatever appears in only one of them is a visitor. Use no placeholders of any kind. ${OUT_FILE} must be a JSON object with exactly these keys: "name": two or three words a person would call this place, such as "Wet Basalt Shore"; "promptName": the same place named for a generator, at most six words; "lighting": a short phrase naming the light, such as "Low directional sunset, long shadows across wet stone"; "description": one sentence a person reads on a card; "subject": "product" if this world suits a staged object, "person" if it suits someone photographed in it, "either" when it truly suits both; "prompt": four or five sentences describing the set itself, in the present tense, naming nothing branded and nobody identifiable. Ambient human presence belongs here, written the way the rest of the set is written - a figure far off in the frame, worn seats, a table dressed for two; "figure": when this concept depends on a person being in it, one short phrase for the role they play - their framing, their scale, and what they are doing - such as "someone is seated at the stone ledge, mid-ground, at human scale" or "one person at close portrait range, squared to camera, filling the frame". A portrait counts. If the reference is built around a person and would stop being this concept without one, that is a figure, however much of the frame they occupy. Use an empty string only when the concept genuinely survives with nobody in it, or when the people present are passers-by rather than the point - describe those in "prompt" with the rest of the set instead. This is a different question from "subject": "subject" is who this world flatters, "figure" is whether the concept needs a body at all; "figureTreatment": what has been done TO that figure, when something has - one short phrase, such as "the face entirely covered in overlapping printed stickers" or "the head and shoulders wrapped in translucent fabric". Describe the treatment, what it is made of, and the character of any printing on it - the kind of label or product it imitates, its typographic style, illustration and colour - because that graphic character is usually the point. Say what kind of thing the printing is without naming a real company from the references. Be specific to what is actually in front of you rather than to the general idea: how much of the surface is covered and how much is left bare, the material and finish - glossy vinyl, matte paper, foil, fabric - the size of the pieces, and - separately from how many there are - how far they reach across the form and which parts they land on, since a treatment can be sparse and still cover the whole face. If the references disagree about how heavy it is, give the range rather than picking one. Use an empty string when nothing has been done to them. Leave it empty too when "figure" is empty; "camera": the camera tendency of this world in a short phrase - height, distance, lens feel, depth of field - or an empty string when it has none. Camera belongs here and never in "prompt"; "keywords": five to ten single words someone might search for; "coverage": an array of at most two short sentences naming what another reference would buy, such as "A wider frame would pin down how the room is laid out." Say so here if these references look like different places, or if they are mostly a person or a packshot with too little environment to build a world from. Use an empty array when they are good; "collections": one or two themed groupings;${collections} "verticals": the industries this world flatters.${verticals}`;
|
|
2248
2254
|
}
|
|
2249
2255
|
function parseDraft(req, raw) {
|
|
2250
2256
|
const text = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```$/, "").trim();
|
|
@@ -2298,6 +2304,8 @@ function parseScene(req, o) {
|
|
|
2298
2304
|
}
|
|
2299
2305
|
if (problems.length) return { ok: false, problems };
|
|
2300
2306
|
const camera = cap(str(o.camera), 200);
|
|
2307
|
+
const figure = oneLine(o.figure, 120);
|
|
2308
|
+
const figureTreatment = figure ? oneLine(o.figureTreatment, 160) : "";
|
|
2301
2309
|
return {
|
|
2302
2310
|
ok: true,
|
|
2303
2311
|
draft: {
|
|
@@ -2310,11 +2318,19 @@ function parseScene(req, o) {
|
|
|
2310
2318
|
verticals: pick(o.verticals, req.vocabulary?.verticals, 4),
|
|
2311
2319
|
keywords: list(o.keywords, 10, 40),
|
|
2312
2320
|
prompt: cap(prompt, 2e3),
|
|
2313
|
-
camera: camera || void 0
|
|
2321
|
+
camera: camera || void 0,
|
|
2322
|
+
figure: figure || void 0,
|
|
2323
|
+
figureTreatment: figureTreatment || void 0,
|
|
2324
|
+
coverage: list(o.coverage, 2, 160)
|
|
2314
2325
|
}
|
|
2315
2326
|
};
|
|
2316
2327
|
}
|
|
2317
2328
|
var str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2329
|
+
function oneLine(v, max) {
|
|
2330
|
+
const one = str(v).replace(/\s+/g, " ");
|
|
2331
|
+
if (!one || /\{[^}]*\}/.test(one)) return "";
|
|
2332
|
+
return cap(one, max);
|
|
2333
|
+
}
|
|
2318
2334
|
var cap = (v, max) => v.length > max ? v.slice(0, max).trim() : v;
|
|
2319
2335
|
function list(v, max, each) {
|
|
2320
2336
|
if (!Array.isArray(v)) return [];
|
|
@@ -3071,6 +3087,33 @@ function shotSpecifiesCamera(text) {
|
|
|
3071
3087
|
text
|
|
3072
3088
|
);
|
|
3073
3089
|
}
|
|
3090
|
+
function sceneFigureDirectives(opts) {
|
|
3091
|
+
const figure = opts.figure.trim().replace(/[.\s]+$/, "");
|
|
3092
|
+
if (!figure) return [];
|
|
3093
|
+
const treatment = (opts.treatment ?? "").trim().replace(/[.\s]+$/, "");
|
|
3094
|
+
const out = [];
|
|
3095
|
+
if (opts.hasPerson) {
|
|
3096
|
+
out.push(
|
|
3097
|
+
`This world is built around one figure: ${figure}. The attached presenter is that figure. Any person the scene direction describes IS the presenter and never a second person, and their identity comes from their own attached photograph alone, never from anything the scene direction says about a body.`
|
|
3098
|
+
);
|
|
3099
|
+
} else {
|
|
3100
|
+
out.push(
|
|
3101
|
+
`This world is built around one figure: ${figure}. Someone fills that role in the frame, and they are nobody in particular: an anonymous person invented for this photograph only, with no recognisable identity to preserve and nothing about them carried anywhere else. Show them unless the direction above asks for no people.`
|
|
3102
|
+
);
|
|
3103
|
+
}
|
|
3104
|
+
if (treatment) {
|
|
3105
|
+
const who = opts.hasPerson ? "The face and body underneath are still exactly theirs - same structure, same proportions, same build - and any earlier instruction that their features must survive unchanged is a rule about who they are, which this does not alter. " : "";
|
|
3106
|
+
out.push(
|
|
3107
|
+
`The art direction of this world is what has been done to that figure: ${treatment}. Render it as a real physical treatment, following the shape of the face and body it sits on rather than floating in the frame. Spread it across the whole form the way the reference does, reaching every part it covers there - brow, forehead, nose, both cheeks, jaw - instead of massing it in one area and leaving the rest untouched. Reaching wide is not the same as covering more: keep the number of pieces and the bare surface between them exactly as the description says, so a sparse treatment stays sparse while still touching every part of the form. Each piece sits on the plane beneath it, curving and catching light with the surface it is stuck to. ${who}The figure is bodily present and in shot: where the treatment covers or hides them, that is the photograph working as intended and never a reason to leave them out, crop them away, or reduce them to a shadow. If no person appears in this shot, the treatment does not go with them: it is what this world looks like, so it applies to whatever the frame does hold - the product, the surfaces, the set - as real pieces resting on those things. Applied on top, never redesigning them: the product keeps the exact form, colour, material and its own printed label that its reference shows, with the treatment sitting over it. Where the treatment carries printing, render it as genuinely designed print: real letterforms, readable words, numerals, illustration and colour, at the quality of commercial label artwork. Invent the companies - every name, logotype and piece of packaging artwork must be plausible but fictional, resembling no existing brand. That includes near-misses: do not borrow, extend or re-spell a name that appears in any attached reference, and use ordinary words for the produce itself rather than any company that sells it.` + // The fictional-brands rule and an attached brand mark are in direct
|
|
3108
|
+
// conflict without this: "resembling no existing brand" reads as an
|
|
3109
|
+
// instruction to mutate the one real mark the user deliberately
|
|
3110
|
+
// attached. Same shape as pairDirectives' packshot override - name the
|
|
3111
|
+
// scope of the earlier rule, carry the exception in the same breath.
|
|
3112
|
+
(opts.hasMark ? " The one exception is the attached brand mark: it is this brand's own real mark, deliberately attached, so the fictional-brands rule above does not apply to it. Where the direction asks for that mark to appear, it appears exactly as drawn - never redrawn, re-lettered or fictionalised." : "")
|
|
3113
|
+
);
|
|
3114
|
+
}
|
|
3115
|
+
return out;
|
|
3116
|
+
}
|
|
3074
3117
|
function sceneGuardDirectives(opts) {
|
|
3075
3118
|
const out = [];
|
|
3076
3119
|
if (opts.hasProduct) {
|
|
@@ -3113,6 +3156,7 @@ function markLabel(brand, logo) {
|
|
|
3113
3156
|
// src/brief.ts
|
|
3114
3157
|
var PRODUCT_REF_MAX = 3;
|
|
3115
3158
|
var CHARACTER_REF_MAX = 2;
|
|
3159
|
+
var SCENE_REF_MAX = 1;
|
|
3116
3160
|
var FORMATS = [
|
|
3117
3161
|
{ id: "square", label: "Square 1:1", w: 1024, h: 1024 },
|
|
3118
3162
|
{ id: "story", label: "Story 9:16", w: 1080, h: 1920 },
|
|
@@ -3289,6 +3333,14 @@ function compileBrief(brief, ctx) {
|
|
|
3289
3333
|
}
|
|
3290
3334
|
inlineTemplates.push(t);
|
|
3291
3335
|
append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
|
|
3336
|
+
if (ctx.mode !== "edit" && t.figure) {
|
|
3337
|
+
for (const r of (t.refs ?? []).slice(0, SCENE_REF_MAX)) {
|
|
3338
|
+
const h = assetHash(r?.file);
|
|
3339
|
+
if (h && ctx.images.has(h)) {
|
|
3340
|
+
attachments.push({ role: "scene", id: t.id, label: t.name, hash: h, essential: false });
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3292
3344
|
break;
|
|
3293
3345
|
}
|
|
3294
3346
|
case "format": {
|
|
@@ -3347,6 +3399,14 @@ function compileBrief(brief, ctx) {
|
|
|
3347
3399
|
// attached one is deliberate.
|
|
3348
3400
|
"Any earlier instruction that bans props, hands, people, or a presenter from the frame is a solo-packshot rule for this product and does not apply to this shot: the attached presenter is deliberate and must appear as directed."
|
|
3349
3401
|
] : [];
|
|
3402
|
+
const figureDirectives = scene?.figure ? sceneFigureDirectives({
|
|
3403
|
+
figure: scene.figure,
|
|
3404
|
+
treatment: scene.figureTreatment,
|
|
3405
|
+
hasPerson,
|
|
3406
|
+
// The treatment's fictional-brands rule needs to know a real mark is
|
|
3407
|
+
// deliberately in play; attachments are fully collected by this point.
|
|
3408
|
+
hasMark: attachments.some((a) => a.role === "brand")
|
|
3409
|
+
}) : [];
|
|
3350
3410
|
const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
|
|
3351
3411
|
"The tight framing includes the presenter: keep at least their hand in genuine contact with the product, and as much more of them as the crop allows. A close-up is never a reason to leave the person out of the photograph."
|
|
3352
3412
|
] : [];
|
|
@@ -3364,6 +3424,7 @@ function compileBrief(brief, ctx) {
|
|
|
3364
3424
|
...productDirectives,
|
|
3365
3425
|
...personDirectives,
|
|
3366
3426
|
...pairDirectives,
|
|
3427
|
+
...figureDirectives,
|
|
3367
3428
|
...closeUpDirectives,
|
|
3368
3429
|
...otherDirectives,
|
|
3369
3430
|
...cameraDirectives,
|
|
@@ -3375,12 +3436,11 @@ function compileBrief(brief, ctx) {
|
|
|
3375
3436
|
if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
|
|
3376
3437
|
const max = ctx.engineCaps.maxReferenceImages;
|
|
3377
3438
|
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
3378
|
-
if (dropped.
|
|
3379
|
-
const names = [...new Set(dropped.map((d) => d.label))];
|
|
3439
|
+
if (dropped.some((d) => d.role !== "scene")) {
|
|
3440
|
+
const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))];
|
|
3441
|
+
const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
|
|
3380
3442
|
warnings.push(
|
|
3381
|
-
`${ctx.engineCaps.displayName} reads ${
|
|
3382
|
-
" and "
|
|
3383
|
-
)} ${names.length === 1 ? "was" : "were"} left out.`
|
|
3443
|
+
`${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
|
|
3384
3444
|
);
|
|
3385
3445
|
}
|
|
3386
3446
|
return {
|
|
@@ -3888,6 +3948,20 @@ var brand_schema_default = {
|
|
|
3888
3948
|
type: "string",
|
|
3889
3949
|
description: "What the user asked for in their own words when this scene was built. Kept so a later refinement has the original intent."
|
|
3890
3950
|
},
|
|
3951
|
+
figure: {
|
|
3952
|
+
type: "string",
|
|
3953
|
+
not: {
|
|
3954
|
+
pattern: "\\{[^}]*\\}"
|
|
3955
|
+
},
|
|
3956
|
+
description: 'The figure this concept depends on, when it depends on one: "one person at close portrait range, squared to camera, filling the frame". A role, never a person - it names the part a body plays in this world, never whose body. Absent means the concept survives with nobody in it; passing human presence belongs in prompt with the rest of the set. A brief binds this to an attached presenter, so a world built around a body and a real presenter never compile as two people.'
|
|
3957
|
+
},
|
|
3958
|
+
figureTreatment: {
|
|
3959
|
+
type: "string",
|
|
3960
|
+
not: {
|
|
3961
|
+
pattern: "\\{[^}]*\\}"
|
|
3962
|
+
},
|
|
3963
|
+
description: "What has been applied to that figure, when something has: stickers over the face, paint, a veil, fabric, reduction to a silhouette. This is art direction, not identity - the scene owns the treatment and the presenter owns the face underneath it, and nothing here is ever written back to a presenter. Names the material and its form, never the words or artwork printed on it. Meaningless without figure."
|
|
3964
|
+
},
|
|
3891
3965
|
refs: {
|
|
3892
3966
|
type: "array",
|
|
3893
3967
|
items: {
|
|
@@ -3900,7 +3974,7 @@ var brand_schema_default = {
|
|
|
3900
3974
|
}
|
|
3901
3975
|
}
|
|
3902
3976
|
},
|
|
3903
|
-
description: "The user's inspiration images.
|
|
3977
|
+
description: "The user's inspiration images. Used to read this scene into words, to draw its preview card, and - only when the scene is built around a figure - one of them is attached to a shot as environment reference. That exception exists because a dense figure treatment cannot survive being described in words. Where it applies, the reference is marked as scene context: the environment and treatment are taken from it, the people, products and marks in it are not."
|
|
3904
3978
|
},
|
|
3905
3979
|
preview: {
|
|
3906
3980
|
$ref: "#/$defs/assetRef",
|
|
@@ -4052,7 +4126,12 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4052
4126
|
const palette = pickPalette(colorSources);
|
|
4053
4127
|
if (!palette.primary) warnings.push("No confident palette found. Set colors manually.");
|
|
4054
4128
|
let logoRef;
|
|
4055
|
-
|
|
4129
|
+
let logoFromOg = false;
|
|
4130
|
+
let iconHref = $('link[rel="apple-touch-icon"]').attr("href") || $('link[rel~="icon"]').attr("href");
|
|
4131
|
+
if (!iconHref) {
|
|
4132
|
+
iconHref = $('meta[property="og:image"]').attr("content");
|
|
4133
|
+
logoFromOg = Boolean(iconHref);
|
|
4134
|
+
}
|
|
4056
4135
|
if (iconHref && opts.saveAsset) {
|
|
4057
4136
|
try {
|
|
4058
4137
|
const iconRes = await fetchImpl(new URL(iconHref, origin).toString(), { redirect: "follow" });
|
|
@@ -4065,6 +4144,10 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4065
4144
|
}
|
|
4066
4145
|
}
|
|
4067
4146
|
if (!logoRef) warnings.push("No logo captured. Add one manually.");
|
|
4147
|
+
else if (logoFromOg)
|
|
4148
|
+
warnings.push(
|
|
4149
|
+
"No site icon was found; the social share image was saved as an alternate mark. Check it before treating it as the logo."
|
|
4150
|
+
);
|
|
4068
4151
|
const brand = {
|
|
4069
4152
|
specVersion: "0.1",
|
|
4070
4153
|
meta: {
|
|
@@ -4083,7 +4166,7 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4083
4166
|
...palette.neutrals.length ? { neutrals: palette.neutrals.map((hex) => ({ hex })) } : {}
|
|
4084
4167
|
}
|
|
4085
4168
|
} : {},
|
|
4086
|
-
...logoRef ? { logos: [{ role: "primary", file: logoRef }] } : {}
|
|
4169
|
+
...logoRef ? { logos: [{ role: logoFromOg ? "alternate" : "primary", file: logoRef }] } : {}
|
|
4087
4170
|
};
|
|
4088
4171
|
return { brand, warnings };
|
|
4089
4172
|
}
|
|
@@ -4117,7 +4200,16 @@ function mergeScrape(existing, scraped) {
|
|
|
4117
4200
|
if (scrapedLogos.length) {
|
|
4118
4201
|
const have = new Set(arr(cur.logos).map((l) => hashOf(l?.file)));
|
|
4119
4202
|
const added = scrapedLogos.filter((l) => !have.has(hashOf(l?.file)));
|
|
4120
|
-
if (added.length)
|
|
4203
|
+
if (added.length) {
|
|
4204
|
+
let hasPrimary = arr(cur.logos).some((l) => l?.role === "primary");
|
|
4205
|
+
const demoted = added.map((l) => {
|
|
4206
|
+
if (l?.role !== "primary") return l;
|
|
4207
|
+
if (hasPrimary) return { ...l, role: "alternate" };
|
|
4208
|
+
hasPrimary = true;
|
|
4209
|
+
return l;
|
|
4210
|
+
});
|
|
4211
|
+
brand.logos = [...arr(cur.logos), ...demoted];
|
|
4212
|
+
}
|
|
4121
4213
|
}
|
|
4122
4214
|
return { brand, suggestions: { palette: suggestions } };
|
|
4123
4215
|
}
|
|
@@ -5484,8 +5576,13 @@ function sceneRecordFrom(input, base) {
|
|
|
5484
5576
|
if (camera) scene.camera = camera;
|
|
5485
5577
|
const keywords = has("keywords") ? strList(input.keywords, 12, 40) : base?.keywords;
|
|
5486
5578
|
if (keywords?.length) scene.keywords = keywords;
|
|
5487
|
-
const
|
|
5579
|
+
const written = has("instruction") ? str3(input.instruction, 400) : "";
|
|
5580
|
+
const instruction = written || base?.instruction;
|
|
5488
5581
|
if (instruction) scene.instruction = instruction;
|
|
5582
|
+
const figure = has("figure") ? str3(input.figure, 120).replace(/\s+/g, " ") : base?.figure;
|
|
5583
|
+
if (figure && !/\{[^}]*\}/.test(figure)) scene.figure = figure;
|
|
5584
|
+
const treatment = has("figureTreatment") ? str3(input.figureTreatment, 160).replace(/\s+/g, " ") : base?.figureTreatment;
|
|
5585
|
+
if (scene.figure && treatment && !/\{[^}]*\}/.test(treatment)) scene.figureTreatment = treatment;
|
|
5489
5586
|
if (refs?.length) scene.refs = refs;
|
|
5490
5587
|
if (previewRef) scene.preview = previewRef;
|
|
5491
5588
|
return { ok: true, scene };
|
|
@@ -5527,7 +5624,7 @@ function presenterRecordFrom(input, base) {
|
|
|
5527
5624
|
return { ok: true, presenter };
|
|
5528
5625
|
}
|
|
5529
5626
|
function lintSceneProse(brandJson, scene) {
|
|
5530
|
-
const haystack = `${scene.prompt} ${scene.description}`.toLowerCase();
|
|
5627
|
+
const haystack = `${scene.prompt} ${scene.description} ${scene.figure ?? ""} ${scene.figureTreatment ?? ""}`.toLowerCase();
|
|
5531
5628
|
const named = /* @__PURE__ */ new Set();
|
|
5532
5629
|
const consider = (raw) => {
|
|
5533
5630
|
const n = String(raw ?? "").trim();
|
|
@@ -5576,7 +5673,10 @@ function startAssetBuild(deps, input) {
|
|
|
5576
5673
|
const { core } = deps;
|
|
5577
5674
|
const brand = core.store.getBrand(input.brandId);
|
|
5578
5675
|
if (!brand) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
|
|
5579
|
-
const
|
|
5676
|
+
const prior = input.sceneId ? brandSceneById(brand.json, input.sceneId) : void 0;
|
|
5677
|
+
if (input.sceneId && !prior) throw Object.assign(new Error("scene not found"), { statusCode: 404 });
|
|
5678
|
+
const supplied = input.imageHashes.length ? input.imageHashes : (prior?.refs ?? []).map((r) => String(r?.file ?? "").replace(/^asset:/, ""));
|
|
5679
|
+
const hashes = supplied.filter((h) => /^[a-f0-9]{32}$/.test(h) && core.images.has(h));
|
|
5580
5680
|
if (input.kind === "presenter" && !hashes.length) {
|
|
5581
5681
|
throw Object.assign(new Error("add at least one photo of this person"), { statusCode: 400 });
|
|
5582
5682
|
}
|
|
@@ -5597,6 +5697,7 @@ function startAssetBuild(deps, input) {
|
|
|
5597
5697
|
warnings: [],
|
|
5598
5698
|
coverage: [],
|
|
5599
5699
|
facets: strList(input.facets, 8, 40),
|
|
5700
|
+
sceneId: input.sceneId ?? null,
|
|
5600
5701
|
error: null,
|
|
5601
5702
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5602
5703
|
finished: false
|
|
@@ -5906,15 +6007,17 @@ async function crop(core, hash, region) {
|
|
|
5906
6007
|
}
|
|
5907
6008
|
async function runSceneBuild(deps, job, hashes, instruction, signal) {
|
|
5908
6009
|
const { core } = deps;
|
|
6010
|
+
const prior = job.sceneId ? brandSceneById(core.store.getBrand(job.brandId)?.json ?? {}, job.sceneId) : void 0;
|
|
5909
6011
|
let draft = null;
|
|
5910
6012
|
if (deps.analyzer) {
|
|
5911
|
-
patch(job, { stage: "analyzing", message: "Reading the references" });
|
|
6013
|
+
patch(job, { stage: "analyzing", message: prior ? "Reading the references again" : "Reading the references" });
|
|
5912
6014
|
draft = await deps.analyzer.analyze(
|
|
5913
6015
|
{
|
|
5914
6016
|
kind: "scene",
|
|
5915
6017
|
imagePaths: hashes.map((h) => core.images.pathFor(h)),
|
|
5916
6018
|
name: job.name,
|
|
5917
6019
|
instruction: instruction || void 0,
|
|
6020
|
+
...prior ? { priorDraft: prior, correction: instruction || void 0 } : {},
|
|
5918
6021
|
vocabulary: deps.vocabulary
|
|
5919
6022
|
},
|
|
5920
6023
|
signal
|
|
@@ -5923,29 +6026,41 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
|
|
|
5923
6026
|
throw new Error("describe the place in a sentence, or install the Codex CLI to read the references");
|
|
5924
6027
|
}
|
|
5925
6028
|
if (signal.aborted) throw new Error("cancelled");
|
|
5926
|
-
const built = sceneRecordFrom(
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
6029
|
+
const built = sceneRecordFrom(
|
|
6030
|
+
{
|
|
6031
|
+
name: job.name || draft?.name || "New scene",
|
|
6032
|
+
promptName: draft?.promptName,
|
|
6033
|
+
lighting: draft?.lighting,
|
|
6034
|
+
description: draft?.description ?? instruction,
|
|
6035
|
+
subject: draft?.subject ?? "either",
|
|
6036
|
+
prompt: draft?.prompt ?? instruction,
|
|
6037
|
+
camera: draft?.camera,
|
|
6038
|
+
figure: draft?.figure,
|
|
6039
|
+
figureTreatment: draft?.figureTreatment,
|
|
6040
|
+
collections: draft?.collections,
|
|
6041
|
+
verticals: job.facets.length ? job.facets : draft?.verticals,
|
|
6042
|
+
keywords: draft?.keywords,
|
|
6043
|
+
instruction,
|
|
6044
|
+
refHashes: hashes
|
|
6045
|
+
},
|
|
6046
|
+
prior
|
|
6047
|
+
);
|
|
5940
6048
|
if (!built.ok) throw new Error(built.error);
|
|
5941
6049
|
const scene = built.scene;
|
|
6050
|
+
if (draft?.coverage?.length) patch(job, { coverage: draft.coverage });
|
|
5942
6051
|
let previewHash = null;
|
|
5943
6052
|
if (deps.engine) {
|
|
5944
6053
|
patch(job, { stage: "building", steps: 1, message: "Drawing the place" });
|
|
5945
6054
|
try {
|
|
6055
|
+
const previewRefs = hashes.slice(0, deps.engine.capabilities().maxReferenceImages).map((h) => core.images.pathFor(h));
|
|
5946
6056
|
previewHash = await trimEdgeBars(
|
|
5947
6057
|
core,
|
|
5948
|
-
await draw(deps, {
|
|
6058
|
+
await draw(deps, {
|
|
6059
|
+
prompt: scenePreviewPrompt(scene),
|
|
6060
|
+
brandId: job.brandId,
|
|
6061
|
+
...previewRefs.length ? { referenceImages: previewRefs, referenceRoles: previewRefs.map(() => "scene") } : {},
|
|
6062
|
+
signal
|
|
6063
|
+
})
|
|
5949
6064
|
);
|
|
5950
6065
|
scene.preview = `asset:${previewHash}`;
|
|
5951
6066
|
patch(job, { step: 1, previewHash });
|
|
@@ -5959,7 +6074,9 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
|
|
|
5959
6074
|
const brand = core.store.getBrand(job.brandId);
|
|
5960
6075
|
const warnings = [...job.warnings, ...lintSceneProse(brand?.json ?? {}, scene)];
|
|
5961
6076
|
commit(core, job.brandId, (json) => {
|
|
5962
|
-
|
|
6077
|
+
const rows = brandScenes(json);
|
|
6078
|
+
const at = rows.findIndex((s) => s?.id === scene.id);
|
|
6079
|
+
json.scenes = at >= 0 ? rows.map((s, i) => i === at ? scene : s) : [...rows, scene];
|
|
5963
6080
|
});
|
|
5964
6081
|
patch(job, {
|
|
5965
6082
|
stage: "done",
|
|
@@ -5971,7 +6088,8 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
|
|
|
5971
6088
|
});
|
|
5972
6089
|
}
|
|
5973
6090
|
function scenePreviewPrompt(scene) {
|
|
5974
|
-
|
|
6091
|
+
const body = scene.figure ? `A figure is in this photograph: ${scene.figure.replace(/[.\s]+$/, "")}. ` + (scene.figureTreatment ? `The art direction is what has been done to them: ${scene.figureTreatment.replace(/[.\s]+$/, "")}, rendered as a real physical treatment that follows the shape it sits on. ` : "") + "They are nobody in particular: do not reproduce any person from the attached reference images, and give them no recognisable identity. No product, no logos, no watermarks, and no readable words anywhere in the frame." : "The set is empty: no product, no person, no hands, no text, no logos, no watermarks anywhere in the frame.";
|
|
6092
|
+
return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind. ${scene.prompt} ${scene.lighting ? `${scene.lighting}. ` : ""}` + body;
|
|
5975
6093
|
}
|
|
5976
6094
|
async function draw(deps, req) {
|
|
5977
6095
|
const engine = deps.engine;
|
|
@@ -5997,6 +6115,12 @@ async function draw(deps, req) {
|
|
|
5997
6115
|
function runningAssetBuildCount() {
|
|
5998
6116
|
return running2.size;
|
|
5999
6117
|
}
|
|
6118
|
+
function sceneBuildRunning(brandId, sceneId) {
|
|
6119
|
+
for (const b of builds.values()) {
|
|
6120
|
+
if (b.brandId === brandId && b.sceneId === sceneId && !b.finished) return true;
|
|
6121
|
+
}
|
|
6122
|
+
return false;
|
|
6123
|
+
}
|
|
6000
6124
|
var LOOPBACK = ["localhost", "127.0.0.1", "::1"];
|
|
6001
6125
|
var ACCESS_COOKIE = "sc_access";
|
|
6002
6126
|
var LEGACY_ACCESS_COOKIE = "bt_access";
|
|
@@ -6888,7 +7012,7 @@ function registerLogoRoutes(app, deps) {
|
|
|
6888
7012
|
const entry = { role, file: `asset:${part.hash}`, background };
|
|
6889
7013
|
if (existing !== -1) logos[existing] = entry;
|
|
6890
7014
|
else logos.push(entry);
|
|
6891
|
-
json.logos = logos;
|
|
7015
|
+
json.logos = role === "primary" ? logos.map((l) => l !== entry && l?.role === "primary" ? { ...l, role: "alternate" } : l) : logos;
|
|
6892
7016
|
const v = validateBrand(json);
|
|
6893
7017
|
if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
|
|
6894
7018
|
return core.store.updateBrand(brand.id, json);
|
|
@@ -6916,7 +7040,7 @@ function registerLogoRoutes(app, deps) {
|
|
|
6916
7040
|
if (cs) patch2.clearSpace = cs;
|
|
6917
7041
|
}
|
|
6918
7042
|
json.logos = readLogos(json).map((l, i) => {
|
|
6919
|
-
if (i !== idx) return l;
|
|
7043
|
+
if (i !== idx) return patch2.role === "primary" && l?.role === "primary" ? { ...l, role: "alternate" } : l;
|
|
6920
7044
|
const next = { ...l, ...patch2 };
|
|
6921
7045
|
if ("clearSpace" in body && !patch2.clearSpace) delete next.clearSpace;
|
|
6922
7046
|
return next;
|
|
@@ -7372,6 +7496,30 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
7372
7496
|
});
|
|
7373
7497
|
return { ok: true };
|
|
7374
7498
|
});
|
|
7499
|
+
app.post("/api/brands/:id/scenes/:sceneId/reread", async (req, reply) => {
|
|
7500
|
+
const brand = brandOr404(req, reply);
|
|
7501
|
+
if (!brand) return;
|
|
7502
|
+
const id = String(req.params.sceneId);
|
|
7503
|
+
const scene = brandScenes(brand.json).find((s) => s.id === id);
|
|
7504
|
+
if (!scene) return reply.status(404).send({ error: "scene not found" });
|
|
7505
|
+
if (!(scene.refs ?? []).length)
|
|
7506
|
+
return reply.status(400).send({ error: "this scene was written from words, so there is nothing to read again" });
|
|
7507
|
+
if (sceneBuildRunning(brand.id, id))
|
|
7508
|
+
return reply.status(409).send({ error: "this scene is already being read again" });
|
|
7509
|
+
const body = req.body ?? {};
|
|
7510
|
+
try {
|
|
7511
|
+
return startAssetBuild(await buildDeps(), {
|
|
7512
|
+
brandId: brand.id,
|
|
7513
|
+
kind: "scene",
|
|
7514
|
+
sceneId: id,
|
|
7515
|
+
name: scene.name,
|
|
7516
|
+
instruction: body.correction == null ? void 0 : String(body.correction),
|
|
7517
|
+
imageHashes: []
|
|
7518
|
+
});
|
|
7519
|
+
} catch (err) {
|
|
7520
|
+
return reply.status(err.statusCode ?? 500).send({ error: err.message ?? "could not start" });
|
|
7521
|
+
}
|
|
7522
|
+
});
|
|
7375
7523
|
app.post("/api/brands/:id/scenes/:sceneId/preview", async (req, reply) => {
|
|
7376
7524
|
const brand = brandOr404(req, reply);
|
|
7377
7525
|
if (!brand) return;
|
|
@@ -7380,9 +7528,11 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
7380
7528
|
if (!scene) return reply.status(404).send({ error: "scene not found" });
|
|
7381
7529
|
const engine = await buildEngine();
|
|
7382
7530
|
if (!engine) return reply.status(400).send({ error: "no engine here can draw a preview" });
|
|
7531
|
+
const refs = (scene.refs ?? []).map((r) => String(r?.file ?? "").replace(/^asset:/, "")).filter((h) => /^[a-f0-9]{32}$/.test(h) && core.images.has(h)).slice(0, engine.capabilities().maxReferenceImages).map((h) => core.images.pathFor(h));
|
|
7383
7532
|
const request = {
|
|
7384
7533
|
prompt: scenePreviewPrompt(scene),
|
|
7385
7534
|
brand: brandContext(core, brand.id),
|
|
7535
|
+
...refs.length ? { referenceImages: refs, referenceRoles: refs.map(() => "scene") } : {},
|
|
7386
7536
|
width: scene.width,
|
|
7387
7537
|
height: scene.height,
|
|
7388
7538
|
count: 1
|
|
@@ -7823,6 +7973,44 @@ function registerImageRoutes(app, deps) {
|
|
|
7823
7973
|
|
|
7824
7974
|
// src/release/notes.data.ts
|
|
7825
7975
|
var RELEASES = [
|
|
7976
|
+
{
|
|
7977
|
+
version: "0.6.3",
|
|
7978
|
+
date: "2026-08-28",
|
|
7979
|
+
title: "Your logo stays your logo, and every page opens like a page.",
|
|
7980
|
+
sections: [
|
|
7981
|
+
{
|
|
7982
|
+
heading: "Brand",
|
|
7983
|
+
body: "A brand mark attached to a shot is carried faithfully everywhere the shot goes: a refinement keeps it, Reuse setup rebuilds the brief with it, and when the logo has since left the kit, Scenri says so instead of quietly dropping it. The kit holds one primary logo at a time, every screen agrees which one it is, and the composer says before sending when an engine cannot carry the mark as an image."
|
|
7984
|
+
},
|
|
7985
|
+
{
|
|
7986
|
+
heading: "Fixes",
|
|
7987
|
+
body: "Anything that opens a page is a real link now. Middle click, Cmd or Ctrl click and copy link work on the navigation, cards, shots, crumbs and notifications, each opening in its own tab, while a plain click stays as fast as before."
|
|
7988
|
+
}
|
|
7989
|
+
]
|
|
7990
|
+
},
|
|
7991
|
+
{
|
|
7992
|
+
version: "0.6.2",
|
|
7993
|
+
date: "2026-08-28",
|
|
7994
|
+
title: "Scenes keep what makes them distinctive.",
|
|
7995
|
+
sections: [
|
|
7996
|
+
{
|
|
7997
|
+
heading: "Scenes",
|
|
7998
|
+
body: "A scene built from references that are about a person now keeps that person as part of its art direction: where they sit in the composition, and what has been done to them, whether that is a face covered in stickers or a figure behind fabric. Before, a reference like that was read as an empty room and the idea was lost."
|
|
7999
|
+
},
|
|
8000
|
+
{
|
|
8001
|
+
heading: "Create",
|
|
8002
|
+
body: "Attach a presenter to a scene like that and they take the role, wearing their own face under the treatment. Attach nobody and the person stays anonymous. Ask for no people and the treatment stays on whatever is left in the frame."
|
|
8003
|
+
},
|
|
8004
|
+
{
|
|
8005
|
+
heading: "Building a scene",
|
|
8006
|
+
body: "The notes field is now Direction, and it decides how your references are read. Say what matters in them and what to ignore, and it carries more weight than the pictures alone. You can edit it later from the scene page."
|
|
8007
|
+
},
|
|
8008
|
+
{
|
|
8009
|
+
heading: "Fixes",
|
|
8010
|
+
body: "Reading a scene's references again no longer clears the direction you wrote. Scenes you build yourself now appear on the home shelf beside the built-in ones, and a scene that finishes while you are looking at the library no longer stays hidden until you reload."
|
|
8011
|
+
}
|
|
8012
|
+
]
|
|
8013
|
+
},
|
|
7826
8014
|
{
|
|
7827
8015
|
version: "0.6.1",
|
|
7828
8016
|
date: "2026-08-26",
|
|
@@ -8677,16 +8865,18 @@ function buildServer(opts) {
|
|
|
8677
8865
|
...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
|
|
8678
8866
|
});
|
|
8679
8867
|
let inheritedAttachments = [];
|
|
8868
|
+
let identityWarnings = [];
|
|
8680
8869
|
if (inheritedTokens.length) {
|
|
8681
8870
|
const identity = compileBrief(
|
|
8682
8871
|
{ tokens: inheritedTokens },
|
|
8683
8872
|
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
|
|
8684
8873
|
);
|
|
8874
|
+
identityWarnings = identity.warnings;
|
|
8685
8875
|
inheritedAttachments = identity.attachments.filter((a) => a.essential || a.role === "brand" || a.role === "reference").map((a) => ({ ...a, inherited: true }));
|
|
8686
8876
|
}
|
|
8687
8877
|
const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
|
|
8688
8878
|
const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
|
|
8689
|
-
const warnings = [...compiled2.warnings];
|
|
8879
|
+
const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
|
|
8690
8880
|
if (merged.dropped.length) {
|
|
8691
8881
|
if (engineCaps.maxReferenceImages <= 1) {
|
|
8692
8882
|
warnings.push(
|