scenri 0.6.13 → 0.7.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 +20 -0
- package/dist/serve.js +372 -110
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0](https://github.com/tonygorb/Scenri/compare/v0.6.13...v0.7.0) (2026-08-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* a pure-grade refinement keeps the photograph's own pixels ([7daf442](https://github.com/tonygorb/Scenri/commit/7daf442a7949a3dec0acbcb510ed9100863937e0))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **brief:** a shed corroboration angle no longer reads as a lost identity ([d4830f7](https://github.com/tonygorb/Scenri/commit/d4830f7940f8cc596f013000e4159da4459d6a60))
|
|
14
|
+
* **brief:** identity has one source - the carve-out leads the scene directive, and the presenter outranks a reference ([9c97fad](https://github.com/tonygorb/Scenri/commit/9c97fada04934e27f54e439d78511f12d7d775d0))
|
|
15
|
+
* **brief:** the drawn plate conditions a figure-led scene, and the raw upload never rides beside a presenter ([c0766da](https://github.com/tonygorb/Scenri/commit/c0766da479a36677bc4ad66762b55d1daa4a8eb1))
|
|
16
|
+
* carried mood references stop claiming to be the subject on refines ([a0d3c40](https://github.com/tonygorb/Scenri/commit/a0d3c408156ae2da9579e97a220848c5591fd68d))
|
|
17
|
+
* **openrouter:** repeat the identity carve-out after the image list ([dc2cf93](https://github.com/tonygorb/Scenri/commit/dc2cf93f3fa36af76a67f4a85c0f9676a1e4bf4c))
|
|
18
|
+
* refines on pixel-budget engines step down honestly instead of upscaling ([1fa419d](https://github.com/tonygorb/Scenri/commit/1fa419d191fa5201b2a78fe29fa6f4123955411c))
|
|
19
|
+
* **scenes:** a treatment plate may carry fictional print, and a redraw trims its bars ([8f5c0a2](https://github.com/tonygorb/Scenri/commit/8f5c0a2bc77433b8a46dca6f985768fd7e13d940))
|
|
20
|
+
* **server:** a formatless edit brief never reshapes ([253804f](https://github.com/tonygorb/Scenri/commit/253804ffedc6164c5dbc46e5f19e942b424a6ad9))
|
|
21
|
+
* the edit identity compile declares edit mode at the scene seam ([40cb845](https://github.com/tonygorb/Scenri/commit/40cb84596ca77b74f1626c3cfeed68cf1b8f44d2))
|
|
22
|
+
|
|
3
23
|
## [0.6.13](https://github.com/tonygorb/Scenri/compare/v0.6.12...v0.6.13) (2026-08-30)
|
|
4
24
|
|
|
5
25
|
|
package/dist/serve.js
CHANGED
|
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
|
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
|
-
import
|
|
13
|
+
import sharp20 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
15
15
|
import fastifyStatic from '@fastify/static';
|
|
16
16
|
import fastifyMultipart from '@fastify/multipart';
|
|
@@ -1295,7 +1295,13 @@ var REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1295
1295
|
// names staged objects as stand-ins rather than just "no product": a bare
|
|
1296
1296
|
// prohibition still left the demo object in the frame, because the model had
|
|
1297
1297
|
// nowhere to put what the photograph so vividly showed.
|
|
1298
|
-
|
|
1298
|
+
//
|
|
1299
|
+
// The carve-out leads. It used to sit forty words in, one subordinate
|
|
1300
|
+
// clause after a paragraph of "match this" - and the tester case (a close
|
|
1301
|
+
// portrait as the scene image beside a selected presenter) showed which
|
|
1302
|
+
// half the model heard. Every treatment clause is retained word for word;
|
|
1303
|
+
// only the order and the register of the identity refusal changed.
|
|
1304
|
+
scene: "a reference for this world, never for a person: take no identity from the person in it \u2014 not their face, not their likeness \u2014 they are an anonymous stand-in whose place the attached subject takes. Match the environment, the light, and the material, density, scale, finish and spread of the treatment applied to the figure, including which parts of the form it covers and how far it reaches; treat any product, garment or prop staged in it as the same kind of stand-in, demonstrating placement and scale \u2014 never an object to reproduce",
|
|
1299
1305
|
composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
|
|
1300
1306
|
style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
|
|
1301
1307
|
reference: "a reference to match in composition, lighting and treatment"
|
|
@@ -1304,7 +1310,7 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1304
1310
|
product: "the exact product: keep or restore its label, shape and design faithfully",
|
|
1305
1311
|
character: "the exact person: keep their face, facial structure, skin, hair and build faithfully; take no clothing, pose or background from this reference, and keep the source image's existing outfit unless the instruction changes it",
|
|
1306
1312
|
brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears \u2014 every character down to the smallest secondary lettering, in its original script and reading direction \u2014 never redrawn, re-lettered, translated or transliterated",
|
|
1307
|
-
scene: "a reference for environment and
|
|
1313
|
+
scene: "a reference for environment, light and treatment only \u2014 take no identity from any person in it",
|
|
1308
1314
|
composition: "a reference for framing and pose only",
|
|
1309
1315
|
style: "a reference for treatment and mood only",
|
|
1310
1316
|
reference: "a reference for composition, lighting and treatment only"
|
|
@@ -1332,6 +1338,14 @@ function ratioLabel(width, height) {
|
|
|
1332
1338
|
const d = gcd(width, height) || 1;
|
|
1333
1339
|
return `${Math.round(width / d)}:${Math.round(height / d)}`;
|
|
1334
1340
|
}
|
|
1341
|
+
function budgetSize(width, height, pixelBudget) {
|
|
1342
|
+
const ratio = width / height;
|
|
1343
|
+
if (!(ratio > 0) || !Number.isFinite(ratio) || !(pixelBudget > 0)) return { width, height };
|
|
1344
|
+
return {
|
|
1345
|
+
width: Math.round(Math.sqrt(pixelBudget * ratio)),
|
|
1346
|
+
height: Math.round(Math.sqrt(pixelBudget / ratio))
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1335
1349
|
|
|
1336
1350
|
// ../core/src/index.ts
|
|
1337
1351
|
function defaultHome() {
|
|
@@ -1458,12 +1472,14 @@ function createOpenRouterEngine(opts) {
|
|
|
1458
1472
|
const role = roles[i] ?? "reference";
|
|
1459
1473
|
return refs.length > 1 ? `Attached image ${i + 1} is ${roleDirective[role]}.` : `The attached image is ${roleDirective[role]}.`;
|
|
1460
1474
|
}).join(" ");
|
|
1475
|
+
const identityCoda = roles.includes("character") && roles.some((r) => r === "scene" || r === "reference") ? "Identity check: the person in this image comes only from the character reference image(s); the scene and reference images lend world, composition, lighting and treatment \u2014 take no face or likeness from them." : null;
|
|
1461
1476
|
const content = [
|
|
1462
1477
|
{ type: "text", text: refDirectives ? `${req.prompt} ${refDirectives}` : req.prompt },
|
|
1463
1478
|
...refs.map((p) => ({
|
|
1464
1479
|
type: "image_url",
|
|
1465
1480
|
image_url: { url: dataUrl(p) }
|
|
1466
|
-
}))
|
|
1481
|
+
})),
|
|
1482
|
+
...identityCoda ? [{ type: "text", text: identityCoda }] : []
|
|
1467
1483
|
];
|
|
1468
1484
|
const body = {
|
|
1469
1485
|
model: opts.model ?? DEFAULT_MODEL,
|
|
@@ -2519,12 +2535,7 @@ function orientationOf(width, height) {
|
|
|
2519
2535
|
}
|
|
2520
2536
|
var CODEX_PIXEL_BUDGET = 1572864;
|
|
2521
2537
|
function codexNativeSize(width, height) {
|
|
2522
|
-
|
|
2523
|
-
if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height };
|
|
2524
|
-
return {
|
|
2525
|
-
width: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET * ratio)),
|
|
2526
|
-
height: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET / ratio))
|
|
2527
|
-
};
|
|
2538
|
+
return budgetSize(width, height, CODEX_PIXEL_BUDGET);
|
|
2528
2539
|
}
|
|
2529
2540
|
function refFileNames(roles, count) {
|
|
2530
2541
|
const perRole = /* @__PURE__ */ new Map();
|
|
@@ -2597,6 +2608,10 @@ function createCodexEngine(opts) {
|
|
|
2597
2608
|
// OSS-local only: the user's own session, on the user's own machine
|
|
2598
2609
|
supportsEdit: true,
|
|
2599
2610
|
supportsMask: false,
|
|
2611
|
+
// The image tool draws at this fixed pixel count (measured, the
|
|
2612
|
+
// native-size probe): an edit of a larger source steps down honestly
|
|
2613
|
+
// instead of being upscaled back into pixels the tool never drew.
|
|
2614
|
+
editPixelBudget: CODEX_PIXEL_BUDGET,
|
|
2600
2615
|
/*
|
|
2601
2616
|
* Five, and it is a hard constraint of the image tool, not a product
|
|
2602
2617
|
* choice.
|
|
@@ -2831,7 +2846,7 @@ function createDemoEngine(saveImage) {
|
|
|
2831
2846
|
<text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
|
|
2832
2847
|
<text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
|
|
2833
2848
|
</svg>`;
|
|
2834
|
-
return
|
|
2849
|
+
return sharp20(Buffer.from(svg)).png().toBuffer();
|
|
2835
2850
|
}
|
|
2836
2851
|
return {
|
|
2837
2852
|
capabilities() {
|
|
@@ -3005,7 +3020,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
|
3005
3020
|
for (const [slot, angle] of PRESENTER_ANGLES) {
|
|
3006
3021
|
const path = presenterRefPath(templatesRoot, presenter.id, slot);
|
|
3007
3022
|
if (!existsSync(path)) continue;
|
|
3008
|
-
const png = await
|
|
3023
|
+
const png = await sharp20(readFileSync(path)).png().toBuffer();
|
|
3009
3024
|
const hash = core.images.save(png);
|
|
3010
3025
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
3011
3026
|
}
|
|
@@ -3100,7 +3115,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
|
|
|
3100
3115
|
for (const angle of angles) {
|
|
3101
3116
|
const path = demoProductRefPath(templatesRoot, product.id, angle);
|
|
3102
3117
|
if (!existsSync(path)) continue;
|
|
3103
|
-
const png = await
|
|
3118
|
+
const png = await sharp20(readFileSync(path)).png().toBuffer();
|
|
3104
3119
|
const hash = core.images.save(png);
|
|
3105
3120
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
3106
3121
|
}
|
|
@@ -3239,7 +3254,7 @@ var assetHash = (ref) => {
|
|
|
3239
3254
|
};
|
|
3240
3255
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
3241
3256
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
3242
|
-
var toPng = (buf) =>
|
|
3257
|
+
var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
|
|
3243
3258
|
var COST_PROBE = {
|
|
3244
3259
|
prompt: "",
|
|
3245
3260
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -3252,11 +3267,11 @@ var MARK_MIN_EDGE = 1024;
|
|
|
3252
3267
|
var MARK_TINY_EDGE = 256;
|
|
3253
3268
|
var MARK_WARN_EDGE = 512;
|
|
3254
3269
|
var toMarkPng = async (buf) => {
|
|
3255
|
-
const out = await
|
|
3256
|
-
const meta = await
|
|
3270
|
+
const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3271
|
+
const meta = await sharp20(out).metadata();
|
|
3257
3272
|
const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
|
|
3258
3273
|
if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
|
|
3259
|
-
return
|
|
3274
|
+
return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
|
|
3260
3275
|
}
|
|
3261
3276
|
return out;
|
|
3262
3277
|
};
|
|
@@ -3267,9 +3282,9 @@ async function capReferenceEdge(core, path, maxEdge) {
|
|
|
3267
3282
|
if (hit) return hit;
|
|
3268
3283
|
let out = path;
|
|
3269
3284
|
try {
|
|
3270
|
-
const meta = await
|
|
3285
|
+
const meta = await sharp20(path).metadata();
|
|
3271
3286
|
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
3272
|
-
const buf = await
|
|
3287
|
+
const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3273
3288
|
out = core.images.pathFor(core.images.save(buf));
|
|
3274
3289
|
}
|
|
3275
3290
|
} catch {
|
|
@@ -3306,12 +3321,21 @@ function productFidelityDirective(attached) {
|
|
|
3306
3321
|
function editPreservationDirective(scope, opts) {
|
|
3307
3322
|
if (scope === "local") {
|
|
3308
3323
|
const removal = opts?.removal ? " What is removed leaves nothing behind: the surface and the scene continue as if it had never been there, with no outline, silhouette, residue or ghost of it." : "";
|
|
3309
|
-
return "This is a change to a photograph that already exists, not a new photograph. Return the same image with one change made. Everything the instruction does not name comes back exactly as it is now: the same framing, the same crop, the same camera position, the same subject placement and pose, the same lighting, the same colours, the same background and the same dimensions. Do not re-render, re-stage, re-light or re-compose the picture. Change only what was asked for, together with the shadows, reflections and contact points that move with it." + removal;
|
|
3324
|
+
return "This is a change to a photograph that already exists, not a new photograph. Return the same image with one change made. Everything the instruction does not name comes back exactly as it is now: the same framing, the same crop, the same camera position, the same subject placement and pose, the same lighting, the same colours, the same background and the same dimensions. Do not re-render, re-stage, re-light or re-compose the picture. Change only what was asked for, together with the shadows, reflections and contact points that move with it. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now." + removal;
|
|
3310
3325
|
}
|
|
3311
|
-
return "This is a change to a photograph that already exists, not a new photograph. Apply the instruction to the image you were given and keep what it does not name: the same subject and the same face, the same product with the same label, geometry and colour, and the same dimensions. Do not replace the subject and do not redesign the product.";
|
|
3326
|
+
return "This is a change to a photograph that already exists, not a new photograph. Apply the instruction to the image you were given and keep what it does not name: the same subject and the same face, the same product with the same label, geometry and colour, and the same dimensions. Do not replace the subject and do not redesign the product. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now.";
|
|
3327
|
+
}
|
|
3328
|
+
function inheritedIdentityDirective(kinds) {
|
|
3329
|
+
const product = kinds?.product ?? true;
|
|
3330
|
+
const person = kinds?.person ?? true;
|
|
3331
|
+
if (product && person)
|
|
3332
|
+
return "The attached product and person references are the same product and the same person that are already in this picture. Use them to hold that identity exact while you make the change, not as a reason to re-stage the shot.";
|
|
3333
|
+
if (product)
|
|
3334
|
+
return "The attached product references show the same product that is already in this picture. Use them to hold its identity exact while you make the change, not as a reason to re-stage the shot.";
|
|
3335
|
+
return "The attached person reference shows the same person who is already in this picture. Use it to hold their identity exact while you make the change, not as a reason to re-stage the shot.";
|
|
3312
3336
|
}
|
|
3313
|
-
function
|
|
3314
|
-
return "The
|
|
3337
|
+
function inheritedRefDirective() {
|
|
3338
|
+
return "The carried reference is attached for composition, lighting and treatment only. Any person or product visible in it lends mood, never identity \u2014 nobody and nothing in this photograph takes a face, a body or a design from it.";
|
|
3315
3339
|
}
|
|
3316
3340
|
function productFactDirectives(p) {
|
|
3317
3341
|
const out = [];
|
|
@@ -3340,6 +3364,9 @@ function productEditFidelityDirective(name) {
|
|
|
3340
3364
|
function characterEditIdentityDirective(name) {
|
|
3341
3365
|
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.`;
|
|
3342
3366
|
}
|
|
3367
|
+
function referenceIdentityGuard() {
|
|
3368
|
+
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.";
|
|
3369
|
+
}
|
|
3343
3370
|
function markEditDirective() {
|
|
3344
3371
|
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.";
|
|
3345
3372
|
}
|
|
@@ -3440,7 +3467,7 @@ function markLabel(brand, logo) {
|
|
|
3440
3467
|
|
|
3441
3468
|
// src/brief.ts
|
|
3442
3469
|
var PRODUCT_REF_MAX = 3;
|
|
3443
|
-
var CHARACTER_REF_MAX =
|
|
3470
|
+
var CHARACTER_REF_MAX = 3;
|
|
3444
3471
|
var SCENE_REF_MAX = 1;
|
|
3445
3472
|
var FORMATS = [
|
|
3446
3473
|
{ id: "square", label: "Square 1:1", w: 1024, h: 1024 },
|
|
@@ -3497,6 +3524,7 @@ function validateBrief(brief) {
|
|
|
3497
3524
|
function compileBrief(brief, ctx) {
|
|
3498
3525
|
const warnings = [];
|
|
3499
3526
|
const attachments = [];
|
|
3527
|
+
const rawSceneFallback = [];
|
|
3500
3528
|
const productDirectives = [];
|
|
3501
3529
|
const personDirectives = [];
|
|
3502
3530
|
const otherDirectives = [];
|
|
@@ -3633,10 +3661,14 @@ function compileBrief(brief, ctx) {
|
|
|
3633
3661
|
inlineTemplates.push(t);
|
|
3634
3662
|
append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
|
|
3635
3663
|
if (ctx.mode !== "edit" && t.figure) {
|
|
3636
|
-
|
|
3637
|
-
|
|
3664
|
+
const plate = assetHash2(t.preview);
|
|
3665
|
+
const hasPlate = !!plate && ctx.images.has(plate);
|
|
3666
|
+
const candidates = hasPlate ? [plate] : (t.refs ?? []).slice(0, SCENE_REF_MAX).map((r) => assetHash2(r?.file));
|
|
3667
|
+
for (const h of candidates) {
|
|
3638
3668
|
if (h && ctx.images.has(h)) {
|
|
3639
|
-
|
|
3669
|
+
const a = { role: "scene", id: t.id, label: t.name, hash: h, essential: false };
|
|
3670
|
+
attachments.push(a);
|
|
3671
|
+
if (!hasPlate) rawSceneFallback.push(a);
|
|
3640
3672
|
}
|
|
3641
3673
|
}
|
|
3642
3674
|
}
|
|
@@ -3687,6 +3719,12 @@ function compileBrief(brief, ctx) {
|
|
|
3687
3719
|
}
|
|
3688
3720
|
const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
|
|
3689
3721
|
const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
|
|
3722
|
+
if (hasPerson && rawSceneFallback.length) {
|
|
3723
|
+
for (const a of rawSceneFallback) {
|
|
3724
|
+
const i = attachments.indexOf(a);
|
|
3725
|
+
if (i !== -1) attachments.splice(i, 1);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3690
3728
|
const max = ctx.engineCaps.maxReferenceImages;
|
|
3691
3729
|
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
3692
3730
|
const guard = scene ? sceneGuardDirectives({
|
|
@@ -3720,7 +3758,7 @@ function compileBrief(brief, ctx) {
|
|
|
3720
3758
|
const brandLines = brandRuleDirectives(ctx.brand);
|
|
3721
3759
|
const preservation = ctx.mode === "edit" ? [
|
|
3722
3760
|
...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
|
|
3723
|
-
...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
|
|
3761
|
+
...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
|
|
3724
3762
|
...ctx.inheritedDirectives ?? []
|
|
3725
3763
|
] : [];
|
|
3726
3764
|
const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
|
|
@@ -3728,6 +3766,7 @@ function compileBrief(brief, ctx) {
|
|
|
3728
3766
|
const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
|
|
3729
3767
|
return String(rec?.category ?? "").toLowerCase() === "apparel";
|
|
3730
3768
|
}) ? [garmentDisplayDirective()] : [];
|
|
3769
|
+
const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
|
|
3731
3770
|
const allDirectives = [
|
|
3732
3771
|
...productDirectives,
|
|
3733
3772
|
...personDirectives,
|
|
@@ -3739,15 +3778,21 @@ function compileBrief(brief, ctx) {
|
|
|
3739
3778
|
...apparelUnworn,
|
|
3740
3779
|
...brandLines,
|
|
3741
3780
|
...guard,
|
|
3781
|
+
...refGuard,
|
|
3742
3782
|
...preservation
|
|
3743
3783
|
];
|
|
3744
3784
|
if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
|
|
3745
3785
|
if (dropped.some((d) => d.role !== "scene")) {
|
|
3746
|
-
const
|
|
3747
|
-
const
|
|
3748
|
-
|
|
3749
|
-
`${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
|
|
3786
|
+
const keptLabels = new Set(kept.map((a) => a.label));
|
|
3787
|
+
const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
|
|
3788
|
+
(l) => !keptLabels.has(l)
|
|
3750
3789
|
);
|
|
3790
|
+
if (names.length) {
|
|
3791
|
+
const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
|
|
3792
|
+
warnings.push(
|
|
3793
|
+
`${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
|
|
3794
|
+
);
|
|
3795
|
+
}
|
|
3751
3796
|
}
|
|
3752
3797
|
return {
|
|
3753
3798
|
prompt: prompt.trim(),
|
|
@@ -5756,8 +5801,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
5756
5801
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
5757
5802
|
return;
|
|
5758
5803
|
}
|
|
5759
|
-
const png = await
|
|
5760
|
-
const meta = await
|
|
5804
|
+
const png = await sharp20(buf).rotate().png().toBuffer();
|
|
5805
|
+
const meta = await sharp20(png).metadata();
|
|
5761
5806
|
const hash = core.images.save(png);
|
|
5762
5807
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
5763
5808
|
width: meta.width,
|
|
@@ -6206,7 +6251,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
6206
6251
|
return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
|
|
6207
6252
|
}
|
|
6208
6253
|
async function edgeBarGeometry(buf) {
|
|
6209
|
-
const { data, info } = await
|
|
6254
|
+
const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
6210
6255
|
const W = info.width;
|
|
6211
6256
|
const H = info.height;
|
|
6212
6257
|
const scan = (len, cross, at) => {
|
|
@@ -6260,7 +6305,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
6260
6305
|
const width = g.right - g.left + 1;
|
|
6261
6306
|
const height = g.bottom - g.top + 1;
|
|
6262
6307
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
6263
|
-
const png = await
|
|
6308
|
+
const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
6264
6309
|
return core.images.save(png);
|
|
6265
6310
|
} catch {
|
|
6266
6311
|
return hash;
|
|
@@ -6302,12 +6347,12 @@ async function avatarCrop(core, hash) {
|
|
|
6302
6347
|
);
|
|
6303
6348
|
}
|
|
6304
6349
|
async function figureBox(buf) {
|
|
6305
|
-
const meta = await
|
|
6350
|
+
const meta = await sharp20(buf).metadata();
|
|
6306
6351
|
const W = meta.width ?? 0;
|
|
6307
6352
|
const H = meta.height ?? 0;
|
|
6308
6353
|
if (!W || !H) return null;
|
|
6309
6354
|
for (const threshold of FIGURE_TRIM_THRESHOLDS) {
|
|
6310
|
-
const { info } = await
|
|
6355
|
+
const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
|
|
6311
6356
|
const left = Math.abs(info.trimOffsetLeft ?? 0);
|
|
6312
6357
|
const top = Math.abs(info.trimOffsetTop ?? 0);
|
|
6313
6358
|
const width = info.width ?? 0;
|
|
@@ -6340,13 +6385,13 @@ async function smartCover(core, hash, box) {
|
|
|
6340
6385
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6341
6386
|
try {
|
|
6342
6387
|
const buf = core.images.read(hash);
|
|
6343
|
-
const meta = await
|
|
6388
|
+
const meta = await sharp20(buf).metadata();
|
|
6344
6389
|
const w = meta.width ?? 0;
|
|
6345
6390
|
const h = meta.height ?? 0;
|
|
6346
6391
|
if (!w || !h) return void 0;
|
|
6347
6392
|
const raw = box(w, h);
|
|
6348
6393
|
const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
|
|
6349
|
-
const png = await
|
|
6394
|
+
const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
6350
6395
|
return core.images.save(png);
|
|
6351
6396
|
} catch {
|
|
6352
6397
|
return void 0;
|
|
@@ -6355,11 +6400,11 @@ async function smartCover(core, hash, box) {
|
|
|
6355
6400
|
async function crop(core, hash, region, cap2) {
|
|
6356
6401
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6357
6402
|
try {
|
|
6358
|
-
const meta = await
|
|
6403
|
+
const meta = await sharp20(core.images.read(hash)).metadata();
|
|
6359
6404
|
const w = meta.width ?? 0;
|
|
6360
6405
|
const h = meta.height ?? 0;
|
|
6361
6406
|
if (!w || !h) return void 0;
|
|
6362
|
-
let pipeline =
|
|
6407
|
+
let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
|
|
6363
6408
|
if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
|
|
6364
6409
|
const png = await pipeline.png().toBuffer();
|
|
6365
6410
|
return core.images.save(png);
|
|
@@ -6450,7 +6495,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
|
|
|
6450
6495
|
});
|
|
6451
6496
|
}
|
|
6452
6497
|
function scenePreviewPrompt(scene) {
|
|
6453
|
-
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.";
|
|
6498
|
+
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. " + (scene.figureTreatment ? "No product and no watermarks. Where the treatment itself carries printing, render it as genuinely designed print - real letterforms, readable words, numerals and label-quality artwork - belonging to companies that are plausible but fictional, resembling no existing brand, and borrowing, extending or re-spelling no name that appears in any attached reference. Everywhere outside the treatment, no logos and no readable words." : "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.";
|
|
6454
6499
|
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;
|
|
6455
6500
|
}
|
|
6456
6501
|
async function draw(deps, req) {
|
|
@@ -6621,6 +6666,119 @@ function scopeOfInstruction(text) {
|
|
|
6621
6666
|
if (!matched.length) return { scope: "global", matched: ["no local cue"] };
|
|
6622
6667
|
return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
|
|
6623
6668
|
}
|
|
6669
|
+
var FIT_EDGE = 160;
|
|
6670
|
+
var GATE_EDGE = 32;
|
|
6671
|
+
var GRADE_GATE_MEAN_DELTA = 40;
|
|
6672
|
+
var SLOPE_CLAMP = 1.8;
|
|
6673
|
+
var TONAL_WORD = /^(warm(er)?|cool(er)?|bright(er)?|dark(er)?|deep(er)?|soft(er)?|hard(er)?|punch(ier|y)?|rich(er)?|flat(ter)?|mood(y|ier)?|golden|overcast|sun(ny|lit)?|cloudy|dusk|dawn|evening|morning|night|daylight|light|lighting|lit|glow(ing)?|exposure|contrast|saturation|desaturat(e|ed)|muted|vivid|tone[sd]?|tint(ed)?|grade[d]?|grading|temperature|white|balance|shadows?|highlights?|blacks?|whites?|filmic|cinematic|dramatic|airy|hazy|misty|crisp)$/i;
|
|
6674
|
+
var NEUTRAL_WORD = /^(a|an|the|it|its|this|that|make|makes|keep|slightly|slight|touch|bit|little|more|less|much|very|again|overall|whole|image|frame|shot|picture|photo|look|feel|and|of|in|with|to|too|just|please|now|even)$/i;
|
|
6675
|
+
function isGradeOnlyInstruction(text) {
|
|
6676
|
+
const words = String(text ?? "").toLowerCase().replace(/[^a-z\s-]/g, " ").split(/\s+/).filter(Boolean);
|
|
6677
|
+
if (!words.length) return false;
|
|
6678
|
+
let tonal = 0;
|
|
6679
|
+
for (const w of words) {
|
|
6680
|
+
if (TONAL_WORD.test(w)) tonal++;
|
|
6681
|
+
else if (!NEUTRAL_WORD.test(w)) return false;
|
|
6682
|
+
}
|
|
6683
|
+
return tonal > 0;
|
|
6684
|
+
}
|
|
6685
|
+
var srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
6686
|
+
var linearToSrgb = (c) => c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
6687
|
+
var EPS = 1e-6;
|
|
6688
|
+
var toLab = (r, g, b) => {
|
|
6689
|
+
const R = srgbToLinear(r / 255);
|
|
6690
|
+
const G = srgbToLinear(g / 255);
|
|
6691
|
+
const B = srgbToLinear(b / 255);
|
|
6692
|
+
const L = Math.log10(Math.max(EPS, 0.3811 * R + 0.5783 * G + 0.0402 * B));
|
|
6693
|
+
const M = Math.log10(Math.max(EPS, 0.1967 * R + 0.7244 * G + 0.0782 * B));
|
|
6694
|
+
const S = Math.log10(Math.max(EPS, 0.0241 * R + 0.1288 * G + 0.8444 * B));
|
|
6695
|
+
return [(L + M + S) / Math.sqrt(3), (L + M - 2 * S) / Math.sqrt(6), (L - M) / Math.sqrt(2)];
|
|
6696
|
+
};
|
|
6697
|
+
var fromLab = (l, a, bb) => {
|
|
6698
|
+
const L = l / Math.sqrt(3) + a / Math.sqrt(6) + bb / Math.sqrt(2);
|
|
6699
|
+
const M = l / Math.sqrt(3) + a / Math.sqrt(6) - bb / Math.sqrt(2);
|
|
6700
|
+
const S = l / Math.sqrt(3) - 2 * a / Math.sqrt(6);
|
|
6701
|
+
const Rl = 10 ** L;
|
|
6702
|
+
const Ml = 10 ** M;
|
|
6703
|
+
const Sl = 10 ** S;
|
|
6704
|
+
const R = 4.4679 * Rl - 3.5873 * Ml + 0.1193 * Sl;
|
|
6705
|
+
const G = -1.2186 * Rl + 2.3809 * Ml - 0.1624 * Sl;
|
|
6706
|
+
const B = 0.0497 * Rl - 0.2439 * Ml + 1.2045 * Sl;
|
|
6707
|
+
const clamp = (x) => Math.max(0, Math.min(255, Math.round(linearToSrgb(Math.max(0, Math.min(1, x))) * 255)));
|
|
6708
|
+
return [clamp(R), clamp(G), clamp(B)];
|
|
6709
|
+
};
|
|
6710
|
+
var rawAt = async (png, edge) => {
|
|
6711
|
+
let img = sharp20(png);
|
|
6712
|
+
if (edge) img = img.resize(edge, edge, { fit: "fill" });
|
|
6713
|
+
const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
6714
|
+
return { data, width: info.width, height: info.height };
|
|
6715
|
+
};
|
|
6716
|
+
var labStats = (raw) => {
|
|
6717
|
+
const n = raw.data.length / 3;
|
|
6718
|
+
const mu = [0, 0, 0];
|
|
6719
|
+
const sq = [0, 0, 0];
|
|
6720
|
+
for (let i = 0; i < raw.data.length; i += 3) {
|
|
6721
|
+
const lab = toLab(raw.data[i], raw.data[i + 1], raw.data[i + 2]);
|
|
6722
|
+
for (let c = 0; c < 3; c++) {
|
|
6723
|
+
mu[c] += lab[c];
|
|
6724
|
+
sq[c] += lab[c] * lab[c];
|
|
6725
|
+
}
|
|
6726
|
+
}
|
|
6727
|
+
for (let c = 0; c < 3; c++) {
|
|
6728
|
+
mu[c] /= n;
|
|
6729
|
+
sq[c] = Math.sqrt(Math.max(1e-9, sq[c] / n - mu[c] * mu[c]));
|
|
6730
|
+
}
|
|
6731
|
+
return { mu, sd: sq };
|
|
6732
|
+
};
|
|
6733
|
+
var fitAffine = (input, output) => {
|
|
6734
|
+
const a = labStats(input);
|
|
6735
|
+
const b = labStats(output);
|
|
6736
|
+
return [0, 1, 2].map((c) => ({
|
|
6737
|
+
k: Math.max(1 / SLOPE_CLAMP, Math.min(SLOPE_CLAMP, b.sd[c] / a.sd[c])),
|
|
6738
|
+
mi: a.mu[c],
|
|
6739
|
+
mo: b.mu[c]
|
|
6740
|
+
}));
|
|
6741
|
+
};
|
|
6742
|
+
var applyAffine = (raw, T) => {
|
|
6743
|
+
const d = raw.data;
|
|
6744
|
+
for (let i = 0; i < d.length; i += 3) {
|
|
6745
|
+
const peak = Math.max(d[i], d[i + 1], d[i + 2]);
|
|
6746
|
+
const w = peak <= 225 ? 1 : Math.max(0, (255 - peak) / 30);
|
|
6747
|
+
const lab = toLab(d[i], d[i + 1], d[i + 2]);
|
|
6748
|
+
const out = fromLab(
|
|
6749
|
+
(lab[0] - T[0].mi) * T[0].k + T[0].mo,
|
|
6750
|
+
(lab[1] - T[1].mi) * T[1].k + T[1].mo,
|
|
6751
|
+
(lab[2] - T[2].mi) * T[2].k + T[2].mo
|
|
6752
|
+
);
|
|
6753
|
+
d[i] = Math.round(d[i] * (1 - w) + out[0] * w);
|
|
6754
|
+
d[i + 1] = Math.round(d[i + 1] * (1 - w) + out[1] * w);
|
|
6755
|
+
d[i + 2] = Math.round(d[i + 2] * (1 - w) + out[2] * w);
|
|
6756
|
+
}
|
|
6757
|
+
};
|
|
6758
|
+
var meanDelta = (a, b) => {
|
|
6759
|
+
const n = Math.min(a.data.length, b.data.length);
|
|
6760
|
+
let sum = 0;
|
|
6761
|
+
for (let i = 0; i < n; i++) sum += Math.abs(a.data[i] - b.data[i]);
|
|
6762
|
+
return n ? sum / n : 255;
|
|
6763
|
+
};
|
|
6764
|
+
async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
|
|
6765
|
+
try {
|
|
6766
|
+
const T = fitAffine(await rawAt(modelInputPng, FIT_EDGE), await rawAt(modelOutputPng, FIT_EDGE));
|
|
6767
|
+
const gateIn = await rawAt(modelInputPng, GATE_EDGE);
|
|
6768
|
+
const gateOut = await rawAt(modelOutputPng, GATE_EDGE);
|
|
6769
|
+
applyAffine(gateIn, T);
|
|
6770
|
+
const residual = meanDelta(gateIn, gateOut);
|
|
6771
|
+
if (residual > GRADE_GATE_MEAN_DELTA) return null;
|
|
6772
|
+
const full = await rawAt(originalPng);
|
|
6773
|
+
applyAffine(full, T);
|
|
6774
|
+
const image = await sharp20(full.data, {
|
|
6775
|
+
raw: { width: full.width, height: full.height, channels: 3 }
|
|
6776
|
+
}).png().toBuffer();
|
|
6777
|
+
return { image, residual };
|
|
6778
|
+
} catch {
|
|
6779
|
+
return null;
|
|
6780
|
+
}
|
|
6781
|
+
}
|
|
6624
6782
|
|
|
6625
6783
|
// src/expandRules.ts
|
|
6626
6784
|
var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
|
|
@@ -6687,13 +6845,20 @@ Avoid: new objects, products, people, text or watermarks; do not recompose, rece
|
|
|
6687
6845
|
// src/editSizeRules.ts
|
|
6688
6846
|
var SAME_SHAPE_TOL = 0.01;
|
|
6689
6847
|
var SHRINK_FLOOR = 0.8;
|
|
6690
|
-
|
|
6848
|
+
var BUDGET_TOL = 0.1;
|
|
6849
|
+
function judgeEditSize(src, got, opts) {
|
|
6691
6850
|
if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
|
|
6692
6851
|
if (got.width === src.width && got.height === src.height) return { action: "keep" };
|
|
6693
6852
|
const want = src.width / src.height;
|
|
6694
6853
|
const have = got.width / got.height;
|
|
6695
6854
|
if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
|
|
6696
6855
|
const scale = Math.max(got.width, got.height) / Math.max(src.width, src.height);
|
|
6856
|
+
const budget = opts?.pixelBudget;
|
|
6857
|
+
if (budget && src.width * src.height > budget) {
|
|
6858
|
+
const gotPx = got.width * got.height;
|
|
6859
|
+
if (gotPx <= src.width * src.height && Math.abs(gotPx - budget) / budget <= BUDGET_TOL)
|
|
6860
|
+
return { action: "accept", scale };
|
|
6861
|
+
}
|
|
6697
6862
|
if (scale < SHRINK_FLOOR) return { action: "reject", scale };
|
|
6698
6863
|
return { action: "resize", scale };
|
|
6699
6864
|
}
|
|
@@ -6712,7 +6877,7 @@ function planCrop(source, targetRatio) {
|
|
|
6712
6877
|
}
|
|
6713
6878
|
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
6714
6879
|
try {
|
|
6715
|
-
const { info } = await
|
|
6880
|
+
const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
6716
6881
|
const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
|
|
6717
6882
|
const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
|
|
6718
6883
|
const left = Math.round((attnLeft + plan.left) / 2);
|
|
@@ -6865,23 +7030,23 @@ function relax(grid, seam, fixedSweeps) {
|
|
|
6865
7030
|
|
|
6866
7031
|
// src/expand.ts
|
|
6867
7032
|
async function expandCanvas(source, plan) {
|
|
6868
|
-
const bed = await
|
|
6869
|
-
return
|
|
7033
|
+
const bed = await sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
7034
|
+
return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
6870
7035
|
}
|
|
6871
7036
|
async function compositeExpand(engineImage, source, plan) {
|
|
6872
|
-
const meta = await
|
|
7037
|
+
const meta = await sharp20(engineImage).metadata();
|
|
6873
7038
|
const want = plan.width / plan.height;
|
|
6874
7039
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
6875
7040
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
6876
7041
|
const aligned = sameOrientation;
|
|
6877
7042
|
const exact = meta.width === plan.width && meta.height === plan.height;
|
|
6878
|
-
const surround = aligned ? exact ? engineImage : await
|
|
7043
|
+
const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
6879
7044
|
const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
|
|
6880
|
-
const image = await
|
|
7045
|
+
const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
6881
7046
|
return { image, aligned };
|
|
6882
7047
|
}
|
|
6883
7048
|
async function matchMarginsToSeam(surround, source, plan) {
|
|
6884
|
-
const src = await
|
|
7049
|
+
const src = await sharp20(source).metadata();
|
|
6885
7050
|
if (!src.width || !src.height) return surround;
|
|
6886
7051
|
const SW = src.width;
|
|
6887
7052
|
const SH = src.height;
|
|
@@ -6928,8 +7093,8 @@ var MAX_CORRECTION = 60;
|
|
|
6928
7093
|
async function reconcile(surround, source, side, axis) {
|
|
6929
7094
|
const { margin } = side;
|
|
6930
7095
|
if (margin.width < 1 || margin.height < 1) return surround;
|
|
6931
|
-
const marginRaw = await
|
|
6932
|
-
const edgeRaw = await
|
|
7096
|
+
const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
|
|
7097
|
+
const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
|
|
6933
7098
|
const W = margin.width;
|
|
6934
7099
|
const H = margin.height;
|
|
6935
7100
|
const along = axis === "width" ? H : W;
|
|
@@ -6983,11 +7148,11 @@ async function reconcile(surround, source, side, axis) {
|
|
|
6983
7148
|
}
|
|
6984
7149
|
}
|
|
6985
7150
|
}
|
|
6986
|
-
const patch2 = await
|
|
6987
|
-
return
|
|
7151
|
+
const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
7152
|
+
return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
|
|
6988
7153
|
}
|
|
6989
7154
|
async function expandCanvasBedOnly(source, plan) {
|
|
6990
|
-
return
|
|
7155
|
+
return sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
6991
7156
|
}
|
|
6992
7157
|
function medianOf(rgb, channel, from, to) {
|
|
6993
7158
|
const n = to - from;
|
|
@@ -6998,17 +7163,17 @@ function medianOf(rgb, channel, from, to) {
|
|
|
6998
7163
|
return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
|
|
6999
7164
|
}
|
|
7000
7165
|
async function reframeExpand(engineImage, plan) {
|
|
7001
|
-
const meta = await
|
|
7166
|
+
const meta = await sharp20(engineImage).metadata();
|
|
7002
7167
|
if (!(meta.width && meta.height)) return null;
|
|
7003
7168
|
const want = plan.width / plan.height;
|
|
7004
7169
|
const got = meta.width / meta.height;
|
|
7005
7170
|
if (got >= 1 !== want >= 1) return null;
|
|
7006
7171
|
if (meta.width === plan.width && meta.height === plan.height) return engineImage;
|
|
7007
7172
|
const straight = Math.abs(got - want) / want <= 0.02;
|
|
7008
|
-
return
|
|
7173
|
+
return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
|
|
7009
7174
|
}
|
|
7010
7175
|
async function seamScore(image, plan, source) {
|
|
7011
|
-
const { data, info } = await
|
|
7176
|
+
const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
7012
7177
|
const W = info.width;
|
|
7013
7178
|
const H = info.height;
|
|
7014
7179
|
const horizontal = plan.axis === "width";
|
|
@@ -7041,7 +7206,7 @@ var SEAM_VISIBLE = 2.2;
|
|
|
7041
7206
|
var OFFSET = 4;
|
|
7042
7207
|
var RESIDUAL_VISIBLE = 15;
|
|
7043
7208
|
async function seamResidual(image, plan, source) {
|
|
7044
|
-
const { data, info } = await
|
|
7209
|
+
const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
7045
7210
|
const W = info.width;
|
|
7046
7211
|
const H = info.height;
|
|
7047
7212
|
const ch = info.channels;
|
|
@@ -7076,7 +7241,7 @@ var MAX_SHARE = 0.8;
|
|
|
7076
7241
|
async function subjectFraction(src, source, axis) {
|
|
7077
7242
|
try {
|
|
7078
7243
|
const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
|
|
7079
|
-
const { info } = await
|
|
7244
|
+
const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
7080
7245
|
const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
|
|
7081
7246
|
const span = axis === "width" ? source.width : source.height;
|
|
7082
7247
|
const extent = axis === "width" ? window.width : window.height;
|
|
@@ -7099,14 +7264,14 @@ function placeExpand(plan, source, fraction) {
|
|
|
7099
7264
|
}
|
|
7100
7265
|
var NEUTRAL = { r: 128, g: 128, b: 128 };
|
|
7101
7266
|
async function conditioningCanvas(source, plan, fill = "edge") {
|
|
7102
|
-
const meta = await
|
|
7267
|
+
const meta = await sharp20(source).metadata();
|
|
7103
7268
|
const sw = meta.width ?? 0;
|
|
7104
7269
|
const sh = meta.height ?? 0;
|
|
7105
7270
|
if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
|
|
7106
7271
|
const layers = [];
|
|
7107
7272
|
if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
|
|
7108
7273
|
layers.push({ input: source, left: plan.left, top: plan.top });
|
|
7109
|
-
const canvas =
|
|
7274
|
+
const canvas = sharp20({
|
|
7110
7275
|
create: {
|
|
7111
7276
|
width: plan.width,
|
|
7112
7277
|
height: plan.height,
|
|
@@ -7118,7 +7283,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
|
|
|
7118
7283
|
}
|
|
7119
7284
|
async function edgeMargins(source, plan, size) {
|
|
7120
7285
|
const out = [];
|
|
7121
|
-
const strip = async (extract, width, height) =>
|
|
7286
|
+
const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
|
|
7122
7287
|
if (plan.axis === "width") {
|
|
7123
7288
|
const before = plan.left;
|
|
7124
7289
|
const after = plan.width - plan.left - size.width;
|
|
@@ -7209,12 +7374,12 @@ async function resolveOutpaintRoute(all, shot) {
|
|
|
7209
7374
|
return { engine: shot, method: "reframe", crossed: false };
|
|
7210
7375
|
}
|
|
7211
7376
|
async function driftDiff(a, b) {
|
|
7212
|
-
const metaA = await
|
|
7213
|
-
const metaB = await
|
|
7377
|
+
const metaA = await sharp20(a).metadata();
|
|
7378
|
+
const metaB = await sharp20(b).metadata();
|
|
7214
7379
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
7215
7380
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
7216
7381
|
const [rawA, rawB] = await Promise.all(
|
|
7217
|
-
[a, b].map((buf) =>
|
|
7382
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
7218
7383
|
);
|
|
7219
7384
|
const out = new PNG({ width, height });
|
|
7220
7385
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -7226,11 +7391,11 @@ async function driftDiff(a, b) {
|
|
|
7226
7391
|
};
|
|
7227
7392
|
}
|
|
7228
7393
|
async function changeMask(a, b, cap2 = 1024) {
|
|
7229
|
-
const metaA = await
|
|
7394
|
+
const metaA = await sharp20(a).metadata();
|
|
7230
7395
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
7231
7396
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
7232
7397
|
const [rawA, rawB] = await Promise.all(
|
|
7233
|
-
[a, b].map((buf) =>
|
|
7398
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
7234
7399
|
);
|
|
7235
7400
|
const out = new PNG({ width, height });
|
|
7236
7401
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -7279,8 +7444,8 @@ function dilationFor(longEdge) {
|
|
|
7279
7444
|
// src/localEdit.ts
|
|
7280
7445
|
async function preserveOutsideChange(source, edited) {
|
|
7281
7446
|
try {
|
|
7282
|
-
const srcMeta = await
|
|
7283
|
-
const outMeta = await
|
|
7447
|
+
const srcMeta = await sharp20(source).metadata();
|
|
7448
|
+
const outMeta = await sharp20(edited).metadata();
|
|
7284
7449
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
7285
7450
|
return { image: edited, outcome: "error", changed: 0 };
|
|
7286
7451
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -7290,15 +7455,15 @@ async function preserveOutsideChange(source, edited) {
|
|
|
7290
7455
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
7291
7456
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
7292
7457
|
const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
|
|
7293
|
-
const spread = await
|
|
7294
|
-
const dilated = await
|
|
7295
|
-
const feathered = await
|
|
7296
|
-
const grown = await
|
|
7297
|
-
const editedRgb = await
|
|
7298
|
-
const masked = await
|
|
7458
|
+
const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
|
|
7459
|
+
const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
|
|
7460
|
+
const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
|
|
7461
|
+
const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
7462
|
+
const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
7463
|
+
const masked = await sharp20(editedRgb, {
|
|
7299
7464
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
7300
7465
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
7301
|
-
const image = await
|
|
7466
|
+
const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
7302
7467
|
return { image, outcome: "composited", changed: shape.changed };
|
|
7303
7468
|
} catch {
|
|
7304
7469
|
return { image: edited, outcome: "error", changed: 0 };
|
|
@@ -7336,7 +7501,7 @@ function registerLogoRoutes(app, deps) {
|
|
|
7336
7501
|
const v = validateBrand(json);
|
|
7337
7502
|
if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
|
|
7338
7503
|
const row = core.store.updateBrand(brand.id, json);
|
|
7339
|
-
const meta = await
|
|
7504
|
+
const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
|
|
7340
7505
|
const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
|
|
7341
7506
|
return { ...row, logoHash: part.hash, logoEdge };
|
|
7342
7507
|
});
|
|
@@ -7463,7 +7628,7 @@ async function vibrantColor(input) {
|
|
|
7463
7628
|
let data;
|
|
7464
7629
|
let channels;
|
|
7465
7630
|
try {
|
|
7466
|
-
const out = await
|
|
7631
|
+
const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
7467
7632
|
data = out.data;
|
|
7468
7633
|
channels = out.info.channels;
|
|
7469
7634
|
} catch {
|
|
@@ -7486,7 +7651,7 @@ async function vibrantColor(input) {
|
|
|
7486
7651
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
7487
7652
|
if (best.score <= 0) {
|
|
7488
7653
|
try {
|
|
7489
|
-
const { dominant } = await
|
|
7654
|
+
const { dominant } = await sharp20(input).stats();
|
|
7490
7655
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
7491
7656
|
} catch {
|
|
7492
7657
|
return null;
|
|
@@ -7870,10 +8035,11 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
7870
8035
|
core.ledger.recordCost(engineId, null, result.costUsd);
|
|
7871
8036
|
const hash = result.images[0];
|
|
7872
8037
|
if (!hash) return reply.status(500).send({ error: "the engine returned no image" });
|
|
8038
|
+
const trimmed = await trimEdgeBars(core, hash);
|
|
7873
8039
|
commit(core, brand.id, (json) => {
|
|
7874
|
-
json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${
|
|
8040
|
+
json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${trimmed}` } : s);
|
|
7875
8041
|
});
|
|
7876
|
-
return { preview: `asset:${
|
|
8042
|
+
return { preview: `asset:${trimmed}`, brand: core.store.getBrand(brand.id) };
|
|
7877
8043
|
});
|
|
7878
8044
|
}
|
|
7879
8045
|
async function withDerivedCrops(core, body, base) {
|
|
@@ -8104,7 +8270,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
8104
8270
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
8105
8271
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
8106
8272
|
for (const p of chosen) {
|
|
8107
|
-
const buf = p.width && p.height ? await
|
|
8273
|
+
const buf = p.width && p.height ? await sharp20(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
8108
8274
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
8109
8275
|
}
|
|
8110
8276
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -8265,8 +8431,8 @@ function registerImageRoutes(app, deps) {
|
|
|
8265
8431
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
8266
8432
|
const buf = await part.toBuffer();
|
|
8267
8433
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
8268
|
-
const fmt = (await
|
|
8269
|
-
const png = fmt === "svg" ? await toMarkPng(buf) : await
|
|
8434
|
+
const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
|
|
8435
|
+
const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
|
|
8270
8436
|
return { hash: core.images.save(png) };
|
|
8271
8437
|
});
|
|
8272
8438
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -8301,6 +8467,25 @@ function registerImageRoutes(app, deps) {
|
|
|
8301
8467
|
|
|
8302
8468
|
// src/release/notes.data.ts
|
|
8303
8469
|
var RELEASES = [
|
|
8470
|
+
{
|
|
8471
|
+
version: "0.7.0",
|
|
8472
|
+
date: "2026-08-30",
|
|
8473
|
+
title: "Your presenter stays your presenter.",
|
|
8474
|
+
sections: [
|
|
8475
|
+
{
|
|
8476
|
+
heading: "Scenes",
|
|
8477
|
+
body: "A scene lends its world, its light and its treatment, never a face. Generation now conditions on the scene\u2019s own drawn card instead of your raw upload, so the person you selected is the person in the shot. Scenes made before this release render printed treatments best after a one-tap redraw from the scene\u2019s page."
|
|
8478
|
+
},
|
|
8479
|
+
{
|
|
8480
|
+
heading: "Refine",
|
|
8481
|
+
body: "Refining light and mood no longer wears a shot down: a tonal refinement keeps the photograph\u2019s own pixels and changes only the grade, so the tenth adjustment is as sharp as the first. High-resolution refinements state their working size honestly instead of inflating it, and refining an extended shot works again."
|
|
8482
|
+
},
|
|
8483
|
+
{
|
|
8484
|
+
heading: "Presenters",
|
|
8485
|
+
body: "A presenter\u2019s identity rides with three of their views. A reference you attach cannot lend anyone its face while a presenter is selected, and a mood image carried into a refinement is never mistaken for the person in the picture."
|
|
8486
|
+
}
|
|
8487
|
+
]
|
|
8488
|
+
},
|
|
8304
8489
|
{
|
|
8305
8490
|
version: "0.6.13",
|
|
8306
8491
|
date: "2026-08-30",
|
|
@@ -9099,7 +9284,7 @@ function buildServer(opts) {
|
|
|
9099
9284
|
// Measured as stored (post-toMarkPng), so the scrape judges the same
|
|
9100
9285
|
// pixels the compiler will one day attach.
|
|
9101
9286
|
probeLongEdge: async (buf) => {
|
|
9102
|
-
const m = await
|
|
9287
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
9103
9288
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
9104
9289
|
},
|
|
9105
9290
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -9188,7 +9373,7 @@ function buildServer(opts) {
|
|
|
9188
9373
|
fetchImpl: opts.fetchImpl,
|
|
9189
9374
|
saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
|
|
9190
9375
|
probeLongEdge: async (buf) => {
|
|
9191
|
-
const m = await
|
|
9376
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
9192
9377
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
9193
9378
|
},
|
|
9194
9379
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -9294,7 +9479,19 @@ function buildServer(opts) {
|
|
|
9294
9479
|
registerShowcaseRoutes(app, { templatesRoot });
|
|
9295
9480
|
app.get("/api/formats", async () => FORMATS);
|
|
9296
9481
|
function briefInputsOnly(brief) {
|
|
9297
|
-
const {
|
|
9482
|
+
const {
|
|
9483
|
+
inherited,
|
|
9484
|
+
rendered,
|
|
9485
|
+
croppedFrom,
|
|
9486
|
+
resizedFrom,
|
|
9487
|
+
resampledHops,
|
|
9488
|
+
steppedDown,
|
|
9489
|
+
gradeComposited,
|
|
9490
|
+
expand,
|
|
9491
|
+
crop: crop2,
|
|
9492
|
+
sourceImage,
|
|
9493
|
+
...inputs
|
|
9494
|
+
} = brief;
|
|
9298
9495
|
return inputs;
|
|
9299
9496
|
}
|
|
9300
9497
|
async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
|
|
@@ -9325,6 +9522,9 @@ function buildServer(opts) {
|
|
|
9325
9522
|
);
|
|
9326
9523
|
const inheritedDirectives = [];
|
|
9327
9524
|
let inheritedMark = false;
|
|
9525
|
+
let inheritedRef = false;
|
|
9526
|
+
const inheritedProduct = inheritedTokens.some((t) => t.t === "product");
|
|
9527
|
+
const inheritedPerson = inheritedTokens.some((t) => t.t === "character");
|
|
9328
9528
|
for (const tok of inheritedTokens) {
|
|
9329
9529
|
if (tok.t === "product") {
|
|
9330
9530
|
const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
|
|
@@ -9343,8 +9543,12 @@ function buildServer(opts) {
|
|
|
9343
9543
|
} else if (tok.t === "mark" && !inheritedMark) {
|
|
9344
9544
|
inheritedMark = true;
|
|
9345
9545
|
inheritedDirectives.push(markEditDirective());
|
|
9546
|
+
} else if (tok.t === "ref" && !inheritedRef) {
|
|
9547
|
+
inheritedRef = true;
|
|
9548
|
+
inheritedDirectives.push(inheritedRefDirective());
|
|
9346
9549
|
}
|
|
9347
9550
|
}
|
|
9551
|
+
if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
|
|
9348
9552
|
const compiled2 = compileBrief(brief, {
|
|
9349
9553
|
brand: brandJson,
|
|
9350
9554
|
images: core.images,
|
|
@@ -9354,7 +9558,11 @@ function buildServer(opts) {
|
|
|
9354
9558
|
mode: "edit",
|
|
9355
9559
|
editScope: verdict.scope,
|
|
9356
9560
|
editRemoval: verdict.removal ?? false,
|
|
9357
|
-
|
|
9561
|
+
// Kinds, not a count: the identity claim speaks only about the kinds
|
|
9562
|
+
// that actually ride. A mark-only or ref-only inheritance emits no
|
|
9563
|
+
// generic claim - markEditDirective and inheritedRefDirective speak
|
|
9564
|
+
// for themselves.
|
|
9565
|
+
inheritedIdentity: inheritedProduct || inheritedPerson ? { product: inheritedProduct, person: inheritedPerson } : false,
|
|
9358
9566
|
inheritedDirectives,
|
|
9359
9567
|
// Only the explicit op drops the dimension promise: an implicit legacy
|
|
9360
9568
|
// expansion keeps its historical prompt byte for byte.
|
|
@@ -9365,7 +9573,7 @@ function buildServer(opts) {
|
|
|
9365
9573
|
if (inheritedTokens.length) {
|
|
9366
9574
|
const identity = compileBrief(
|
|
9367
9575
|
{ tokens: inheritedTokens },
|
|
9368
|
-
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
|
|
9576
|
+
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById, mode: "edit" }
|
|
9369
9577
|
);
|
|
9370
9578
|
identityWarnings = identity.warnings;
|
|
9371
9579
|
const productAngles = /* @__PURE__ */ new Map();
|
|
@@ -9520,11 +9728,11 @@ function buildServer(opts) {
|
|
|
9520
9728
|
const out = [];
|
|
9521
9729
|
for (const h of images) {
|
|
9522
9730
|
const buf = core.images.read(h);
|
|
9523
|
-
const meta2 = await
|
|
9731
|
+
const meta2 = await sharp20(buf).metadata().catch(() => null);
|
|
9524
9732
|
if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
|
|
9525
9733
|
const oriented = (meta2.orientation ?? 1) !== 1;
|
|
9526
9734
|
out.push(
|
|
9527
|
-
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await
|
|
9735
|
+
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
|
|
9528
9736
|
);
|
|
9529
9737
|
}
|
|
9530
9738
|
return out;
|
|
@@ -9536,7 +9744,7 @@ function buildServer(opts) {
|
|
|
9536
9744
|
const out = [];
|
|
9537
9745
|
for (const h of images) {
|
|
9538
9746
|
const buf = core.images.read(h);
|
|
9539
|
-
const meta2 = await
|
|
9747
|
+
const meta2 = await sharp20(buf).metadata();
|
|
9540
9748
|
if (!meta2.width || !meta2.height) {
|
|
9541
9749
|
out.push(h);
|
|
9542
9750
|
continue;
|
|
@@ -9549,7 +9757,7 @@ function buildServer(opts) {
|
|
|
9549
9757
|
}
|
|
9550
9758
|
const w = got > target ? Math.round(meta2.height * target) : meta2.width;
|
|
9551
9759
|
const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
|
|
9552
|
-
const cropped = await
|
|
9760
|
+
const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
9553
9761
|
app.log.info(
|
|
9554
9762
|
{ nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
|
|
9555
9763
|
"canvas: cropped a drifted frame to the asked ratio"
|
|
@@ -9568,7 +9776,7 @@ function buildServer(opts) {
|
|
|
9568
9776
|
async function assertAspect(images, expect) {
|
|
9569
9777
|
const want = expect.width / expect.height;
|
|
9570
9778
|
for (const h of images) {
|
|
9571
|
-
const meta2 = await
|
|
9779
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9572
9780
|
if (!meta2.width || !meta2.height) continue;
|
|
9573
9781
|
const got = meta2.width / meta2.height;
|
|
9574
9782
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -9600,7 +9808,7 @@ function buildServer(opts) {
|
|
|
9600
9808
|
try {
|
|
9601
9809
|
const sizes = [];
|
|
9602
9810
|
for (const h of result.images) {
|
|
9603
|
-
const meta2 = await
|
|
9811
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9604
9812
|
if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
|
|
9605
9813
|
}
|
|
9606
9814
|
const node = core.store.getNode(nodeId);
|
|
@@ -9667,7 +9875,7 @@ function buildServer(opts) {
|
|
|
9667
9875
|
if (!srcHash || !core.images.has(String(srcHash)))
|
|
9668
9876
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
9669
9877
|
const srcBuf = core.images.read(String(srcHash));
|
|
9670
|
-
const srcMeta = await
|
|
9878
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
9671
9879
|
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
9672
9880
|
const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
|
|
9673
9881
|
if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
|
|
@@ -9690,7 +9898,7 @@ function buildServer(opts) {
|
|
|
9690
9898
|
crop: window
|
|
9691
9899
|
});
|
|
9692
9900
|
const work2 = async () => ({
|
|
9693
|
-
images: [core.images.save(await
|
|
9901
|
+
images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
|
|
9694
9902
|
costUsd: 0
|
|
9695
9903
|
});
|
|
9696
9904
|
void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
@@ -9712,6 +9920,9 @@ function buildServer(opts) {
|
|
|
9712
9920
|
let inheritedTokens = [];
|
|
9713
9921
|
let mergedEdit = null;
|
|
9714
9922
|
let editScope = "global";
|
|
9923
|
+
let gradeOnlyAsk = false;
|
|
9924
|
+
let budgetSourceHash;
|
|
9925
|
+
let sentSize;
|
|
9715
9926
|
const extraWarnings = [];
|
|
9716
9927
|
let expandPlan = null;
|
|
9717
9928
|
let expandSourceHash = null;
|
|
@@ -9726,6 +9937,9 @@ function buildServer(opts) {
|
|
|
9726
9937
|
inheritedTokens = edit.inheritedTokens;
|
|
9727
9938
|
mergedEdit = edit.merged;
|
|
9728
9939
|
editScope = edit.editScope;
|
|
9940
|
+
gradeOnlyAsk = editScope === "global" && isGradeOnlyInstruction(
|
|
9941
|
+
(brief?.tokens ?? []).filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
9942
|
+
);
|
|
9729
9943
|
extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
|
|
9730
9944
|
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
9731
9945
|
return reply.status(400).send({ error: "the brief is empty" });
|
|
@@ -9842,11 +10056,12 @@ function buildServer(opts) {
|
|
|
9842
10056
|
const editEdge = engine.capabilities().maxReferenceEdge;
|
|
9843
10057
|
if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
|
|
9844
10058
|
const srcBuf = core.images.read(String(srcHash));
|
|
9845
|
-
const srcMeta = await
|
|
10059
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
9846
10060
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
9847
10061
|
const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
|
|
9848
10062
|
const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
|
|
9849
|
-
const
|
|
10063
|
+
const briefNamesShape = (brief?.tokens ?? []).some((t) => t?.t === "format");
|
|
10064
|
+
const reshapeIntended = reshape === "extend" || reshape === void 0 && briefNamesShape && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
|
|
9850
10065
|
width: srcMeta.width,
|
|
9851
10066
|
height: srcMeta.height
|
|
9852
10067
|
});
|
|
@@ -9873,9 +10088,20 @@ function buildServer(opts) {
|
|
|
9873
10088
|
}
|
|
9874
10089
|
expectShape = { width: expandPlan.width, height: expandPlan.height };
|
|
9875
10090
|
}
|
|
10091
|
+
const editPixelBudget = runEngine.capabilities().editPixelBudget;
|
|
10092
|
+
if (!expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget) {
|
|
10093
|
+
sentSize = budgetSize(srcMeta.width, srcMeta.height, editPixelBudget);
|
|
10094
|
+
budgetSourceHash = core.images.save(
|
|
10095
|
+
await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
|
|
10096
|
+
);
|
|
10097
|
+
if (!gradeOnlyAsk)
|
|
10098
|
+
extraWarnings.push(
|
|
10099
|
+
`${runEngine.capabilities().displayName} refines at about ${(editPixelBudget / 1e6).toFixed(1)} megapixels, so this ${srcMeta.width}x${srcMeta.height} frame continues at ${sentSize.width}x${sentSize.height} from here on. The picture is unchanged; the stored size is now the size the engine truly drew.`
|
|
10100
|
+
);
|
|
10101
|
+
}
|
|
9876
10102
|
const editReq = {
|
|
9877
10103
|
instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
|
|
9878
|
-
sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
|
|
10104
|
+
sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
|
|
9879
10105
|
brand: ctx,
|
|
9880
10106
|
...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
|
|
9881
10107
|
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
|
|
@@ -9885,7 +10111,7 @@ function buildServer(opts) {
|
|
|
9885
10111
|
// engines given no size answered at whatever size they liked, the
|
|
9886
10112
|
// shrunken answer was stored, and the next refinement inherited it —
|
|
9887
10113
|
// the chain that walked shots down to thumbnails.
|
|
9888
|
-
...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
|
|
10114
|
+
...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : sentSize ? sentSize : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
|
|
9889
10115
|
// Only an engine that can genuinely paint a margin is told where the
|
|
9890
10116
|
// picture sits; the rest would ignore it anyway.
|
|
9891
10117
|
...expandPlan && canOutpaint2 ? {
|
|
@@ -9987,13 +10213,29 @@ function buildServer(opts) {
|
|
|
9987
10213
|
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
9988
10214
|
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
9989
10215
|
const enforceEditCanvas = async (images) => {
|
|
9990
|
-
const srcMeta = await
|
|
10216
|
+
const srcMeta = await sharp20(original).metadata();
|
|
9991
10217
|
if (!srcMeta.width || !srcMeta.height) return images;
|
|
9992
10218
|
const out = [];
|
|
9993
10219
|
for (const h of images) {
|
|
9994
|
-
const meta2 = await
|
|
10220
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9995
10221
|
const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
|
|
9996
|
-
const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got
|
|
10222
|
+
const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
|
|
10223
|
+
pixelBudget: runEngine.capabilities().editPixelBudget
|
|
10224
|
+
});
|
|
10225
|
+
if (verdict.action === "accept") {
|
|
10226
|
+
app.log.info(
|
|
10227
|
+
{ nodeId: node.id, got: `${got.width}x${got.height}`, src: `${srcMeta.width}x${srcMeta.height}` },
|
|
10228
|
+
"edit: kept the engine-native answer (pixel-budget step-down)"
|
|
10229
|
+
);
|
|
10230
|
+
try {
|
|
10231
|
+
const fresh = core.store.getNode(node.id);
|
|
10232
|
+
const b = fresh?.brief ?? {};
|
|
10233
|
+
core.store.setBrief(node.id, { ...b, steppedDown: [srcMeta.width, srcMeta.height] });
|
|
10234
|
+
} catch {
|
|
10235
|
+
}
|
|
10236
|
+
out.push(h);
|
|
10237
|
+
continue;
|
|
10238
|
+
}
|
|
9997
10239
|
if (verdict.action === "reject")
|
|
9998
10240
|
throw new Error(
|
|
9999
10241
|
`engine returned ${got.width}x${got.height} for a ${srcMeta.width}x${srcMeta.height} frame: too little of the picture came back to keep at this resolution`
|
|
@@ -10005,7 +10247,7 @@ function buildServer(opts) {
|
|
|
10005
10247
|
);
|
|
10006
10248
|
out.push(
|
|
10007
10249
|
core.images.save(
|
|
10008
|
-
await
|
|
10250
|
+
await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
|
|
10009
10251
|
)
|
|
10010
10252
|
);
|
|
10011
10253
|
try {
|
|
@@ -10027,7 +10269,7 @@ function buildServer(opts) {
|
|
|
10027
10269
|
const out = [];
|
|
10028
10270
|
for (const h of images) {
|
|
10029
10271
|
const answer = core.images.read(h);
|
|
10030
|
-
const got = await
|
|
10272
|
+
const got = await sharp20(answer).metadata();
|
|
10031
10273
|
if (got.width !== plan.width || got.height !== plan.height)
|
|
10032
10274
|
app.log.info(
|
|
10033
10275
|
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
@@ -10053,6 +10295,26 @@ function buildServer(opts) {
|
|
|
10053
10295
|
return images;
|
|
10054
10296
|
} : kind === "edit" && original ? async (images) => {
|
|
10055
10297
|
let staged = images;
|
|
10298
|
+
if (gradeOnlyAsk && !expandPlan) {
|
|
10299
|
+
const sent = budgetSourceHash ? core.images.read(budgetSourceHash) : original;
|
|
10300
|
+
const out = [];
|
|
10301
|
+
for (const h of staged) {
|
|
10302
|
+
const g = await gradeComposite(original, sent, core.images.read(h));
|
|
10303
|
+
if (g) {
|
|
10304
|
+
app.log.info(
|
|
10305
|
+
{ nodeId: node.id, residual: Number(g.residual.toFixed(2)) },
|
|
10306
|
+
"edit: shipped the original pixels wearing the grade"
|
|
10307
|
+
);
|
|
10308
|
+
out.push(core.images.save(g.image));
|
|
10309
|
+
try {
|
|
10310
|
+
const fresh = core.store.getNode(node.id);
|
|
10311
|
+
core.store.setBrief(node.id, { ...fresh?.brief ?? {}, gradeComposited: true });
|
|
10312
|
+
} catch {
|
|
10313
|
+
}
|
|
10314
|
+
} else out.push(h);
|
|
10315
|
+
}
|
|
10316
|
+
staged = out;
|
|
10317
|
+
}
|
|
10056
10318
|
if (localScope) {
|
|
10057
10319
|
const out = [];
|
|
10058
10320
|
for (const h of staged) {
|
|
@@ -10328,8 +10590,8 @@ async function verify() {
|
|
|
10328
10590
|
const db = new Database2(":memory:");
|
|
10329
10591
|
db.pragma("user_version");
|
|
10330
10592
|
db.close();
|
|
10331
|
-
const { default:
|
|
10332
|
-
await
|
|
10593
|
+
const { default: sharp21 } = await import('sharp');
|
|
10594
|
+
await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
10333
10595
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
10334
10596
|
} catch (err) {
|
|
10335
10597
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|