scenri 0.6.12 → 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 +38 -0
- package/dist/serve.js +572 -202
- package/package.json +1 -1
- package/studio-dist/assets/{index-BxndNQUR.css → index-B6onUYQF.css} +1 -1
- package/studio-dist/assets/index-pvL9RRJ6.js +103 -0
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-CloVtkXx.js +0 -103
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';
|
|
@@ -1285,7 +1285,7 @@ function createCatalogStore(db) {
|
|
|
1285
1285
|
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
|
-
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",
|
|
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, every character down to the smallest secondary lettering, in its original script and reading direction, never translated, transliterated or re-spelled; 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
1290
|
// case actually needs. It used to read "environment and light only - take no
|
|
1291
1291
|
// subject or person from it", which handed the model a photograph of a face
|
|
@@ -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"
|
|
@@ -1303,8 +1309,8 @@ var REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1303
1309
|
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
|
-
brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears, never redrawn
|
|
1307
|
-
scene: "a reference for environment and
|
|
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",
|
|
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
|
}
|
|
@@ -3216,23 +3231,111 @@ function mergeEditAttachments(own, inherited, cap2) {
|
|
|
3216
3231
|
const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
|
|
3217
3232
|
return allocateAttachments([...own, ...borrowed], cap2);
|
|
3218
3233
|
}
|
|
3234
|
+
function joinNames(labels) {
|
|
3235
|
+
const uniq = [...new Set(labels)];
|
|
3236
|
+
if (uniq.length <= 1) return uniq[0] ?? "";
|
|
3237
|
+
return `${uniq.slice(0, -1).join(", ")} and ${uniq[uniq.length - 1]}`;
|
|
3238
|
+
}
|
|
3239
|
+
function brandContext(core, brandId) {
|
|
3240
|
+
const brand = core.store.getBrand(brandId);
|
|
3241
|
+
if (!brand) throw new Error("brand not found");
|
|
3242
|
+
const assetPaths = {};
|
|
3243
|
+
const json = brand.json;
|
|
3244
|
+
for (const logo of json.logos ?? []) {
|
|
3245
|
+
const ref = String(logo.file ?? "");
|
|
3246
|
+
if (ref.startsWith("asset:") && core.images.has(ref.slice(6))) assetPaths[ref] = core.images.pathFor(ref.slice(6));
|
|
3247
|
+
}
|
|
3248
|
+
return { brand: brand.json, assetPaths };
|
|
3249
|
+
}
|
|
3250
|
+
var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
3251
|
+
var assetHash = (ref) => {
|
|
3252
|
+
const s = String(ref ?? "");
|
|
3253
|
+
return s.startsWith("asset:") ? s.slice(6) : null;
|
|
3254
|
+
};
|
|
3255
|
+
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
3256
|
+
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
3257
|
+
var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
|
|
3258
|
+
var COST_PROBE = {
|
|
3259
|
+
prompt: "",
|
|
3260
|
+
brand: { brand: {}, assetPaths: {} },
|
|
3261
|
+
width: 1024,
|
|
3262
|
+
height: 1024,
|
|
3263
|
+
count: 1
|
|
3264
|
+
};
|
|
3265
|
+
var MARK_MAX_EDGE = 2048;
|
|
3266
|
+
var MARK_MIN_EDGE = 1024;
|
|
3267
|
+
var MARK_TINY_EDGE = 256;
|
|
3268
|
+
var MARK_WARN_EDGE = 512;
|
|
3269
|
+
var toMarkPng = async (buf) => {
|
|
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();
|
|
3272
|
+
const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
|
|
3273
|
+
if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
|
|
3274
|
+
return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
|
|
3275
|
+
}
|
|
3276
|
+
return out;
|
|
3277
|
+
};
|
|
3278
|
+
var cappedRefs = /* @__PURE__ */ new Map();
|
|
3279
|
+
async function capReferenceEdge(core, path, maxEdge) {
|
|
3280
|
+
const key = `${path}#${maxEdge}`;
|
|
3281
|
+
const hit = cappedRefs.get(key);
|
|
3282
|
+
if (hit) return hit;
|
|
3283
|
+
let out = path;
|
|
3284
|
+
try {
|
|
3285
|
+
const meta = await sharp20(path).metadata();
|
|
3286
|
+
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
3287
|
+
const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3288
|
+
out = core.images.pathFor(core.images.save(buf));
|
|
3289
|
+
}
|
|
3290
|
+
} catch {
|
|
3291
|
+
}
|
|
3292
|
+
cappedRefs.set(key, out);
|
|
3293
|
+
return out;
|
|
3294
|
+
}
|
|
3295
|
+
var readImagePart = async (core, req, normalize2) => {
|
|
3296
|
+
const part = await req.file();
|
|
3297
|
+
if (!part) return { error: "multipart file field required" };
|
|
3298
|
+
const buf = await part.toBuffer();
|
|
3299
|
+
if (buf.length === 0) return { error: "empty file" };
|
|
3300
|
+
try {
|
|
3301
|
+
return { hash: core.images.save(await normalize2(buf)), fields: part.fields ?? {}, filename: part.filename };
|
|
3302
|
+
} catch {
|
|
3303
|
+
return { error: "that file is not an image we can read" };
|
|
3304
|
+
}
|
|
3305
|
+
};
|
|
3306
|
+
var mtimeQS = (path) => existsSync(path) ? `?v=${Math.round(statSync(path).mtimeMs)}` : "";
|
|
3307
|
+
var serveJpeg = (req, reply, path) => {
|
|
3308
|
+
const etag = `"${statSync(path).mtimeMs}"`;
|
|
3309
|
+
reply.header("cache-control", "public, max-age=31536000, immutable").header("etag", etag);
|
|
3310
|
+
if (req.headers["if-none-match"] === etag) return reply.status(304).send();
|
|
3311
|
+
return reply.header("content-type", "image/jpeg").send(readFileSync(path));
|
|
3312
|
+
};
|
|
3219
3313
|
|
|
3220
3314
|
// src/briefDirectives.ts
|
|
3221
3315
|
function productFidelityDirective(attached) {
|
|
3222
3316
|
if (attached <= 1) {
|
|
3223
3317
|
return "The attached product image is the exact product: preserve its label, shape, colors and proportions faithfully, and do not redesign it. It is also the only view of this product that exists. Any face, side or detail not visible in it is unknown \u2014 keep those plain and consistent with the visible materials and color, and do not invent hardware, text, seams, closures, ornament or branding on them. Prefer a composition that shows the product from the view the reference gives.";
|
|
3224
3318
|
}
|
|
3225
|
-
return "The attached product images all show the exact
|
|
3319
|
+
return "The attached product images all show the exact product to feature: preserve its label, shape and proportions faithfully, do not redesign it, and never treat an extra image as an additional product. The first product image is the authority for its color, finish and material. Where another image differs in color or finish, it shows the same product in another colorway \u2014 never blend colorways, and render the one the first image shows. Any face not visible in them is unknown \u2014 keep it plain and consistent with the materials the first image shows, and do not invent detail on it. If the direction above explicitly asks for more than one colorway, that explicit request wins.";
|
|
3226
3320
|
}
|
|
3227
3321
|
function editPreservationDirective(scope, opts) {
|
|
3228
3322
|
if (scope === "local") {
|
|
3229
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." : "";
|
|
3230
|
-
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;
|
|
3231
3325
|
}
|
|
3232
|
-
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.";
|
|
3233
3336
|
}
|
|
3234
|
-
function
|
|
3235
|
-
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.";
|
|
3236
3339
|
}
|
|
3237
3340
|
function productFactDirectives(p) {
|
|
3238
3341
|
const out = [];
|
|
@@ -3241,6 +3344,10 @@ function productFactDirectives(p) {
|
|
|
3241
3344
|
const materials = p.materials ?? p.material;
|
|
3242
3345
|
if (materials) out.push(`Its materials and finish: ${materials}.`);
|
|
3243
3346
|
if (p.primaryColors) out.push(`Its actual colors: ${p.primaryColors}.`);
|
|
3347
|
+
if (Array.isArray(p.colorways) && p.colorways.length)
|
|
3348
|
+
out.push(
|
|
3349
|
+
`It is sold in these colorways: ${p.colorways.join(", ")}. The one in this shot is the colorway the first product image shows.`
|
|
3350
|
+
);
|
|
3244
3351
|
if (p.dimensions)
|
|
3245
3352
|
out.push(`Its real-world size is ${p.dimensions} \u2014 keep it at true scale relative to everything else in frame.`);
|
|
3246
3353
|
return out;
|
|
@@ -3257,8 +3364,11 @@ function productEditFidelityDirective(name) {
|
|
|
3257
3364
|
function characterEditIdentityDirective(name) {
|
|
3258
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.`;
|
|
3259
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
|
+
}
|
|
3260
3370
|
function markEditDirective() {
|
|
3261
|
-
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.";
|
|
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.";
|
|
3262
3372
|
}
|
|
3263
3373
|
function personSkinDirective() {
|
|
3264
3374
|
return "Every person in this photograph has real photographed skin: fine natural texture at pore scale, faint lines and natural asymmetry left intact, true-to-life proportions, never airbrushed, waxy, plastic or synthetic-looking. Light behaves physically on it: a hard flash or a specular key may genuinely shine on skin, but the surface underneath stays living skin in a photograph, never gloss, never a render. Any retouch, clarity or sharpness language in the direction above is about finish and focus, never a licence to smooth skin beyond what a professional photograph holds; if the direction above explicitly asks for a stylised or non-photographic treatment of the person, that explicit request wins.";
|
|
@@ -3296,7 +3406,7 @@ function sceneFigureDirectives(opts) {
|
|
|
3296
3406
|
// instruction to mutate the one real mark the user deliberately
|
|
3297
3407
|
// attached. Same shape as pairDirectives' packshot override - name the
|
|
3298
3408
|
// scope of the earlier rule, carry the exception in the same breath.
|
|
3299
|
-
(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." : "")
|
|
3409
|
+
(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 - every character, including the smallest lettering, in its original script - never redrawn, re-lettered, translated or fictionalised." : "")
|
|
3300
3410
|
);
|
|
3301
3411
|
}
|
|
3302
3412
|
return out;
|
|
@@ -3357,7 +3467,7 @@ function markLabel(brand, logo) {
|
|
|
3357
3467
|
|
|
3358
3468
|
// src/brief.ts
|
|
3359
3469
|
var PRODUCT_REF_MAX = 3;
|
|
3360
|
-
var CHARACTER_REF_MAX =
|
|
3470
|
+
var CHARACTER_REF_MAX = 3;
|
|
3361
3471
|
var SCENE_REF_MAX = 1;
|
|
3362
3472
|
var FORMATS = [
|
|
3363
3473
|
{ id: "square", label: "Square 1:1", w: 1024, h: 1024 },
|
|
@@ -3365,7 +3475,7 @@ var FORMATS = [
|
|
|
3365
3475
|
{ id: "landscape", label: "Landscape 16:9", w: 1600, h: 900 },
|
|
3366
3476
|
{ id: "portrait", label: "Portrait 4:5", w: 1024, h: 1280 }
|
|
3367
3477
|
];
|
|
3368
|
-
var
|
|
3478
|
+
var assetHash2 = (ref) => {
|
|
3369
3479
|
const s = String(ref ?? "");
|
|
3370
3480
|
return s.startsWith("asset:") ? s.slice(6) : null;
|
|
3371
3481
|
};
|
|
@@ -3414,6 +3524,7 @@ function validateBrief(brief) {
|
|
|
3414
3524
|
function compileBrief(brief, ctx) {
|
|
3415
3525
|
const warnings = [];
|
|
3416
3526
|
const attachments = [];
|
|
3527
|
+
const rawSceneFallback = [];
|
|
3417
3528
|
const productDirectives = [];
|
|
3418
3529
|
const personDirectives = [];
|
|
3419
3530
|
const otherDirectives = [];
|
|
@@ -3428,6 +3539,9 @@ function compileBrief(brief, ctx) {
|
|
|
3428
3539
|
const append = (s) => {
|
|
3429
3540
|
sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
|
|
3430
3541
|
};
|
|
3542
|
+
const attachingMarkHashes = new Set(
|
|
3543
|
+
brief.tokens.filter((t) => t?.t === "mark").map((t) => t.imageHash).filter((h) => (ctx.brand?.logos ?? []).some((l) => assetHash2(l?.file) === h) && ctx.images.has(h))
|
|
3544
|
+
);
|
|
3431
3545
|
for (const tok of brief.tokens) {
|
|
3432
3546
|
switch (tok.t) {
|
|
3433
3547
|
case "text":
|
|
@@ -3446,7 +3560,7 @@ function compileBrief(brief, ctx) {
|
|
|
3446
3560
|
const phashes = [];
|
|
3447
3561
|
for (const s of orderedShots) {
|
|
3448
3562
|
if (phashes.length >= PRODUCT_REF_MAX) break;
|
|
3449
|
-
const h =
|
|
3563
|
+
const h = assetHash2(s?.file);
|
|
3450
3564
|
if (h && ctx.images.has(h) && !phashes.includes(h)) phashes.push(h);
|
|
3451
3565
|
}
|
|
3452
3566
|
if (phashes.length) {
|
|
@@ -3472,7 +3586,7 @@ function compileBrief(brief, ctx) {
|
|
|
3472
3586
|
}
|
|
3473
3587
|
hasPerson = true;
|
|
3474
3588
|
append(c.promptName ?? c.name);
|
|
3475
|
-
const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) =>
|
|
3589
|
+
const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => assetHash2(s?.file)).filter((h) => !!h && ctx.images.has(h));
|
|
3476
3590
|
if (chashes.length) {
|
|
3477
3591
|
chashes.forEach((chash, i) => {
|
|
3478
3592
|
attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
|
|
@@ -3500,6 +3614,10 @@ function compileBrief(brief, ctx) {
|
|
|
3500
3614
|
break;
|
|
3501
3615
|
}
|
|
3502
3616
|
case "ref": {
|
|
3617
|
+
if (attachingMarkHashes.has(tok.imageHash)) {
|
|
3618
|
+
warnings.push("That reference is the same image as your brand mark, so it rides once, as the mark.");
|
|
3619
|
+
break;
|
|
3620
|
+
}
|
|
3503
3621
|
if (!ctx.images.has(tok.imageHash)) {
|
|
3504
3622
|
warnings.push("A reference shot is missing and was skipped.");
|
|
3505
3623
|
break;
|
|
@@ -3510,14 +3628,23 @@ function compileBrief(brief, ctx) {
|
|
|
3510
3628
|
}
|
|
3511
3629
|
case "mark": {
|
|
3512
3630
|
const logos = ctx.brand?.logos ?? [];
|
|
3513
|
-
const logo = logos.find((l) =>
|
|
3631
|
+
const logo = logos.find((l) => assetHash2(l?.file) === tok.imageHash);
|
|
3514
3632
|
if (!logo || !ctx.images.has(tok.imageHash)) {
|
|
3515
3633
|
warnings.push("A brand mark in this brief is no longer in the kit.");
|
|
3516
3634
|
break;
|
|
3517
3635
|
}
|
|
3518
3636
|
attachments.push({ role: "brand", label: markLabel(ctx.brand, logo), hash: tok.imageHash });
|
|
3637
|
+
try {
|
|
3638
|
+
const head = ctx.images.read(tok.imageHash);
|
|
3639
|
+
const edge = Math.max(head.readUInt32BE(16), head.readUInt32BE(20));
|
|
3640
|
+
if (edge < MARK_WARN_EDGE)
|
|
3641
|
+
warnings.push(
|
|
3642
|
+
`${markLabel(ctx.brand, logo)} is only ${edge}px across, so fine lettering will not survive generation. Export it larger, or as SVG.`
|
|
3643
|
+
);
|
|
3644
|
+
} catch {
|
|
3645
|
+
}
|
|
3519
3646
|
otherDirectives.push(
|
|
3520
|
-
"The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Otherwise take only its colour and treatment from it."
|
|
3647
|
+
"The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Every character it carries appears intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled. Otherwise take only its colour and treatment from it."
|
|
3521
3648
|
);
|
|
3522
3649
|
break;
|
|
3523
3650
|
}
|
|
@@ -3534,10 +3661,14 @@ function compileBrief(brief, ctx) {
|
|
|
3534
3661
|
inlineTemplates.push(t);
|
|
3535
3662
|
append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
|
|
3536
3663
|
if (ctx.mode !== "edit" && t.figure) {
|
|
3537
|
-
|
|
3538
|
-
|
|
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) {
|
|
3539
3668
|
if (h && ctx.images.has(h)) {
|
|
3540
|
-
|
|
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);
|
|
3541
3672
|
}
|
|
3542
3673
|
}
|
|
3543
3674
|
}
|
|
@@ -3588,6 +3719,12 @@ function compileBrief(brief, ctx) {
|
|
|
3588
3719
|
}
|
|
3589
3720
|
const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
|
|
3590
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
|
+
}
|
|
3591
3728
|
const max = ctx.engineCaps.maxReferenceImages;
|
|
3592
3729
|
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
3593
3730
|
const guard = scene ? sceneGuardDirectives({
|
|
@@ -3621,7 +3758,7 @@ function compileBrief(brief, ctx) {
|
|
|
3621
3758
|
const brandLines = brandRuleDirectives(ctx.brand);
|
|
3622
3759
|
const preservation = ctx.mode === "edit" ? [
|
|
3623
3760
|
...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
|
|
3624
|
-
...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
|
|
3761
|
+
...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
|
|
3625
3762
|
...ctx.inheritedDirectives ?? []
|
|
3626
3763
|
] : [];
|
|
3627
3764
|
const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
|
|
@@ -3629,6 +3766,7 @@ function compileBrief(brief, ctx) {
|
|
|
3629
3766
|
const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
|
|
3630
3767
|
return String(rec?.category ?? "").toLowerCase() === "apparel";
|
|
3631
3768
|
}) ? [garmentDisplayDirective()] : [];
|
|
3769
|
+
const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
|
|
3632
3770
|
const allDirectives = [
|
|
3633
3771
|
...productDirectives,
|
|
3634
3772
|
...personDirectives,
|
|
@@ -3640,15 +3778,21 @@ function compileBrief(brief, ctx) {
|
|
|
3640
3778
|
...apparelUnworn,
|
|
3641
3779
|
...brandLines,
|
|
3642
3780
|
...guard,
|
|
3781
|
+
...refGuard,
|
|
3643
3782
|
...preservation
|
|
3644
3783
|
];
|
|
3645
3784
|
if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
|
|
3646
3785
|
if (dropped.some((d) => d.role !== "scene")) {
|
|
3647
|
-
const
|
|
3648
|
-
const
|
|
3649
|
-
|
|
3650
|
-
`${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)
|
|
3651
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
|
+
}
|
|
3652
3796
|
}
|
|
3653
3797
|
return {
|
|
3654
3798
|
prompt: prompt.trim(),
|
|
@@ -4334,6 +4478,7 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4334
4478
|
if (!palette.primary) warnings.push("No confident palette found. Set colors manually.");
|
|
4335
4479
|
let logoRef;
|
|
4336
4480
|
let logoFromOg = false;
|
|
4481
|
+
let logoTiny = false;
|
|
4337
4482
|
let iconHref = $('link[rel="apple-touch-icon"]').attr("href") || $('link[rel~="icon"]').attr("href");
|
|
4338
4483
|
if (!iconHref) {
|
|
4339
4484
|
iconHref = $('meta[property="og:image"]').attr("content");
|
|
@@ -4344,7 +4489,18 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4344
4489
|
const iconRes = await fetchImpl(new URL(iconHref, origin).toString(), { redirect: "follow" });
|
|
4345
4490
|
if (iconRes.ok) {
|
|
4346
4491
|
const buf = Buffer.from(await iconRes.arrayBuffer());
|
|
4347
|
-
if (buf.length > 0)
|
|
4492
|
+
if (buf.length > 0) {
|
|
4493
|
+
logoRef = await opts.saveAsset(buf, "logo");
|
|
4494
|
+
if (!logoFromOg && opts.probeLongEdge) {
|
|
4495
|
+
const edge = await opts.probeLongEdge(buf).catch(() => null);
|
|
4496
|
+
if (edge !== null && edge < 256) {
|
|
4497
|
+
logoTiny = true;
|
|
4498
|
+
warnings.push(
|
|
4499
|
+
`The site icon is favicon-sized (${edge}px), so it was saved as an alternate mark, not the logo. Upload your real logo in Settings.`
|
|
4500
|
+
);
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4348
4504
|
}
|
|
4349
4505
|
} catch {
|
|
4350
4506
|
warnings.push("Logo download failed.");
|
|
@@ -4373,7 +4529,7 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4373
4529
|
...palette.neutrals.length ? { neutrals: palette.neutrals.map((hex) => ({ hex })) } : {}
|
|
4374
4530
|
}
|
|
4375
4531
|
} : {},
|
|
4376
|
-
...logoRef ? { logos: [{ role: logoFromOg ? "alternate" : "primary", file: logoRef }] } : {}
|
|
4532
|
+
...logoRef ? { logos: [{ role: logoFromOg || logoTiny ? "alternate" : "primary", file: logoRef }] } : {}
|
|
4377
4533
|
};
|
|
4378
4534
|
return { brand, warnings };
|
|
4379
4535
|
}
|
|
@@ -5645,8 +5801,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
5645
5801
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
5646
5802
|
return;
|
|
5647
5803
|
}
|
|
5648
|
-
const png = await
|
|
5649
|
-
const meta = await
|
|
5804
|
+
const png = await sharp20(buf).rotate().png().toBuffer();
|
|
5805
|
+
const meta = await sharp20(png).metadata();
|
|
5650
5806
|
const hash = core.images.save(png);
|
|
5651
5807
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
5652
5808
|
width: meta.width,
|
|
@@ -5703,24 +5859,54 @@ function resolveLibraryProduct(core, brandId, productId) {
|
|
|
5703
5859
|
const library = core.catalog.listLibraryProducts(brandId, brand.json);
|
|
5704
5860
|
return library.find((p) => p.id === productId) ?? null;
|
|
5705
5861
|
}
|
|
5862
|
+
function stripHtml(html) {
|
|
5863
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/\s+/g, " ").trim();
|
|
5864
|
+
}
|
|
5865
|
+
function firstSentence(text, max) {
|
|
5866
|
+
if (text.length <= max) return text;
|
|
5867
|
+
const stop = text.slice(0, max + 1).search(/[.!?](\s|$)/);
|
|
5868
|
+
if (stop > 40) return text.slice(0, stop + 1);
|
|
5869
|
+
const cut = text.lastIndexOf(" ", max);
|
|
5870
|
+
return `${text.slice(0, cut > 40 ? cut : max).trimEnd()}\u2026`;
|
|
5871
|
+
}
|
|
5872
|
+
function colorwaysOf(variants) {
|
|
5873
|
+
const seen = [];
|
|
5874
|
+
for (const v of variants ?? []) {
|
|
5875
|
+
const key = Object.keys(v.options ?? {}).find((k) => /colou?r/i.test(k));
|
|
5876
|
+
const val = key ? String(v.options[key]).trim() : "";
|
|
5877
|
+
if (val && !seen.includes(val)) seen.push(val);
|
|
5878
|
+
if (seen.length >= 12) break;
|
|
5879
|
+
}
|
|
5880
|
+
return seen.length >= 2 ? seen : [];
|
|
5881
|
+
}
|
|
5706
5882
|
function brandJsonWithCatalogProducts(core, brandId) {
|
|
5707
5883
|
const brand = core.store.getBrand(brandId);
|
|
5708
5884
|
if (!brand) return null;
|
|
5709
5885
|
const json = { ...brand.json };
|
|
5710
5886
|
const library = core.catalog.listLibraryProducts(brandId, brand.json);
|
|
5711
|
-
json.products = library.map((p) =>
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5887
|
+
json.products = library.map((p) => {
|
|
5888
|
+
const colorways = colorwaysOf(p.variants);
|
|
5889
|
+
return {
|
|
5890
|
+
id: p.id,
|
|
5891
|
+
name: p.name,
|
|
5892
|
+
shots: p.shots,
|
|
5893
|
+
notes: p.url ?? void 0,
|
|
5894
|
+
// Forwarded so the compiler can state real-world material and size. These
|
|
5895
|
+
// were dropped here, which is part of why a watch could render plate-sized:
|
|
5896
|
+
// nothing downstream ever knew how big the object actually is.
|
|
5897
|
+
...p.category ? { category: p.category } : {},
|
|
5898
|
+
...p.variant ? { variant: p.variant } : {},
|
|
5899
|
+
...p.material ? { material: p.material } : {},
|
|
5900
|
+
...p.dimensions ? { dimensions: p.dimensions } : {},
|
|
5901
|
+
// The store's own words, the way the 0.6.9 fix gave demo products
|
|
5902
|
+
// theirs: the description is what anchors scale when dimensions are
|
|
5903
|
+
// absent, and imported products shipped with neither.
|
|
5904
|
+
...p.descriptionHtml ? { description: firstSentence(stripHtml(String(p.descriptionHtml)), 300) } : {},
|
|
5905
|
+
// The declared colorways, so the compiler can say a colour difference
|
|
5906
|
+
// between references is a colorway rather than lighting.
|
|
5907
|
+
...colorways.length ? { colorways } : {}
|
|
5908
|
+
};
|
|
5909
|
+
});
|
|
5724
5910
|
return json;
|
|
5725
5911
|
}
|
|
5726
5912
|
function runningImportCount() {
|
|
@@ -6065,7 +6251,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
6065
6251
|
return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
|
|
6066
6252
|
}
|
|
6067
6253
|
async function edgeBarGeometry(buf) {
|
|
6068
|
-
const { data, info } = await
|
|
6254
|
+
const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
6069
6255
|
const W = info.width;
|
|
6070
6256
|
const H = info.height;
|
|
6071
6257
|
const scan = (len, cross, at) => {
|
|
@@ -6119,7 +6305,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
6119
6305
|
const width = g.right - g.left + 1;
|
|
6120
6306
|
const height = g.bottom - g.top + 1;
|
|
6121
6307
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
6122
|
-
const png = await
|
|
6308
|
+
const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
6123
6309
|
return core.images.save(png);
|
|
6124
6310
|
} catch {
|
|
6125
6311
|
return hash;
|
|
@@ -6161,12 +6347,12 @@ async function avatarCrop(core, hash) {
|
|
|
6161
6347
|
);
|
|
6162
6348
|
}
|
|
6163
6349
|
async function figureBox(buf) {
|
|
6164
|
-
const meta = await
|
|
6350
|
+
const meta = await sharp20(buf).metadata();
|
|
6165
6351
|
const W = meta.width ?? 0;
|
|
6166
6352
|
const H = meta.height ?? 0;
|
|
6167
6353
|
if (!W || !H) return null;
|
|
6168
6354
|
for (const threshold of FIGURE_TRIM_THRESHOLDS) {
|
|
6169
|
-
const { info } = await
|
|
6355
|
+
const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
|
|
6170
6356
|
const left = Math.abs(info.trimOffsetLeft ?? 0);
|
|
6171
6357
|
const top = Math.abs(info.trimOffsetTop ?? 0);
|
|
6172
6358
|
const width = info.width ?? 0;
|
|
@@ -6199,13 +6385,13 @@ async function smartCover(core, hash, box) {
|
|
|
6199
6385
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6200
6386
|
try {
|
|
6201
6387
|
const buf = core.images.read(hash);
|
|
6202
|
-
const meta = await
|
|
6388
|
+
const meta = await sharp20(buf).metadata();
|
|
6203
6389
|
const w = meta.width ?? 0;
|
|
6204
6390
|
const h = meta.height ?? 0;
|
|
6205
6391
|
if (!w || !h) return void 0;
|
|
6206
6392
|
const raw = box(w, h);
|
|
6207
6393
|
const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
|
|
6208
|
-
const png = await
|
|
6394
|
+
const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
6209
6395
|
return core.images.save(png);
|
|
6210
6396
|
} catch {
|
|
6211
6397
|
return void 0;
|
|
@@ -6214,11 +6400,11 @@ async function smartCover(core, hash, box) {
|
|
|
6214
6400
|
async function crop(core, hash, region, cap2) {
|
|
6215
6401
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6216
6402
|
try {
|
|
6217
|
-
const meta = await
|
|
6403
|
+
const meta = await sharp20(core.images.read(hash)).metadata();
|
|
6218
6404
|
const w = meta.width ?? 0;
|
|
6219
6405
|
const h = meta.height ?? 0;
|
|
6220
6406
|
if (!w || !h) return void 0;
|
|
6221
|
-
let pipeline =
|
|
6407
|
+
let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
|
|
6222
6408
|
if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
|
|
6223
6409
|
const png = await pipeline.png().toBuffer();
|
|
6224
6410
|
return core.images.save(png);
|
|
@@ -6309,7 +6495,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
|
|
|
6309
6495
|
});
|
|
6310
6496
|
}
|
|
6311
6497
|
function scenePreviewPrompt(scene) {
|
|
6312
|
-
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.";
|
|
6313
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;
|
|
6314
6500
|
}
|
|
6315
6501
|
async function draw(deps, req) {
|
|
@@ -6402,6 +6588,7 @@ function registerAccessGuard(app, opts = {}) {
|
|
|
6402
6588
|
var MAX_HOPS = 64;
|
|
6403
6589
|
var IDENTITY_KINDS = /* @__PURE__ */ new Set(["product", "character", "mark", "ref"]);
|
|
6404
6590
|
var identityOf = (list2) => Array.isArray(list2) ? list2.filter((t) => IDENTITY_KINDS.has(t?.t)) : [];
|
|
6591
|
+
var identityTokenKey = (t) => t?.t === "product" ? `p:${t.id}` : JSON.stringify(t);
|
|
6405
6592
|
function inheritedIdentityTokens(parentId, getNode) {
|
|
6406
6593
|
let id = parentId;
|
|
6407
6594
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -6417,7 +6604,7 @@ function inheritedIdentityTokens(parentId, getNode) {
|
|
|
6417
6604
|
const seen = /* @__PURE__ */ new Set();
|
|
6418
6605
|
const tokens = [];
|
|
6419
6606
|
for (const t of [...own, ...carried]) {
|
|
6420
|
-
const key =
|
|
6607
|
+
const key = identityTokenKey(t);
|
|
6421
6608
|
if (seen.has(key)) continue;
|
|
6422
6609
|
seen.add(key);
|
|
6423
6610
|
tokens.push(t);
|
|
@@ -6479,6 +6666,119 @@ function scopeOfInstruction(text) {
|
|
|
6479
6666
|
if (!matched.length) return { scope: "global", matched: ["no local cue"] };
|
|
6480
6667
|
return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
|
|
6481
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
|
+
}
|
|
6482
6782
|
|
|
6483
6783
|
// src/expandRules.ts
|
|
6484
6784
|
var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
|
|
@@ -6545,13 +6845,20 @@ Avoid: new objects, products, people, text or watermarks; do not recompose, rece
|
|
|
6545
6845
|
// src/editSizeRules.ts
|
|
6546
6846
|
var SAME_SHAPE_TOL = 0.01;
|
|
6547
6847
|
var SHRINK_FLOOR = 0.8;
|
|
6548
|
-
|
|
6848
|
+
var BUDGET_TOL = 0.1;
|
|
6849
|
+
function judgeEditSize(src, got, opts) {
|
|
6549
6850
|
if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
|
|
6550
6851
|
if (got.width === src.width && got.height === src.height) return { action: "keep" };
|
|
6551
6852
|
const want = src.width / src.height;
|
|
6552
6853
|
const have = got.width / got.height;
|
|
6553
6854
|
if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
|
|
6554
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
|
+
}
|
|
6555
6862
|
if (scale < SHRINK_FLOOR) return { action: "reject", scale };
|
|
6556
6863
|
return { action: "resize", scale };
|
|
6557
6864
|
}
|
|
@@ -6570,7 +6877,7 @@ function planCrop(source, targetRatio) {
|
|
|
6570
6877
|
}
|
|
6571
6878
|
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
6572
6879
|
try {
|
|
6573
|
-
const { info } = await
|
|
6880
|
+
const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
6574
6881
|
const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
|
|
6575
6882
|
const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
|
|
6576
6883
|
const left = Math.round((attnLeft + plan.left) / 2);
|
|
@@ -6723,23 +7030,23 @@ function relax(grid, seam, fixedSweeps) {
|
|
|
6723
7030
|
|
|
6724
7031
|
// src/expand.ts
|
|
6725
7032
|
async function expandCanvas(source, plan) {
|
|
6726
|
-
const bed = await
|
|
6727
|
-
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();
|
|
6728
7035
|
}
|
|
6729
7036
|
async function compositeExpand(engineImage, source, plan) {
|
|
6730
|
-
const meta = await
|
|
7037
|
+
const meta = await sharp20(engineImage).metadata();
|
|
6731
7038
|
const want = plan.width / plan.height;
|
|
6732
7039
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
6733
7040
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
6734
7041
|
const aligned = sameOrientation;
|
|
6735
7042
|
const exact = meta.width === plan.width && meta.height === plan.height;
|
|
6736
|
-
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);
|
|
6737
7044
|
const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
|
|
6738
|
-
const image = await
|
|
7045
|
+
const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
6739
7046
|
return { image, aligned };
|
|
6740
7047
|
}
|
|
6741
7048
|
async function matchMarginsToSeam(surround, source, plan) {
|
|
6742
|
-
const src = await
|
|
7049
|
+
const src = await sharp20(source).metadata();
|
|
6743
7050
|
if (!src.width || !src.height) return surround;
|
|
6744
7051
|
const SW = src.width;
|
|
6745
7052
|
const SH = src.height;
|
|
@@ -6786,8 +7093,8 @@ var MAX_CORRECTION = 60;
|
|
|
6786
7093
|
async function reconcile(surround, source, side, axis) {
|
|
6787
7094
|
const { margin } = side;
|
|
6788
7095
|
if (margin.width < 1 || margin.height < 1) return surround;
|
|
6789
|
-
const marginRaw = await
|
|
6790
|
-
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();
|
|
6791
7098
|
const W = margin.width;
|
|
6792
7099
|
const H = margin.height;
|
|
6793
7100
|
const along = axis === "width" ? H : W;
|
|
@@ -6841,11 +7148,11 @@ async function reconcile(surround, source, side, axis) {
|
|
|
6841
7148
|
}
|
|
6842
7149
|
}
|
|
6843
7150
|
}
|
|
6844
|
-
const patch2 = await
|
|
6845
|
-
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();
|
|
6846
7153
|
}
|
|
6847
7154
|
async function expandCanvasBedOnly(source, plan) {
|
|
6848
|
-
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();
|
|
6849
7156
|
}
|
|
6850
7157
|
function medianOf(rgb, channel, from, to) {
|
|
6851
7158
|
const n = to - from;
|
|
@@ -6856,17 +7163,17 @@ function medianOf(rgb, channel, from, to) {
|
|
|
6856
7163
|
return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
|
|
6857
7164
|
}
|
|
6858
7165
|
async function reframeExpand(engineImage, plan) {
|
|
6859
|
-
const meta = await
|
|
7166
|
+
const meta = await sharp20(engineImage).metadata();
|
|
6860
7167
|
if (!(meta.width && meta.height)) return null;
|
|
6861
7168
|
const want = plan.width / plan.height;
|
|
6862
7169
|
const got = meta.width / meta.height;
|
|
6863
7170
|
if (got >= 1 !== want >= 1) return null;
|
|
6864
7171
|
if (meta.width === plan.width && meta.height === plan.height) return engineImage;
|
|
6865
7172
|
const straight = Math.abs(got - want) / want <= 0.02;
|
|
6866
|
-
return
|
|
7173
|
+
return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
|
|
6867
7174
|
}
|
|
6868
7175
|
async function seamScore(image, plan, source) {
|
|
6869
|
-
const { data, info } = await
|
|
7176
|
+
const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
6870
7177
|
const W = info.width;
|
|
6871
7178
|
const H = info.height;
|
|
6872
7179
|
const horizontal = plan.axis === "width";
|
|
@@ -6899,7 +7206,7 @@ var SEAM_VISIBLE = 2.2;
|
|
|
6899
7206
|
var OFFSET = 4;
|
|
6900
7207
|
var RESIDUAL_VISIBLE = 15;
|
|
6901
7208
|
async function seamResidual(image, plan, source) {
|
|
6902
|
-
const { data, info } = await
|
|
7209
|
+
const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
6903
7210
|
const W = info.width;
|
|
6904
7211
|
const H = info.height;
|
|
6905
7212
|
const ch = info.channels;
|
|
@@ -6934,7 +7241,7 @@ var MAX_SHARE = 0.8;
|
|
|
6934
7241
|
async function subjectFraction(src, source, axis) {
|
|
6935
7242
|
try {
|
|
6936
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)) };
|
|
6937
|
-
const { info } = await
|
|
7244
|
+
const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
6938
7245
|
const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
|
|
6939
7246
|
const span = axis === "width" ? source.width : source.height;
|
|
6940
7247
|
const extent = axis === "width" ? window.width : window.height;
|
|
@@ -6957,14 +7264,14 @@ function placeExpand(plan, source, fraction) {
|
|
|
6957
7264
|
}
|
|
6958
7265
|
var NEUTRAL = { r: 128, g: 128, b: 128 };
|
|
6959
7266
|
async function conditioningCanvas(source, plan, fill = "edge") {
|
|
6960
|
-
const meta = await
|
|
7267
|
+
const meta = await sharp20(source).metadata();
|
|
6961
7268
|
const sw = meta.width ?? 0;
|
|
6962
7269
|
const sh = meta.height ?? 0;
|
|
6963
7270
|
if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
|
|
6964
7271
|
const layers = [];
|
|
6965
7272
|
if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
|
|
6966
7273
|
layers.push({ input: source, left: plan.left, top: plan.top });
|
|
6967
|
-
const canvas =
|
|
7274
|
+
const canvas = sharp20({
|
|
6968
7275
|
create: {
|
|
6969
7276
|
width: plan.width,
|
|
6970
7277
|
height: plan.height,
|
|
@@ -6976,7 +7283,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
|
|
|
6976
7283
|
}
|
|
6977
7284
|
async function edgeMargins(source, plan, size) {
|
|
6978
7285
|
const out = [];
|
|
6979
|
-
const strip = async (extract, width, height) =>
|
|
7286
|
+
const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
|
|
6980
7287
|
if (plan.axis === "width") {
|
|
6981
7288
|
const before = plan.left;
|
|
6982
7289
|
const after = plan.width - plan.left - size.width;
|
|
@@ -7067,12 +7374,12 @@ async function resolveOutpaintRoute(all, shot) {
|
|
|
7067
7374
|
return { engine: shot, method: "reframe", crossed: false };
|
|
7068
7375
|
}
|
|
7069
7376
|
async function driftDiff(a, b) {
|
|
7070
|
-
const metaA = await
|
|
7071
|
-
const metaB = await
|
|
7377
|
+
const metaA = await sharp20(a).metadata();
|
|
7378
|
+
const metaB = await sharp20(b).metadata();
|
|
7072
7379
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
7073
7380
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
7074
7381
|
const [rawA, rawB] = await Promise.all(
|
|
7075
|
-
[a, b].map((buf) =>
|
|
7382
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
7076
7383
|
);
|
|
7077
7384
|
const out = new PNG({ width, height });
|
|
7078
7385
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -7084,11 +7391,11 @@ async function driftDiff(a, b) {
|
|
|
7084
7391
|
};
|
|
7085
7392
|
}
|
|
7086
7393
|
async function changeMask(a, b, cap2 = 1024) {
|
|
7087
|
-
const metaA = await
|
|
7394
|
+
const metaA = await sharp20(a).metadata();
|
|
7088
7395
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
7089
7396
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
7090
7397
|
const [rawA, rawB] = await Promise.all(
|
|
7091
|
-
[a, b].map((buf) =>
|
|
7398
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
7092
7399
|
);
|
|
7093
7400
|
const out = new PNG({ width, height });
|
|
7094
7401
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -7137,8 +7444,8 @@ function dilationFor(longEdge) {
|
|
|
7137
7444
|
// src/localEdit.ts
|
|
7138
7445
|
async function preserveOutsideChange(source, edited) {
|
|
7139
7446
|
try {
|
|
7140
|
-
const srcMeta = await
|
|
7141
|
-
const outMeta = await
|
|
7447
|
+
const srcMeta = await sharp20(source).metadata();
|
|
7448
|
+
const outMeta = await sharp20(edited).metadata();
|
|
7142
7449
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
7143
7450
|
return { image: edited, outcome: "error", changed: 0 };
|
|
7144
7451
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -7148,94 +7455,24 @@ async function preserveOutsideChange(source, edited) {
|
|
|
7148
7455
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
7149
7456
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
7150
7457
|
const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
|
|
7151
|
-
const spread = await
|
|
7152
|
-
const dilated = await
|
|
7153
|
-
const feathered = await
|
|
7154
|
-
const grown = await
|
|
7155
|
-
const editedRgb = await
|
|
7156
|
-
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, {
|
|
7157
7464
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
7158
7465
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
7159
|
-
const image = await
|
|
7466
|
+
const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
7160
7467
|
return { image, outcome: "composited", changed: shape.changed };
|
|
7161
7468
|
} catch {
|
|
7162
7469
|
return { image: edited, outcome: "error", changed: 0 };
|
|
7163
7470
|
}
|
|
7164
7471
|
}
|
|
7165
|
-
function joinNames(labels) {
|
|
7166
|
-
const uniq = [...new Set(labels)];
|
|
7167
|
-
if (uniq.length <= 1) return uniq[0] ?? "";
|
|
7168
|
-
return `${uniq.slice(0, -1).join(", ")} and ${uniq[uniq.length - 1]}`;
|
|
7169
|
-
}
|
|
7170
|
-
function brandContext(core, brandId) {
|
|
7171
|
-
const brand = core.store.getBrand(brandId);
|
|
7172
|
-
if (!brand) throw new Error("brand not found");
|
|
7173
|
-
const assetPaths = {};
|
|
7174
|
-
const json = brand.json;
|
|
7175
|
-
for (const logo of json.logos ?? []) {
|
|
7176
|
-
const ref = String(logo.file ?? "");
|
|
7177
|
-
if (ref.startsWith("asset:") && core.images.has(ref.slice(6))) assetPaths[ref] = core.images.pathFor(ref.slice(6));
|
|
7178
|
-
}
|
|
7179
|
-
return { brand: brand.json, assetPaths };
|
|
7180
|
-
}
|
|
7181
|
-
var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
7182
|
-
var assetHash2 = (ref) => {
|
|
7183
|
-
const s = String(ref ?? "");
|
|
7184
|
-
return s.startsWith("asset:") ? s.slice(6) : null;
|
|
7185
|
-
};
|
|
7186
|
-
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
7187
|
-
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
7188
|
-
var toPng = (buf) => sharp7(buf).rotate().png().toBuffer();
|
|
7189
|
-
var COST_PROBE = {
|
|
7190
|
-
prompt: "",
|
|
7191
|
-
brand: { brand: {}, assetPaths: {} },
|
|
7192
|
-
width: 1024,
|
|
7193
|
-
height: 1024,
|
|
7194
|
-
count: 1
|
|
7195
|
-
};
|
|
7196
|
-
var MARK_MAX_EDGE = 2048;
|
|
7197
|
-
var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
7198
|
-
var cappedRefs = /* @__PURE__ */ new Map();
|
|
7199
|
-
async function capReferenceEdge(core, path, maxEdge) {
|
|
7200
|
-
const key = `${path}#${maxEdge}`;
|
|
7201
|
-
const hit = cappedRefs.get(key);
|
|
7202
|
-
if (hit) return hit;
|
|
7203
|
-
let out = path;
|
|
7204
|
-
try {
|
|
7205
|
-
const meta = await sharp7(path).metadata();
|
|
7206
|
-
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
7207
|
-
const buf = await sharp7(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
7208
|
-
out = core.images.pathFor(core.images.save(buf));
|
|
7209
|
-
}
|
|
7210
|
-
} catch {
|
|
7211
|
-
}
|
|
7212
|
-
cappedRefs.set(key, out);
|
|
7213
|
-
return out;
|
|
7214
|
-
}
|
|
7215
|
-
var readImagePart = async (core, req, normalize2) => {
|
|
7216
|
-
const part = await req.file();
|
|
7217
|
-
if (!part) return { error: "multipart file field required" };
|
|
7218
|
-
const buf = await part.toBuffer();
|
|
7219
|
-
if (buf.length === 0) return { error: "empty file" };
|
|
7220
|
-
try {
|
|
7221
|
-
return { hash: core.images.save(await normalize2(buf)), fields: part.fields ?? {}, filename: part.filename };
|
|
7222
|
-
} catch {
|
|
7223
|
-
return { error: "that file is not an image we can read" };
|
|
7224
|
-
}
|
|
7225
|
-
};
|
|
7226
|
-
var mtimeQS = (path) => existsSync(path) ? `?v=${Math.round(statSync(path).mtimeMs)}` : "";
|
|
7227
|
-
var serveJpeg = (req, reply, path) => {
|
|
7228
|
-
const etag = `"${statSync(path).mtimeMs}"`;
|
|
7229
|
-
reply.header("cache-control", "public, max-age=31536000, immutable").header("etag", etag);
|
|
7230
|
-
if (req.headers["if-none-match"] === etag) return reply.status(304).send();
|
|
7231
|
-
return reply.header("content-type", "image/jpeg").send(readFileSync(path));
|
|
7232
|
-
};
|
|
7233
|
-
|
|
7234
|
-
// src/routes/logos.ts
|
|
7235
7472
|
function registerLogoRoutes(app, deps) {
|
|
7236
7473
|
const { core } = deps;
|
|
7237
7474
|
const readLogos = (json) => Array.isArray(json.logos) ? json.logos : [];
|
|
7238
|
-
const findLogo = (json, hash) => readLogos(json).findIndex((l) =>
|
|
7475
|
+
const findLogo = (json, hash) => readLogos(json).findIndex((l) => assetHash(l?.file) === hash);
|
|
7239
7476
|
const enumField = (raw, allowed, fallback) => {
|
|
7240
7477
|
const v = raw === void 0 || raw === null ? "" : String(raw);
|
|
7241
7478
|
if (!v) return fallback ?? "";
|
|
@@ -7248,7 +7485,7 @@ function registerLogoRoutes(app, deps) {
|
|
|
7248
7485
|
const logos = [...readLogos(json)];
|
|
7249
7486
|
const part = await readImagePart(core, req, toMarkPng);
|
|
7250
7487
|
if ("error" in part) return reply.status(400).send({ error: part.error });
|
|
7251
|
-
const existing = logos.findIndex((l) =>
|
|
7488
|
+
const existing = logos.findIndex((l) => assetHash(l?.file) === part.hash);
|
|
7252
7489
|
const role = enumField(
|
|
7253
7490
|
part.fields?.role?.value,
|
|
7254
7491
|
LOGO_ROLES,
|
|
@@ -7263,7 +7500,10 @@ function registerLogoRoutes(app, deps) {
|
|
|
7263
7500
|
json.logos = role === "primary" ? logos.map((l) => l !== entry && l?.role === "primary" ? { ...l, role: "alternate" } : l) : logos;
|
|
7264
7501
|
const v = validateBrand(json);
|
|
7265
7502
|
if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
|
|
7266
|
-
|
|
7503
|
+
const row = core.store.updateBrand(brand.id, json);
|
|
7504
|
+
const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
|
|
7505
|
+
const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
|
|
7506
|
+
return { ...row, logoHash: part.hash, logoEdge };
|
|
7267
7507
|
});
|
|
7268
7508
|
app.patch("/api/brands/:id/logos/:hash", async (req, reply) => {
|
|
7269
7509
|
const brand = core.store.getBrand(req.params.id);
|
|
@@ -7388,7 +7628,7 @@ async function vibrantColor(input) {
|
|
|
7388
7628
|
let data;
|
|
7389
7629
|
let channels;
|
|
7390
7630
|
try {
|
|
7391
|
-
const out = await
|
|
7631
|
+
const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
7392
7632
|
data = out.data;
|
|
7393
7633
|
channels = out.info.channels;
|
|
7394
7634
|
} catch {
|
|
@@ -7411,7 +7651,7 @@ async function vibrantColor(input) {
|
|
|
7411
7651
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
7412
7652
|
if (best.score <= 0) {
|
|
7413
7653
|
try {
|
|
7414
|
-
const { dominant } = await
|
|
7654
|
+
const { dominant } = await sharp20(input).stats();
|
|
7415
7655
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
7416
7656
|
} catch {
|
|
7417
7657
|
return null;
|
|
@@ -7795,10 +8035,11 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
7795
8035
|
core.ledger.recordCost(engineId, null, result.costUsd);
|
|
7796
8036
|
const hash = result.images[0];
|
|
7797
8037
|
if (!hash) return reply.status(500).send({ error: "the engine returned no image" });
|
|
8038
|
+
const trimmed = await trimEdgeBars(core, hash);
|
|
7798
8039
|
commit(core, brand.id, (json) => {
|
|
7799
|
-
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);
|
|
7800
8041
|
});
|
|
7801
|
-
return { preview: `asset:${
|
|
8042
|
+
return { preview: `asset:${trimmed}`, brand: core.store.getBrand(brand.id) };
|
|
7802
8043
|
});
|
|
7803
8044
|
}
|
|
7804
8045
|
async function withDerivedCrops(core, body, base) {
|
|
@@ -8029,7 +8270,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
8029
8270
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
8030
8271
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
8031
8272
|
for (const p of chosen) {
|
|
8032
|
-
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;
|
|
8033
8274
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
8034
8275
|
}
|
|
8035
8276
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -8190,7 +8431,8 @@ function registerImageRoutes(app, deps) {
|
|
|
8190
8431
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
8191
8432
|
const buf = await part.toBuffer();
|
|
8192
8433
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
8193
|
-
const
|
|
8434
|
+
const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
|
|
8435
|
+
const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
|
|
8194
8436
|
return { hash: core.images.save(png) };
|
|
8195
8437
|
});
|
|
8196
8438
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -8225,6 +8467,43 @@ function registerImageRoutes(app, deps) {
|
|
|
8225
8467
|
|
|
8226
8468
|
// src/release/notes.data.ts
|
|
8227
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
|
+
},
|
|
8489
|
+
{
|
|
8490
|
+
version: "0.6.13",
|
|
8491
|
+
date: "2026-08-30",
|
|
8492
|
+
sections: [
|
|
8493
|
+
{
|
|
8494
|
+
heading: "Products",
|
|
8495
|
+
body: "A product sold in several colours renders the colour its first photo shows, instead of blending them. Imported products now carry their own description and colour list into every shot, so scale and colourway stay true."
|
|
8496
|
+
},
|
|
8497
|
+
{
|
|
8498
|
+
heading: "Brand",
|
|
8499
|
+
body: "Attach your own logo straight from the Brand tab when composing; it joins the kit and rides the shot. Small logo files are raised to a workable resolution, the smallest lettering and its script are now part of the contract, and a logo too small to survive says so before you spend."
|
|
8500
|
+
},
|
|
8501
|
+
{
|
|
8502
|
+
heading: "Create",
|
|
8503
|
+
body: "The refine strip counts each product once and names the scene a thread keeps. A retried shot carries your recipe, never leftovers from the previous run."
|
|
8504
|
+
}
|
|
8505
|
+
]
|
|
8506
|
+
},
|
|
8228
8507
|
{
|
|
8229
8508
|
version: "0.6.12",
|
|
8230
8509
|
date: "2026-08-29",
|
|
@@ -9002,6 +9281,12 @@ function buildServer(opts) {
|
|
|
9002
9281
|
// about its own format — broken in the marks grid, and mislabelled to any
|
|
9003
9282
|
// engine it is later attached to.
|
|
9004
9283
|
saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
|
|
9284
|
+
// Measured as stored (post-toMarkPng), so the scrape judges the same
|
|
9285
|
+
// pixels the compiler will one day attach.
|
|
9286
|
+
probeLongEdge: async (buf) => {
|
|
9287
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
9288
|
+
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
9289
|
+
},
|
|
9005
9290
|
createdWith: `${meta.name}/${meta.version}`
|
|
9006
9291
|
});
|
|
9007
9292
|
const row = core.store.createBrand(brand);
|
|
@@ -9087,6 +9372,10 @@ function buildServer(opts) {
|
|
|
9087
9372
|
const { brand: scraped, warnings } = await buildFromUrl(url, {
|
|
9088
9373
|
fetchImpl: opts.fetchImpl,
|
|
9089
9374
|
saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
|
|
9375
|
+
probeLongEdge: async (buf) => {
|
|
9376
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
9377
|
+
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
9378
|
+
},
|
|
9090
9379
|
createdWith: `${meta.name}/${meta.version}`
|
|
9091
9380
|
});
|
|
9092
9381
|
const { brand: merged, suggestions } = mergeScrape(brand.json, scraped);
|
|
@@ -9189,13 +9478,29 @@ function buildServer(opts) {
|
|
|
9189
9478
|
registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
|
|
9190
9479
|
registerShowcaseRoutes(app, { templatesRoot });
|
|
9191
9480
|
app.get("/api/formats", async () => FORMATS);
|
|
9481
|
+
function briefInputsOnly(brief) {
|
|
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;
|
|
9495
|
+
return inputs;
|
|
9496
|
+
}
|
|
9192
9497
|
async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
|
|
9193
9498
|
const inherited = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
|
|
9194
9499
|
const borrowed = inherited.tokens;
|
|
9195
9500
|
const already = new Set(
|
|
9196
|
-
brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map(
|
|
9501
|
+
brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map(identityTokenKey)
|
|
9197
9502
|
);
|
|
9198
|
-
const inheritedTokens = borrowed.filter((t) => !already.has(
|
|
9503
|
+
const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
|
|
9199
9504
|
const combined = [...brief.tokens, ...inheritedTokens];
|
|
9200
9505
|
const brandJson = await brandJsonWithResolvedPresenters(
|
|
9201
9506
|
core,
|
|
@@ -9217,6 +9522,9 @@ function buildServer(opts) {
|
|
|
9217
9522
|
);
|
|
9218
9523
|
const inheritedDirectives = [];
|
|
9219
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");
|
|
9220
9528
|
for (const tok of inheritedTokens) {
|
|
9221
9529
|
if (tok.t === "product") {
|
|
9222
9530
|
const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
|
|
@@ -9235,8 +9543,12 @@ function buildServer(opts) {
|
|
|
9235
9543
|
} else if (tok.t === "mark" && !inheritedMark) {
|
|
9236
9544
|
inheritedMark = true;
|
|
9237
9545
|
inheritedDirectives.push(markEditDirective());
|
|
9546
|
+
} else if (tok.t === "ref" && !inheritedRef) {
|
|
9547
|
+
inheritedRef = true;
|
|
9548
|
+
inheritedDirectives.push(inheritedRefDirective());
|
|
9238
9549
|
}
|
|
9239
9550
|
}
|
|
9551
|
+
if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
|
|
9240
9552
|
const compiled2 = compileBrief(brief, {
|
|
9241
9553
|
brand: brandJson,
|
|
9242
9554
|
images: core.images,
|
|
@@ -9246,7 +9558,11 @@ function buildServer(opts) {
|
|
|
9246
9558
|
mode: "edit",
|
|
9247
9559
|
editScope: verdict.scope,
|
|
9248
9560
|
editRemoval: verdict.removal ?? false,
|
|
9249
|
-
|
|
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,
|
|
9250
9566
|
inheritedDirectives,
|
|
9251
9567
|
// Only the explicit op drops the dimension promise: an implicit legacy
|
|
9252
9568
|
// expansion keeps its historical prompt byte for byte.
|
|
@@ -9257,7 +9573,7 @@ function buildServer(opts) {
|
|
|
9257
9573
|
if (inheritedTokens.length) {
|
|
9258
9574
|
const identity = compileBrief(
|
|
9259
9575
|
{ tokens: inheritedTokens },
|
|
9260
|
-
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
|
|
9576
|
+
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById, mode: "edit" }
|
|
9261
9577
|
);
|
|
9262
9578
|
identityWarnings = identity.warnings;
|
|
9263
9579
|
const productAngles = /* @__PURE__ */ new Map();
|
|
@@ -9412,11 +9728,11 @@ function buildServer(opts) {
|
|
|
9412
9728
|
const out = [];
|
|
9413
9729
|
for (const h of images) {
|
|
9414
9730
|
const buf = core.images.read(h);
|
|
9415
|
-
const meta2 = await
|
|
9731
|
+
const meta2 = await sharp20(buf).metadata().catch(() => null);
|
|
9416
9732
|
if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
|
|
9417
9733
|
const oriented = (meta2.orientation ?? 1) !== 1;
|
|
9418
9734
|
out.push(
|
|
9419
|
-
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())
|
|
9420
9736
|
);
|
|
9421
9737
|
}
|
|
9422
9738
|
return out;
|
|
@@ -9428,7 +9744,7 @@ function buildServer(opts) {
|
|
|
9428
9744
|
const out = [];
|
|
9429
9745
|
for (const h of images) {
|
|
9430
9746
|
const buf = core.images.read(h);
|
|
9431
|
-
const meta2 = await
|
|
9747
|
+
const meta2 = await sharp20(buf).metadata();
|
|
9432
9748
|
if (!meta2.width || !meta2.height) {
|
|
9433
9749
|
out.push(h);
|
|
9434
9750
|
continue;
|
|
@@ -9441,7 +9757,7 @@ function buildServer(opts) {
|
|
|
9441
9757
|
}
|
|
9442
9758
|
const w = got > target ? Math.round(meta2.height * target) : meta2.width;
|
|
9443
9759
|
const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
|
|
9444
|
-
const cropped = await
|
|
9760
|
+
const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
9445
9761
|
app.log.info(
|
|
9446
9762
|
{ nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
|
|
9447
9763
|
"canvas: cropped a drifted frame to the asked ratio"
|
|
@@ -9460,7 +9776,7 @@ function buildServer(opts) {
|
|
|
9460
9776
|
async function assertAspect(images, expect) {
|
|
9461
9777
|
const want = expect.width / expect.height;
|
|
9462
9778
|
for (const h of images) {
|
|
9463
|
-
const meta2 = await
|
|
9779
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9464
9780
|
if (!meta2.width || !meta2.height) continue;
|
|
9465
9781
|
const got = meta2.width / meta2.height;
|
|
9466
9782
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -9492,7 +9808,7 @@ function buildServer(opts) {
|
|
|
9492
9808
|
try {
|
|
9493
9809
|
const sizes = [];
|
|
9494
9810
|
for (const h of result.images) {
|
|
9495
|
-
const meta2 = await
|
|
9811
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9496
9812
|
if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
|
|
9497
9813
|
}
|
|
9498
9814
|
const node = core.store.getNode(nodeId);
|
|
@@ -9559,7 +9875,7 @@ function buildServer(opts) {
|
|
|
9559
9875
|
if (!srcHash || !core.images.has(String(srcHash)))
|
|
9560
9876
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
9561
9877
|
const srcBuf = core.images.read(String(srcHash));
|
|
9562
|
-
const srcMeta = await
|
|
9878
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
9563
9879
|
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
9564
9880
|
const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
|
|
9565
9881
|
if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
|
|
@@ -9576,13 +9892,13 @@ function buildServer(opts) {
|
|
|
9576
9892
|
engineId: "local"
|
|
9577
9893
|
});
|
|
9578
9894
|
core.store.setBrief(node2.id, {
|
|
9579
|
-
...brief ?? {},
|
|
9895
|
+
...briefInputsOnly(brief ?? {}),
|
|
9580
9896
|
sourceImage: String(srcHash),
|
|
9581
9897
|
reshape: "crop",
|
|
9582
9898
|
crop: window
|
|
9583
9899
|
});
|
|
9584
9900
|
const work2 = async () => ({
|
|
9585
|
-
images: [core.images.save(await
|
|
9901
|
+
images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
|
|
9586
9902
|
costUsd: 0
|
|
9587
9903
|
});
|
|
9588
9904
|
void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
@@ -9604,6 +9920,9 @@ function buildServer(opts) {
|
|
|
9604
9920
|
let inheritedTokens = [];
|
|
9605
9921
|
let mergedEdit = null;
|
|
9606
9922
|
let editScope = "global";
|
|
9923
|
+
let gradeOnlyAsk = false;
|
|
9924
|
+
let budgetSourceHash;
|
|
9925
|
+
let sentSize;
|
|
9607
9926
|
const extraWarnings = [];
|
|
9608
9927
|
let expandPlan = null;
|
|
9609
9928
|
let expandSourceHash = null;
|
|
@@ -9618,6 +9937,9 @@ function buildServer(opts) {
|
|
|
9618
9937
|
inheritedTokens = edit.inheritedTokens;
|
|
9619
9938
|
mergedEdit = edit.merged;
|
|
9620
9939
|
editScope = edit.editScope;
|
|
9940
|
+
gradeOnlyAsk = editScope === "global" && isGradeOnlyInstruction(
|
|
9941
|
+
(brief?.tokens ?? []).filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
9942
|
+
);
|
|
9621
9943
|
extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
|
|
9622
9944
|
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
9623
9945
|
return reply.status(400).send({ error: "the brief is empty" });
|
|
@@ -9734,11 +10056,12 @@ function buildServer(opts) {
|
|
|
9734
10056
|
const editEdge = engine.capabilities().maxReferenceEdge;
|
|
9735
10057
|
if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
|
|
9736
10058
|
const srcBuf = core.images.read(String(srcHash));
|
|
9737
|
-
const srcMeta = await
|
|
10059
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
9738
10060
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
9739
10061
|
const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
|
|
9740
10062
|
const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
|
|
9741
|
-
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, {
|
|
9742
10065
|
width: srcMeta.width,
|
|
9743
10066
|
height: srcMeta.height
|
|
9744
10067
|
});
|
|
@@ -9765,9 +10088,20 @@ function buildServer(opts) {
|
|
|
9765
10088
|
}
|
|
9766
10089
|
expectShape = { width: expandPlan.width, height: expandPlan.height };
|
|
9767
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
|
+
}
|
|
9768
10102
|
const editReq = {
|
|
9769
10103
|
instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
|
|
9770
|
-
sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
|
|
10104
|
+
sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
|
|
9771
10105
|
brand: ctx,
|
|
9772
10106
|
...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
|
|
9773
10107
|
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
|
|
@@ -9777,7 +10111,7 @@ function buildServer(opts) {
|
|
|
9777
10111
|
// engines given no size answered at whatever size they liked, the
|
|
9778
10112
|
// shrunken answer was stored, and the next refinement inherited it —
|
|
9779
10113
|
// the chain that walked shots down to thumbnails.
|
|
9780
|
-
...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 } : {},
|
|
9781
10115
|
// Only an engine that can genuinely paint a margin is told where the
|
|
9782
10116
|
// picture sits; the rest would ignore it anyway.
|
|
9783
10117
|
...expandPlan && canOutpaint2 ? {
|
|
@@ -9854,7 +10188,7 @@ function buildServer(opts) {
|
|
|
9854
10188
|
});
|
|
9855
10189
|
if (brief)
|
|
9856
10190
|
core.store.setBrief(node.id, {
|
|
9857
|
-
...brief,
|
|
10191
|
+
...briefInputsOnly(brief),
|
|
9858
10192
|
...editedFrom ? { sourceImage: editedFrom } : {},
|
|
9859
10193
|
...kind === "edit" && reshape ? { reshape } : {},
|
|
9860
10194
|
// How the margin was actually made, and by whom. An extend may be
|
|
@@ -9879,13 +10213,29 @@ function buildServer(opts) {
|
|
|
9879
10213
|
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
9880
10214
|
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
9881
10215
|
const enforceEditCanvas = async (images) => {
|
|
9882
|
-
const srcMeta = await
|
|
10216
|
+
const srcMeta = await sharp20(original).metadata();
|
|
9883
10217
|
if (!srcMeta.width || !srcMeta.height) return images;
|
|
9884
10218
|
const out = [];
|
|
9885
10219
|
for (const h of images) {
|
|
9886
|
-
const meta2 = await
|
|
10220
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9887
10221
|
const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
|
|
9888
|
-
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
|
+
}
|
|
9889
10239
|
if (verdict.action === "reject")
|
|
9890
10240
|
throw new Error(
|
|
9891
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`
|
|
@@ -9897,7 +10247,7 @@ function buildServer(opts) {
|
|
|
9897
10247
|
);
|
|
9898
10248
|
out.push(
|
|
9899
10249
|
core.images.save(
|
|
9900
|
-
await
|
|
10250
|
+
await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
|
|
9901
10251
|
)
|
|
9902
10252
|
);
|
|
9903
10253
|
try {
|
|
@@ -9919,7 +10269,7 @@ function buildServer(opts) {
|
|
|
9919
10269
|
const out = [];
|
|
9920
10270
|
for (const h of images) {
|
|
9921
10271
|
const answer = core.images.read(h);
|
|
9922
|
-
const got = await
|
|
10272
|
+
const got = await sharp20(answer).metadata();
|
|
9923
10273
|
if (got.width !== plan.width || got.height !== plan.height)
|
|
9924
10274
|
app.log.info(
|
|
9925
10275
|
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
@@ -9945,6 +10295,26 @@ function buildServer(opts) {
|
|
|
9945
10295
|
return images;
|
|
9946
10296
|
} : kind === "edit" && original ? async (images) => {
|
|
9947
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
|
+
}
|
|
9948
10318
|
if (localScope) {
|
|
9949
10319
|
const out = [];
|
|
9950
10320
|
for (const h of staged) {
|
|
@@ -10220,8 +10590,8 @@ async function verify() {
|
|
|
10220
10590
|
const db = new Database2(":memory:");
|
|
10221
10591
|
db.pragma("user_version");
|
|
10222
10592
|
db.close();
|
|
10223
|
-
const { default:
|
|
10224
|
-
await
|
|
10593
|
+
const { default: sharp21 } = await import('sharp');
|
|
10594
|
+
await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
10225
10595
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
10226
10596
|
} catch (err) {
|
|
10227
10597
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|