scenri 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/dist/serve.js +298 -241
- package/package.json +1 -1
- package/studio-dist/assets/index-DODDNVtQ.js +104 -0
- package/studio-dist/assets/{index-DwfWp4jH.css → index-teI3lJtu.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-9Y9YnJq6.js +0 -103
package/dist/serve.js
CHANGED
|
@@ -8,9 +8,9 @@ import Database from 'better-sqlite3';
|
|
|
8
8
|
import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
|
|
9
9
|
import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, createReadStream, rmSync, readdirSync, statSync, renameSync } from 'fs';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
-
import { readFile, copyFile, stat,
|
|
11
|
+
import { readFile, copyFile, stat, readdir, mkdtemp, rm, access, rename, unlink, 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';
|
|
@@ -690,6 +690,7 @@ var headOf = (prompt) => Array.from(String(prompt ?? "")).slice(0, PROMPT_HEAD_C
|
|
|
690
690
|
var CHILD_COUNT_SQL = "(CASE WHEN n.kind = 'root' THEN 0 ELSE (SELECT count(*) FROM nodes c WHERE c.parent_id = n.id AND c.archived = 0) END)";
|
|
691
691
|
var LINEAGE_SIBLINGS_RADIUS = 25;
|
|
692
692
|
var LINEAGE_CHILDREN_MAX = 60;
|
|
693
|
+
var LINEAGE_HISTORY_MAX = 60;
|
|
693
694
|
var FEED_COLS = `n.id, n.project_id, n.parent_id, n.kind, substr(n.prompt, 1, ${PROMPT_HEAD_CHARS}) AS prompt_head,
|
|
694
695
|
n.engine_id, n.status, n.images, n.cost_usd, n.duration_ms, n.kept, n.error, n.created_at, n.brief, n.archived,
|
|
695
696
|
n.batch_id, n.batch_index, ${CHILD_COUNT_SQL} AS child_count`;
|
|
@@ -1137,7 +1138,7 @@ function createStore(db) {
|
|
|
1137
1138
|
lineageOf(id) {
|
|
1138
1139
|
const node = this.getFeedNode(id);
|
|
1139
1140
|
if (!node) return null;
|
|
1140
|
-
if (node.kind === "root") return { ancestors: [], siblings: [], children: [] };
|
|
1141
|
+
if (node.kind === "root") return { ancestors: [], siblings: [], children: [], history: [] };
|
|
1141
1142
|
const ancestors = [];
|
|
1142
1143
|
let cur = node.parentId ? this.getFeedNode(node.parentId) : null;
|
|
1143
1144
|
for (let hops = 0; cur && cur.kind !== "root" && hops < 64; hops++) {
|
|
@@ -1155,7 +1156,18 @@ function createStore(db) {
|
|
|
1155
1156
|
ORDER BY n.created_at, n.id LIMIT ? OFFSET ?`
|
|
1156
1157
|
).all(node.parentId, take, skip).map(rowToFeedNode);
|
|
1157
1158
|
const children = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id = ? ORDER BY n.created_at, n.id LIMIT ?`).all(node.id, LINEAGE_CHILDREN_MAX).map(rowToFeedNode);
|
|
1158
|
-
|
|
1159
|
+
const rootShot = ancestors[0] ?? node;
|
|
1160
|
+
const history = db.prepare(
|
|
1161
|
+
`WITH RECURSIVE d(id) AS (SELECT @root UNION ALL SELECT c.id FROM nodes c JOIN d ON c.parent_id = d.id)
|
|
1162
|
+
SELECT ${FEED_COLS} FROM nodes n
|
|
1163
|
+
WHERE n.id IN (SELECT id FROM d) AND (n.archived = 0 OR n.id = @self)
|
|
1164
|
+
ORDER BY n.created_at, n.id LIMIT @limit`
|
|
1165
|
+
).all({ root: rootShot.id, self: node.id, limit: LINEAGE_HISTORY_MAX }).map(rowToFeedNode);
|
|
1166
|
+
if (!history.some((n) => n.id === node.id)) {
|
|
1167
|
+
history.pop();
|
|
1168
|
+
history.push(node);
|
|
1169
|
+
}
|
|
1170
|
+
return { ancestors, siblings, children, history };
|
|
1159
1171
|
},
|
|
1160
1172
|
/** The newest finished shots, newest first, for the rail and the attach panel. */
|
|
1161
1173
|
recentShots(projectId, limit = 48) {
|
|
@@ -3508,7 +3520,7 @@ function createDemoEngine(saveImage, opts = {}) {
|
|
|
3508
3520
|
<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>
|
|
3509
3521
|
<text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
|
|
3510
3522
|
</svg>`;
|
|
3511
|
-
return
|
|
3523
|
+
return sharp20(Buffer.from(svg)).png().toBuffer();
|
|
3512
3524
|
}
|
|
3513
3525
|
return {
|
|
3514
3526
|
capabilities() {
|
|
@@ -3699,7 +3711,7 @@ var resolvedRefs = /* @__PURE__ */ new Map();
|
|
|
3699
3711
|
async function refHash(core, path) {
|
|
3700
3712
|
const hit = resolvedRefs.get(path);
|
|
3701
3713
|
if (hit && core.images.has(hit)) return hit;
|
|
3702
|
-
const hash = core.images.save(await
|
|
3714
|
+
const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
|
|
3703
3715
|
resolvedRefs.set(path, hash);
|
|
3704
3716
|
return hash;
|
|
3705
3717
|
}
|
|
@@ -3803,7 +3815,7 @@ var resolvedRefs2 = /* @__PURE__ */ new Map();
|
|
|
3803
3815
|
async function refHash2(core, path) {
|
|
3804
3816
|
const hit = resolvedRefs2.get(path);
|
|
3805
3817
|
if (hit && core.images.has(hit)) return hit;
|
|
3806
|
-
const hash = core.images.save(await
|
|
3818
|
+
const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
|
|
3807
3819
|
resolvedRefs2.set(path, hash);
|
|
3808
3820
|
return hash;
|
|
3809
3821
|
}
|
|
@@ -3940,6 +3952,109 @@ function mergeEditAttachments(own, inherited, cap2) {
|
|
|
3940
3952
|
const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
|
|
3941
3953
|
return allocateAttachments([...own, ...borrowed], cap2);
|
|
3942
3954
|
}
|
|
3955
|
+
var THUMB_WIDTHS = [640, 320, 160];
|
|
3956
|
+
var WARM_WIDTHS = [640, 160];
|
|
3957
|
+
var isThumbWidth = (w) => THUMB_WIDTHS.includes(w);
|
|
3958
|
+
var THUMB_WIDTH_LIST = THUMB_WIDTHS.join(", ");
|
|
3959
|
+
var QUALITY = { 640: 82, 320: 80, 160: 75 };
|
|
3960
|
+
var FILE_KEY = /^[a-z0-9-]{1,120}$/;
|
|
3961
|
+
function createThumbStore(core, opts = {}) {
|
|
3962
|
+
const dir = join(core.home, "thumbs");
|
|
3963
|
+
let enabled = true;
|
|
3964
|
+
try {
|
|
3965
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
3966
|
+
} catch {
|
|
3967
|
+
enabled = false;
|
|
3968
|
+
}
|
|
3969
|
+
const pathFor = (key, w) => join(dir, `${key}-w${w}.webp`);
|
|
3970
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
3971
|
+
const failed = /* @__PURE__ */ new Set();
|
|
3972
|
+
const concurrency = Math.max(1, opts.concurrency ?? 2);
|
|
3973
|
+
let active = 0;
|
|
3974
|
+
const waiting = [];
|
|
3975
|
+
const acquire = () => new Promise((resolve) => {
|
|
3976
|
+
if (active < concurrency) {
|
|
3977
|
+
active++;
|
|
3978
|
+
resolve();
|
|
3979
|
+
} else waiting.push(resolve);
|
|
3980
|
+
});
|
|
3981
|
+
const release = () => {
|
|
3982
|
+
const next = waiting.shift();
|
|
3983
|
+
if (next) next();
|
|
3984
|
+
else active--;
|
|
3985
|
+
};
|
|
3986
|
+
async function make(key, source, w) {
|
|
3987
|
+
const final = pathFor(key, w);
|
|
3988
|
+
const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
3989
|
+
await acquire();
|
|
3990
|
+
try {
|
|
3991
|
+
await sharp20(source).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
|
|
3992
|
+
await rename(tmp, final);
|
|
3993
|
+
return final;
|
|
3994
|
+
} catch {
|
|
3995
|
+
await unlink(tmp).catch(() => {
|
|
3996
|
+
});
|
|
3997
|
+
failed.add(`${key}-w${w}`);
|
|
3998
|
+
return null;
|
|
3999
|
+
} finally {
|
|
4000
|
+
release();
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
async function ensureKey(key, source, w) {
|
|
4004
|
+
if (!enabled) return null;
|
|
4005
|
+
const memo = `${key}-w${w}`;
|
|
4006
|
+
if (failed.has(memo)) return null;
|
|
4007
|
+
const final = pathFor(key, w);
|
|
4008
|
+
try {
|
|
4009
|
+
await access(final);
|
|
4010
|
+
return final;
|
|
4011
|
+
} catch {
|
|
4012
|
+
}
|
|
4013
|
+
let job = inflight.get(memo);
|
|
4014
|
+
if (!job) {
|
|
4015
|
+
job = make(key, source, w).finally(() => inflight.delete(memo));
|
|
4016
|
+
inflight.set(memo, job);
|
|
4017
|
+
}
|
|
4018
|
+
return job;
|
|
4019
|
+
}
|
|
4020
|
+
return {
|
|
4021
|
+
dir,
|
|
4022
|
+
async ensure(hash, w) {
|
|
4023
|
+
if (!/^[a-f0-9]{32}$/.test(hash)) return null;
|
|
4024
|
+
return ensureKey(hash, core.images.pathFor(hash), w);
|
|
4025
|
+
},
|
|
4026
|
+
async ensureFile(key, sourcePath, w) {
|
|
4027
|
+
if (!FILE_KEY.test(key)) return null;
|
|
4028
|
+
return ensureKey(`f-${key}`, sourcePath, w);
|
|
4029
|
+
},
|
|
4030
|
+
warm(hash) {
|
|
4031
|
+
for (const w of WARM_WIDTHS) void this.ensure(hash, w);
|
|
4032
|
+
},
|
|
4033
|
+
async settle() {
|
|
4034
|
+
await Promise.allSettled([...inflight.values()]);
|
|
4035
|
+
},
|
|
4036
|
+
clear() {
|
|
4037
|
+
failed.clear();
|
|
4038
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
4039
|
+
try {
|
|
4040
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
4041
|
+
} catch {
|
|
4042
|
+
enabled = false;
|
|
4043
|
+
}
|
|
4044
|
+
},
|
|
4045
|
+
stream: (path) => createReadStream(path)
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
async function fileSize(path) {
|
|
4049
|
+
try {
|
|
4050
|
+
const s = await stat(path);
|
|
4051
|
+
return s.isFile() ? s.size : null;
|
|
4052
|
+
} catch {
|
|
4053
|
+
return null;
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
// src/routes/shared.ts
|
|
3943
4058
|
function joinNames(labels) {
|
|
3944
4059
|
const uniq = [...new Set(labels)];
|
|
3945
4060
|
if (uniq.length <= 1) return uniq[0] ?? "";
|
|
@@ -3963,7 +4078,7 @@ var assetHash = (ref) => {
|
|
|
3963
4078
|
};
|
|
3964
4079
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
3965
4080
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
3966
|
-
var toPng = (buf) =>
|
|
4081
|
+
var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
|
|
3967
4082
|
var COST_PROBE = {
|
|
3968
4083
|
prompt: "",
|
|
3969
4084
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -3976,11 +4091,11 @@ var MARK_MIN_EDGE = 1024;
|
|
|
3976
4091
|
var MARK_TINY_EDGE = 256;
|
|
3977
4092
|
var MARK_WARN_EDGE = 512;
|
|
3978
4093
|
var toMarkPng = async (buf) => {
|
|
3979
|
-
const out = await
|
|
3980
|
-
const meta = await
|
|
4094
|
+
const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
4095
|
+
const meta = await sharp20(out).metadata();
|
|
3981
4096
|
const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
|
|
3982
4097
|
if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
|
|
3983
|
-
return
|
|
4098
|
+
return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
|
|
3984
4099
|
}
|
|
3985
4100
|
return out;
|
|
3986
4101
|
};
|
|
@@ -3991,9 +4106,9 @@ async function capReferenceEdge(core, path, maxEdge) {
|
|
|
3991
4106
|
if (hit) return hit;
|
|
3992
4107
|
let out = path;
|
|
3993
4108
|
try {
|
|
3994
|
-
const meta = await
|
|
4109
|
+
const meta = await sharp20(path).metadata();
|
|
3995
4110
|
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
3996
|
-
const buf = await
|
|
4111
|
+
const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3997
4112
|
out = core.images.pathFor(core.images.save(buf));
|
|
3998
4113
|
}
|
|
3999
4114
|
} catch {
|
|
@@ -4020,6 +4135,25 @@ var serveJpeg = (req, reply, path) => {
|
|
|
4020
4135
|
if (req.headers["if-none-match"] === etag) return reply.status(304).send();
|
|
4021
4136
|
return reply.header("content-type", "image/jpeg").send(readFileSync(path));
|
|
4022
4137
|
};
|
|
4138
|
+
var fileKey = (prefix, id, path) => `${prefix}-${id}-${Math.round(statSync(path).mtimeMs)}`;
|
|
4139
|
+
var serveJpegSized = async (req, reply, path, thumbs, key) => {
|
|
4140
|
+
const raw = req.query?.w;
|
|
4141
|
+
if (raw === void 0 || raw === "") return serveJpeg(req, reply, path);
|
|
4142
|
+
const w = Number(raw);
|
|
4143
|
+
if (!isThumbWidth(w)) return reply.status(400).send({ error: `w must be one of ${THUMB_WIDTH_LIST}` });
|
|
4144
|
+
const immutable = "public, max-age=31536000, immutable";
|
|
4145
|
+
const etag = `"${key}-w${w}"`;
|
|
4146
|
+
if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", immutable).send();
|
|
4147
|
+
const made = await thumbs.ensureFile(key, path, w);
|
|
4148
|
+
const size = made ? await fileSize(made) : null;
|
|
4149
|
+
if (!made || size === null) {
|
|
4150
|
+
const back = new URL(req.url, "http://scenri.local");
|
|
4151
|
+
back.searchParams.delete("w");
|
|
4152
|
+
return reply.header("cache-control", "no-store").redirect(`${back.pathname}${back.search}`, 307);
|
|
4153
|
+
}
|
|
4154
|
+
reply.header("content-type", "image/webp").header("cache-control", immutable).header("etag", etag).header("content-length", String(size));
|
|
4155
|
+
return reply.send(thumbs.stream(made));
|
|
4156
|
+
};
|
|
4023
4157
|
|
|
4024
4158
|
// src/briefDirectives.ts
|
|
4025
4159
|
function productFidelityDirective(attached) {
|
|
@@ -4100,6 +4234,12 @@ function shotSpecifiesCamera(text) {
|
|
|
4100
4234
|
function namesAreNotLetteringDirective() {
|
|
4101
4235
|
return "The names in this brief identify what to show and are never text to render: no caption, label, signage, engraving or lettering spells a product name or a person's name, in any language or script, anywhere in the picture. Printing that is part of a product's own packaging stays exactly as photographed, and nothing else spells a name unless the direction above explicitly asks for it to be written.";
|
|
4102
4236
|
}
|
|
4237
|
+
var PERSON_SCENE_FIGURE = "the one person this set is shot around";
|
|
4238
|
+
function shotAsksForAPerson(text) {
|
|
4239
|
+
return /\b(?:person|people|man|men|woman|women|models?|figures?|someone|somebody|anyone|hands?(?!-)|arms?(?!-)|portrait|girls?|boys?|guys?|lady|ladies|couple|family|child|children|kids?|baby|crowd|presenter|character|athlete|dancer|customer|shopper|wearer|wearing|holding|holds)\b/i.test(
|
|
4240
|
+
text
|
|
4241
|
+
);
|
|
4242
|
+
}
|
|
4103
4243
|
function sceneFigureDirectives(opts) {
|
|
4104
4244
|
const figure = opts.figure.trim().replace(/[.\s]+$/, "");
|
|
4105
4245
|
if (!figure) return [];
|
|
@@ -4113,15 +4253,23 @@ function sceneFigureDirectives(opts) {
|
|
|
4113
4253
|
out.push(
|
|
4114
4254
|
`This world is built around one figure: ${figure}. The attached presenter is that figure. Any person the scene direction describes IS the presenter and never a second person, and their identity comes from their own attached photograph alone, never from anything the scene direction says about a body.`
|
|
4115
4255
|
);
|
|
4256
|
+
} else if (opts.asked) {
|
|
4257
|
+
out.push(
|
|
4258
|
+
`This world is built around one figure: ${figure}. The brief asks for a person and nobody is attached, so someone fills that role in the frame, and they are nobody in particular: an anonymous person invented for this photograph only, with no recognisable identity to preserve and nothing about them carried anywhere else.`
|
|
4259
|
+
);
|
|
4116
4260
|
} else {
|
|
4117
4261
|
out.push(
|
|
4118
|
-
`This world is built around one figure: ${figure}.
|
|
4262
|
+
`This world is built around one figure: ${figure}. Nobody is attached to take that role, so the role stays empty and nobody is in this image. The frame holds the set, the light and any treatment this world applies.`
|
|
4119
4263
|
);
|
|
4120
4264
|
}
|
|
4121
4265
|
if (treatment) {
|
|
4122
4266
|
const who = opts.hasPerson ? "The face and body underneath are still exactly theirs - same structure, same proportions, same build - and any earlier instruction that their features must survive unchanged is a rule about who they are, which this does not alter. " : "";
|
|
4123
4267
|
out.push(
|
|
4124
|
-
`The art direction of this world is what has been done to that figure: ${treatment}. Render it as a real physical treatment, following the shape of the face and body it sits on rather than floating in the frame. Spread it across the whole form the way the reference does, reaching every part it covers there - brow, forehead, nose, both cheeks, jaw - instead of massing it in one area and leaving the rest untouched. Reaching wide is not the same as covering more: keep the number of pieces and the bare surface between them exactly as the description says, so a sparse treatment stays sparse while still touching every part of the form. Each piece sits on the plane beneath it, curving and catching light with the surface it is stuck to. ${who}The figure is bodily present and in shot: where the treatment covers or hides them, that is the photograph working as intended and never a reason to leave them out, crop them away, or reduce them to a shadow.
|
|
4268
|
+
`The art direction of this world is what has been done to that figure: ${treatment}. Render it as a real physical treatment, following the shape of the face and body it sits on rather than floating in the frame. Spread it across the whole form the way the reference does, reaching every part it covers there - brow, forehead, nose, both cheeks, jaw - instead of massing it in one area and leaving the rest untouched. Reaching wide is not the same as covering more: keep the number of pieces and the bare surface between them exactly as the description says, so a sparse treatment stays sparse while still touching every part of the form. Each piece sits on the plane beneath it, curving and catching light with the surface it is stuck to. ${who}` + (opts.hasPerson ? "The figure is bodily present and in shot: where the treatment covers or hides them, that is the photograph working as intended and never a reason to leave them out, crop them away, or reduce them to a shadow. " : "") + // The treatment is the art direction, not a property of the person. Ask
|
|
4269
|
+
// for this world with no people in it and the stickers should still be
|
|
4270
|
+
// there, on whatever the frame does hold - that IS the scene. Suppressing
|
|
4271
|
+
// them left a plain product on a plinth with nothing of the scene in it.
|
|
4272
|
+
"If no person appears in this shot, the treatment does not go with them: it is what this world looks like, so it applies to whatever the frame does hold - the product, the surfaces, the set - as real pieces resting on those things. Applied on top, never redesigning them: the product keeps the exact form, colour, material and its own printed label that its reference shows, with the treatment sitting over it. Where the treatment carries printing, render it as genuinely designed print: real letterforms, readable words, numerals, illustration and colour, at the quality of commercial label artwork. Invent the companies - every name, logotype and piece of packaging artwork must be plausible but fictional, resembling no existing brand. That includes near-misses: do not borrow, extend or re-spell a name that appears in any attached reference, and use ordinary words for the produce itself rather than any company that sells it." + // The fictional-brands rule and an attached brand mark are in direct
|
|
4125
4273
|
// conflict without this: "resembling no existing brand" reads as an
|
|
4126
4274
|
// instruction to mutate the one real mark the user deliberately
|
|
4127
4275
|
// attached. Same shape as pairDirectives' packshot override - name the
|
|
@@ -4161,6 +4309,12 @@ function sceneGuardDirectives(opts) {
|
|
|
4161
4309
|
);
|
|
4162
4310
|
}
|
|
4163
4311
|
}
|
|
4312
|
+
const emptyRole = (opts.emptyRole ?? "").trim().replace(/[.\s]+$/, "");
|
|
4313
|
+
if (emptyRole) {
|
|
4314
|
+
out.push(
|
|
4315
|
+
`Disregard any person, figure, hand, face or silhouette described in the scene direction or the camera note above: that is the role this world is built around (${emptyRole}), nobody is attached to take it, and it stays empty. Nobody is in this image: no person, no hands, no reflection or shadow of anyone. The set, the light and any treatment this world applies fill the frame on their own.`
|
|
4316
|
+
);
|
|
4317
|
+
}
|
|
4164
4318
|
return out;
|
|
4165
4319
|
}
|
|
4166
4320
|
function brandRuleDirectives(brand) {
|
|
@@ -4188,7 +4342,6 @@ function markLabel(brand, logo) {
|
|
|
4188
4342
|
// src/brief.ts
|
|
4189
4343
|
var PRODUCT_REF_MAX = 3;
|
|
4190
4344
|
var CHARACTER_REF_MAX = 3;
|
|
4191
|
-
var SCENE_REF_MAX = 1;
|
|
4192
4345
|
var FORMATS = [
|
|
4193
4346
|
{ id: "square", label: "Square 1:1", w: 1024, h: 1024 },
|
|
4194
4347
|
{ id: "story", label: "Story 9:16", w: 1080, h: 1920 },
|
|
@@ -4247,7 +4400,6 @@ function compileBrief(brief, ctx) {
|
|
|
4247
4400
|
const warnings = [];
|
|
4248
4401
|
const attachments = [];
|
|
4249
4402
|
const unattachable = [];
|
|
4250
|
-
const rawSceneFallback = [];
|
|
4251
4403
|
const productDirectives = [];
|
|
4252
4404
|
const personDirectives = [];
|
|
4253
4405
|
const otherDirectives = [];
|
|
@@ -4260,6 +4412,7 @@ function compileBrief(brief, ctx) {
|
|
|
4260
4412
|
let hasPerson = false;
|
|
4261
4413
|
let people = 0;
|
|
4262
4414
|
let sentence = "";
|
|
4415
|
+
let userWords = "";
|
|
4263
4416
|
const append = (s) => {
|
|
4264
4417
|
sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
|
|
4265
4418
|
};
|
|
@@ -4270,6 +4423,7 @@ function compileBrief(brief, ctx) {
|
|
|
4270
4423
|
switch (tok.t) {
|
|
4271
4424
|
case "text":
|
|
4272
4425
|
append(tok.v);
|
|
4426
|
+
userWords += ` ${tok.v}`;
|
|
4273
4427
|
break;
|
|
4274
4428
|
case "product": {
|
|
4275
4429
|
const p = products.find((x) => x.id === tok.id);
|
|
@@ -4426,14 +4580,8 @@ function compileBrief(brief, ctx) {
|
|
|
4426
4580
|
append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
|
|
4427
4581
|
if (ctx.mode !== "edit" && t.figure) {
|
|
4428
4582
|
const plate = assetHash2(t.preview);
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
for (const h of candidates) {
|
|
4432
|
-
if (h && ctx.images.has(h)) {
|
|
4433
|
-
const a = { role: "scene", id: t.id, label: t.name, hash: h, essential: false };
|
|
4434
|
-
attachments.push(a);
|
|
4435
|
-
if (!hasPlate) rawSceneFallback.push(a);
|
|
4436
|
-
}
|
|
4583
|
+
if (plate && ctx.images.has(plate)) {
|
|
4584
|
+
attachments.push({ role: "scene", id: t.id, label: t.name, hash: plate, essential: false });
|
|
4437
4585
|
}
|
|
4438
4586
|
}
|
|
4439
4587
|
break;
|
|
@@ -4479,15 +4627,12 @@ function compileBrief(brief, ctx) {
|
|
|
4479
4627
|
if (scene?.subject === "product" && !productId) {
|
|
4480
4628
|
warnings.push(`${scene.name} is built around a product. Add one to this brief.`);
|
|
4481
4629
|
} else if (scene?.subject === "person" && !hasPerson) {
|
|
4482
|
-
warnings.push(`${scene.name} is built around a person.
|
|
4630
|
+
warnings.push(`${scene.name} is built around a person. With nobody attached, the set renders on its own.`);
|
|
4483
4631
|
}
|
|
4484
4632
|
const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
|
|
4485
4633
|
const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
|
|
4486
|
-
if (hasPerson
|
|
4487
|
-
for (
|
|
4488
|
-
const i = attachments.indexOf(a);
|
|
4489
|
-
if (i !== -1) attachments.splice(i, 1);
|
|
4490
|
-
}
|
|
4634
|
+
if (!hasPerson) {
|
|
4635
|
+
for (let i = attachments.length - 1; i >= 0; i--) if (attachments[i].role === "scene") attachments.splice(i, 1);
|
|
4491
4636
|
}
|
|
4492
4637
|
const identityHashes = /* @__PURE__ */ new Map();
|
|
4493
4638
|
for (const a of attachments)
|
|
@@ -4533,10 +4678,14 @@ function compileBrief(brief, ctx) {
|
|
|
4533
4678
|
);
|
|
4534
4679
|
}
|
|
4535
4680
|
}
|
|
4681
|
+
const figureRole = scene?.figure ?? (scene?.subject === "person" && !hasPerson ? PERSON_SCENE_FIGURE : void 0);
|
|
4682
|
+
const asked = shotAsksForAPerson(userWords);
|
|
4683
|
+
const emptyRole = figureRole && !hasPerson && !asked && ctx.mode !== "edit" ? figureRole : void 0;
|
|
4536
4684
|
const guard = scene ? sceneGuardDirectives({
|
|
4537
4685
|
hasProduct: !!productId,
|
|
4538
4686
|
hasPerson,
|
|
4539
|
-
hasScenePhoto: kept.some((a) => a.role === "scene")
|
|
4687
|
+
hasScenePhoto: kept.some((a) => a.role === "scene"),
|
|
4688
|
+
emptyRole
|
|
4540
4689
|
}) : [];
|
|
4541
4690
|
const pairDirectives = productId && hasPerson ? [
|
|
4542
4691
|
"If the attached product is something a person wears, the presenter wears that exact product, with the rest of the outfit styled around it; otherwise the presenter presents or uses the product naturally.",
|
|
@@ -4550,10 +4699,11 @@ function compileBrief(brief, ctx) {
|
|
|
4550
4699
|
productHandlingDirective()
|
|
4551
4700
|
] : [];
|
|
4552
4701
|
const nameDirectives = productId || hasPerson ? [namesAreNotLetteringDirective()] : [];
|
|
4553
|
-
const figureDirectives =
|
|
4554
|
-
figure:
|
|
4555
|
-
treatment: scene
|
|
4702
|
+
const figureDirectives = figureRole && (hasPerson || ctx.mode !== "edit") ? sceneFigureDirectives({
|
|
4703
|
+
figure: figureRole,
|
|
4704
|
+
treatment: scene?.figureTreatment,
|
|
4556
4705
|
hasPerson,
|
|
4706
|
+
asked,
|
|
4557
4707
|
people,
|
|
4558
4708
|
// The treatment's fictional-brands rule needs to know a real mark is
|
|
4559
4709
|
// deliberately in play - and only one that actually rides counts,
|
|
@@ -6639,8 +6789,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
6639
6789
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
6640
6790
|
return;
|
|
6641
6791
|
}
|
|
6642
|
-
const png = await
|
|
6643
|
-
const meta = await
|
|
6792
|
+
const png = await sharp20(buf).rotate().png().toBuffer();
|
|
6793
|
+
const meta = await sharp20(png).metadata();
|
|
6644
6794
|
const hash = core.images.save(png);
|
|
6645
6795
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
6646
6796
|
width: meta.width,
|
|
@@ -7117,7 +7267,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
7117
7267
|
return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
|
|
7118
7268
|
}
|
|
7119
7269
|
async function edgeBarGeometry(buf) {
|
|
7120
|
-
const { data, info } = await
|
|
7270
|
+
const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
7121
7271
|
const W = info.width;
|
|
7122
7272
|
const H = info.height;
|
|
7123
7273
|
const scan = (len, cross, at) => {
|
|
@@ -7171,7 +7321,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
7171
7321
|
const width = g.right - g.left + 1;
|
|
7172
7322
|
const height = g.bottom - g.top + 1;
|
|
7173
7323
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
7174
|
-
const png = await
|
|
7324
|
+
const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
7175
7325
|
return core.images.save(png);
|
|
7176
7326
|
} catch {
|
|
7177
7327
|
return hash;
|
|
@@ -7242,7 +7392,7 @@ async function identityCrop(core, hash) {
|
|
|
7242
7392
|
if (!out) return void 0;
|
|
7243
7393
|
try {
|
|
7244
7394
|
const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
|
|
7245
|
-
const png = await
|
|
7395
|
+
const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
|
|
7246
7396
|
const scaled = core.images.save(png);
|
|
7247
7397
|
identityCrops.set(hash, scaled);
|
|
7248
7398
|
return scaled;
|
|
@@ -7271,12 +7421,12 @@ async function brandJsonWithIdentityCrops(core, json, characterIds) {
|
|
|
7271
7421
|
return changed ? { ...json, characters } : json;
|
|
7272
7422
|
}
|
|
7273
7423
|
async function figureBox(buf) {
|
|
7274
|
-
const meta = await
|
|
7424
|
+
const meta = await sharp20(buf).metadata();
|
|
7275
7425
|
const W = meta.width ?? 0;
|
|
7276
7426
|
const H = meta.height ?? 0;
|
|
7277
7427
|
if (!W || !H) return null;
|
|
7278
7428
|
for (const threshold of FIGURE_TRIM_THRESHOLDS) {
|
|
7279
|
-
const { info } = await
|
|
7429
|
+
const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
|
|
7280
7430
|
const left = Math.abs(info.trimOffsetLeft ?? 0);
|
|
7281
7431
|
const top = Math.abs(info.trimOffsetTop ?? 0);
|
|
7282
7432
|
const width = info.width ?? 0;
|
|
@@ -7309,13 +7459,13 @@ async function smartCover(core, hash, box) {
|
|
|
7309
7459
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
7310
7460
|
try {
|
|
7311
7461
|
const buf = core.images.read(hash);
|
|
7312
|
-
const meta = await
|
|
7462
|
+
const meta = await sharp20(buf).metadata();
|
|
7313
7463
|
const w = meta.width ?? 0;
|
|
7314
7464
|
const h = meta.height ?? 0;
|
|
7315
7465
|
if (!w || !h) return void 0;
|
|
7316
7466
|
const raw = box(w, h);
|
|
7317
7467
|
const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
|
|
7318
|
-
const png = await
|
|
7468
|
+
const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
7319
7469
|
return core.images.save(png);
|
|
7320
7470
|
} catch {
|
|
7321
7471
|
return void 0;
|
|
@@ -7324,11 +7474,11 @@ async function smartCover(core, hash, box) {
|
|
|
7324
7474
|
async function crop(core, hash, region, cap2) {
|
|
7325
7475
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
7326
7476
|
try {
|
|
7327
|
-
const meta = await
|
|
7477
|
+
const meta = await sharp20(core.images.read(hash)).metadata();
|
|
7328
7478
|
const w = meta.width ?? 0;
|
|
7329
7479
|
const h = meta.height ?? 0;
|
|
7330
7480
|
if (!w || !h) return void 0;
|
|
7331
|
-
let pipeline =
|
|
7481
|
+
let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
|
|
7332
7482
|
if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
|
|
7333
7483
|
const png = await pipeline.png().toBuffer();
|
|
7334
7484
|
return core.images.save(png);
|
|
@@ -7675,7 +7825,7 @@ var fromLab = (l, a, bb) => {
|
|
|
7675
7825
|
return [clamp(R), clamp(G), clamp(B)];
|
|
7676
7826
|
};
|
|
7677
7827
|
var rawAt = async (png, edge) => {
|
|
7678
|
-
let img =
|
|
7828
|
+
let img = sharp20(png);
|
|
7679
7829
|
if (edge) img = img.resize(edge, edge, { fit: "fill" });
|
|
7680
7830
|
const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
7681
7831
|
return { data, width: info.width, height: info.height };
|
|
@@ -7738,7 +7888,7 @@ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
|
|
|
7738
7888
|
if (residual > GRADE_GATE_MEAN_DELTA) return null;
|
|
7739
7889
|
const full = await rawAt(originalPng);
|
|
7740
7890
|
applyAffine(full, T);
|
|
7741
|
-
const image = await
|
|
7891
|
+
const image = await sharp20(full.data, {
|
|
7742
7892
|
raw: { width: full.width, height: full.height, channels: 3 }
|
|
7743
7893
|
}).png().toBuffer();
|
|
7744
7894
|
return { image, residual };
|
|
@@ -7912,7 +8062,7 @@ function fitExpandToBudget(plan, source, pixelBudget) {
|
|
|
7912
8062
|
}
|
|
7913
8063
|
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
7914
8064
|
try {
|
|
7915
|
-
const { info } = await
|
|
8065
|
+
const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
7916
8066
|
const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
|
|
7917
8067
|
const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
|
|
7918
8068
|
const left = Math.round((attnLeft + plan.left) / 2);
|
|
@@ -8065,23 +8215,23 @@ function relax(grid, seam, fixedSweeps) {
|
|
|
8065
8215
|
|
|
8066
8216
|
// src/expand.ts
|
|
8067
8217
|
async function expandCanvas(source, plan) {
|
|
8068
|
-
const bed = await
|
|
8069
|
-
return
|
|
8218
|
+
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();
|
|
8219
|
+
return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
8070
8220
|
}
|
|
8071
8221
|
async function compositeExpand(engineImage, source, plan) {
|
|
8072
|
-
const meta = await
|
|
8222
|
+
const meta = await sharp20(engineImage).metadata();
|
|
8073
8223
|
const want = plan.width / plan.height;
|
|
8074
8224
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
8075
8225
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
8076
8226
|
const aligned = sameOrientation;
|
|
8077
8227
|
const exact = meta.width === plan.width && meta.height === plan.height;
|
|
8078
|
-
const surround = aligned ? exact ? engineImage : await
|
|
8228
|
+
const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
8079
8229
|
const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
|
|
8080
|
-
const image = await
|
|
8230
|
+
const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
8081
8231
|
return { image, aligned };
|
|
8082
8232
|
}
|
|
8083
8233
|
async function matchMarginsToSeam(surround, source, plan) {
|
|
8084
|
-
const src = await
|
|
8234
|
+
const src = await sharp20(source).metadata();
|
|
8085
8235
|
if (!src.width || !src.height) return surround;
|
|
8086
8236
|
const SW = src.width;
|
|
8087
8237
|
const SH = src.height;
|
|
@@ -8128,8 +8278,8 @@ var MAX_CORRECTION = 60;
|
|
|
8128
8278
|
async function reconcile(surround, source, side, axis) {
|
|
8129
8279
|
const { margin } = side;
|
|
8130
8280
|
if (margin.width < 1 || margin.height < 1) return surround;
|
|
8131
|
-
const marginRaw = await
|
|
8132
|
-
const edgeRaw = await
|
|
8281
|
+
const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
|
|
8282
|
+
const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
|
|
8133
8283
|
const W = margin.width;
|
|
8134
8284
|
const H = margin.height;
|
|
8135
8285
|
const along = axis === "width" ? H : W;
|
|
@@ -8183,11 +8333,11 @@ async function reconcile(surround, source, side, axis) {
|
|
|
8183
8333
|
}
|
|
8184
8334
|
}
|
|
8185
8335
|
}
|
|
8186
|
-
const patch2 = await
|
|
8187
|
-
return
|
|
8336
|
+
const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
8337
|
+
return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
|
|
8188
8338
|
}
|
|
8189
8339
|
async function expandCanvasBedOnly(source, plan) {
|
|
8190
|
-
return
|
|
8340
|
+
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();
|
|
8191
8341
|
}
|
|
8192
8342
|
function medianOf(rgb, channel, from, to) {
|
|
8193
8343
|
const n = to - from;
|
|
@@ -8198,17 +8348,17 @@ function medianOf(rgb, channel, from, to) {
|
|
|
8198
8348
|
return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
|
|
8199
8349
|
}
|
|
8200
8350
|
async function reframeExpand(engineImage, plan) {
|
|
8201
|
-
const meta = await
|
|
8351
|
+
const meta = await sharp20(engineImage).metadata();
|
|
8202
8352
|
if (!(meta.width && meta.height)) return null;
|
|
8203
8353
|
const want = plan.width / plan.height;
|
|
8204
8354
|
const got = meta.width / meta.height;
|
|
8205
8355
|
if (got >= 1 !== want >= 1) return null;
|
|
8206
8356
|
if (meta.width === plan.width && meta.height === plan.height) return engineImage;
|
|
8207
8357
|
const straight = Math.abs(got - want) / want <= 0.02;
|
|
8208
|
-
return
|
|
8358
|
+
return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
|
|
8209
8359
|
}
|
|
8210
8360
|
async function seamScore(image, plan, source) {
|
|
8211
|
-
const { data, info } = await
|
|
8361
|
+
const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
8212
8362
|
const W = info.width;
|
|
8213
8363
|
const H = info.height;
|
|
8214
8364
|
const horizontal = plan.axis === "width";
|
|
@@ -8241,7 +8391,7 @@ var SEAM_VISIBLE = 2.2;
|
|
|
8241
8391
|
var OFFSET = 4;
|
|
8242
8392
|
var RESIDUAL_VISIBLE = 15;
|
|
8243
8393
|
async function seamResidual(image, plan, source) {
|
|
8244
|
-
const { data, info } = await
|
|
8394
|
+
const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
8245
8395
|
const W = info.width;
|
|
8246
8396
|
const H = info.height;
|
|
8247
8397
|
const ch = info.channels;
|
|
@@ -8276,7 +8426,7 @@ var MAX_SHARE = 0.8;
|
|
|
8276
8426
|
async function subjectFraction(src, source, axis) {
|
|
8277
8427
|
try {
|
|
8278
8428
|
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)) };
|
|
8279
|
-
const { info } = await
|
|
8429
|
+
const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
8280
8430
|
const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
|
|
8281
8431
|
const span = axis === "width" ? source.width : source.height;
|
|
8282
8432
|
const extent = axis === "width" ? window.width : window.height;
|
|
@@ -8299,14 +8449,14 @@ function placeExpand(plan, source, fraction) {
|
|
|
8299
8449
|
}
|
|
8300
8450
|
var NEUTRAL = { r: 128, g: 128, b: 128 };
|
|
8301
8451
|
async function conditioningCanvas(source, plan, fill = "edge") {
|
|
8302
|
-
const meta = await
|
|
8452
|
+
const meta = await sharp20(source).metadata();
|
|
8303
8453
|
const sw = meta.width ?? 0;
|
|
8304
8454
|
const sh = meta.height ?? 0;
|
|
8305
8455
|
if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
|
|
8306
8456
|
const layers = [];
|
|
8307
8457
|
if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
|
|
8308
8458
|
layers.push({ input: source, left: plan.left, top: plan.top });
|
|
8309
|
-
const canvas =
|
|
8459
|
+
const canvas = sharp20({
|
|
8310
8460
|
create: {
|
|
8311
8461
|
width: plan.width,
|
|
8312
8462
|
height: plan.height,
|
|
@@ -8318,7 +8468,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
|
|
|
8318
8468
|
}
|
|
8319
8469
|
async function edgeMargins(source, plan, size) {
|
|
8320
8470
|
const out = [];
|
|
8321
|
-
const strip = async (extract, width, height) =>
|
|
8471
|
+
const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
|
|
8322
8472
|
if (plan.axis === "width") {
|
|
8323
8473
|
const before = plan.left;
|
|
8324
8474
|
const after = plan.width - plan.left - size.width;
|
|
@@ -8409,12 +8559,12 @@ async function resolveOutpaintRoute(all, shot) {
|
|
|
8409
8559
|
return { engine: shot, method: "reframe", crossed: false };
|
|
8410
8560
|
}
|
|
8411
8561
|
async function driftDiff(a, b) {
|
|
8412
|
-
const metaA = await
|
|
8413
|
-
const metaB = await
|
|
8562
|
+
const metaA = await sharp20(a).metadata();
|
|
8563
|
+
const metaB = await sharp20(b).metadata();
|
|
8414
8564
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
8415
8565
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
8416
8566
|
const [rawA, rawB] = await Promise.all(
|
|
8417
|
-
[a, b].map((buf) =>
|
|
8567
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
8418
8568
|
);
|
|
8419
8569
|
const out = new PNG({ width, height });
|
|
8420
8570
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -8426,11 +8576,11 @@ async function driftDiff(a, b) {
|
|
|
8426
8576
|
};
|
|
8427
8577
|
}
|
|
8428
8578
|
async function changeMask(a, b, cap2 = 1024) {
|
|
8429
|
-
const metaA = await
|
|
8579
|
+
const metaA = await sharp20(a).metadata();
|
|
8430
8580
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
8431
8581
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
8432
8582
|
const [rawA, rawB] = await Promise.all(
|
|
8433
|
-
[a, b].map((buf) =>
|
|
8583
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
8434
8584
|
);
|
|
8435
8585
|
const out = new PNG({ width, height });
|
|
8436
8586
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -8479,8 +8629,8 @@ function dilationFor(longEdge) {
|
|
|
8479
8629
|
// src/localEdit.ts
|
|
8480
8630
|
async function preserveOutsideChange(source, edited) {
|
|
8481
8631
|
try {
|
|
8482
|
-
const srcMeta = await
|
|
8483
|
-
const outMeta = await
|
|
8632
|
+
const srcMeta = await sharp20(source).metadata();
|
|
8633
|
+
const outMeta = await sharp20(edited).metadata();
|
|
8484
8634
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
8485
8635
|
return { image: edited, outcome: "error", changed: 0 };
|
|
8486
8636
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -8490,15 +8640,15 @@ async function preserveOutsideChange(source, edited) {
|
|
|
8490
8640
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
8491
8641
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
8492
8642
|
const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
|
|
8493
|
-
const spread = await
|
|
8494
|
-
const dilated = await
|
|
8495
|
-
const feathered = await
|
|
8496
|
-
const grown = await
|
|
8497
|
-
const editedRgb = await
|
|
8498
|
-
const masked = await
|
|
8643
|
+
const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
|
|
8644
|
+
const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
|
|
8645
|
+
const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
|
|
8646
|
+
const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
8647
|
+
const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
8648
|
+
const masked = await sharp20(editedRgb, {
|
|
8499
8649
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
8500
8650
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
8501
|
-
const image = await
|
|
8651
|
+
const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
8502
8652
|
return { image, outcome: "composited", changed: shape.changed };
|
|
8503
8653
|
} catch {
|
|
8504
8654
|
return { image: edited, outcome: "error", changed: 0 };
|
|
@@ -8536,7 +8686,7 @@ function registerLogoRoutes(app, deps) {
|
|
|
8536
8686
|
const v = validateBrand(json);
|
|
8537
8687
|
if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
|
|
8538
8688
|
const row = core.store.updateBrand(brand.id, json);
|
|
8539
|
-
const meta = await
|
|
8689
|
+
const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
|
|
8540
8690
|
const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
|
|
8541
8691
|
return { ...row, logoHash: part.hash, logoEdge };
|
|
8542
8692
|
});
|
|
@@ -8658,7 +8808,7 @@ async function vibrantColor(input) {
|
|
|
8658
8808
|
let data;
|
|
8659
8809
|
let channels;
|
|
8660
8810
|
try {
|
|
8661
|
-
const out = await
|
|
8811
|
+
const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
8662
8812
|
data = out.data;
|
|
8663
8813
|
channels = out.info.channels;
|
|
8664
8814
|
} catch {
|
|
@@ -8681,7 +8831,7 @@ async function vibrantColor(input) {
|
|
|
8681
8831
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
8682
8832
|
if (best.score <= 0) {
|
|
8683
8833
|
try {
|
|
8684
|
-
const { dominant } = await
|
|
8834
|
+
const { dominant } = await sharp20(input).stats();
|
|
8685
8835
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
8686
8836
|
} catch {
|
|
8687
8837
|
return null;
|
|
@@ -8708,7 +8858,7 @@ var toHex = (r, g, b) => "#" + [r, g, b].map(
|
|
|
8708
8858
|
|
|
8709
8859
|
// src/routes/scenes.ts
|
|
8710
8860
|
function registerSceneRoutes(app, deps) {
|
|
8711
|
-
const { templatesRoot, scenes } = deps;
|
|
8861
|
+
const { templatesRoot, scenes, thumbs } = deps;
|
|
8712
8862
|
const previewPath = (id) => contentFile(templatesRoot, "previews", `${id}.jpg`);
|
|
8713
8863
|
const previewColors = /* @__PURE__ */ new Map();
|
|
8714
8864
|
const previewColor = async (id) => {
|
|
@@ -8731,7 +8881,8 @@ function registerSceneRoutes(app, deps) {
|
|
|
8731
8881
|
app.get("/api/scene-thumbnails/:file", async (req, reply) => {
|
|
8732
8882
|
const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
|
|
8733
8883
|
if (!m || !existsSync(previewPath(m[1]))) return reply.status(404).send({ error: "no preview" });
|
|
8734
|
-
|
|
8884
|
+
const path = previewPath(m[1]);
|
|
8885
|
+
return serveJpegSized(req, reply, path, thumbs, fileKey("scene", m[1], path));
|
|
8735
8886
|
});
|
|
8736
8887
|
const refPath = (id, slot) => contentFile(templatesRoot, "previews", id, `${slot}.jpg`);
|
|
8737
8888
|
app.get("/api/scene-previews/:id", async (req, reply) => {
|
|
@@ -8749,7 +8900,7 @@ function registerSceneRoutes(app, deps) {
|
|
|
8749
8900
|
});
|
|
8750
8901
|
}
|
|
8751
8902
|
function registerPresenterRoutes(app, deps) {
|
|
8752
|
-
const { templatesRoot, presenters } = deps;
|
|
8903
|
+
const { templatesRoot, presenters, thumbs } = deps;
|
|
8753
8904
|
const presenterThumbPath = (id) => contentFile(templatesRoot, "previews", "presenters", `${id}.jpg`);
|
|
8754
8905
|
const avatarPath = (id) => presenterAvatarPath(templatesRoot, id);
|
|
8755
8906
|
const decoratePresenter = (p) => ({
|
|
@@ -8766,12 +8917,14 @@ function registerPresenterRoutes(app, deps) {
|
|
|
8766
8917
|
app.get("/api/presenter-thumbnails/:file", async (req, reply) => {
|
|
8767
8918
|
const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
|
|
8768
8919
|
if (!m || !existsSync(presenterThumbPath(m[1]))) return reply.status(404).send({ error: "no preview" });
|
|
8769
|
-
|
|
8920
|
+
const path = presenterThumbPath(m[1]);
|
|
8921
|
+
return serveJpegSized(req, reply, path, thumbs, fileKey("presenter", m[1], path));
|
|
8770
8922
|
});
|
|
8771
8923
|
app.get("/api/presenter-avatars/:file", async (req, reply) => {
|
|
8772
8924
|
const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
|
|
8773
8925
|
if (!m || !existsSync(avatarPath(m[1]))) return reply.status(404).send({ error: "no avatar" });
|
|
8774
|
-
|
|
8926
|
+
const path = avatarPath(m[1]);
|
|
8927
|
+
return serveJpegSized(req, reply, path, thumbs, fileKey("avatar", m[1], path));
|
|
8775
8928
|
});
|
|
8776
8929
|
app.get("/api/presenter-previews/:id", async (req, reply) => {
|
|
8777
8930
|
const id = /^[a-z0-9-]+$/.exec(String(req.params.id))?.[0];
|
|
@@ -9085,7 +9238,7 @@ async function withDerivedCrops(core, body, base) {
|
|
|
9085
9238
|
};
|
|
9086
9239
|
}
|
|
9087
9240
|
function registerDemoProductRoutes(app, deps) {
|
|
9088
|
-
const { templatesRoot, demoProducts, demoProductById } = deps;
|
|
9241
|
+
const { templatesRoot, demoProducts, demoProductById, thumbs } = deps;
|
|
9089
9242
|
const demoProductThumbPath = (id) => {
|
|
9090
9243
|
const p = demoProductById(id);
|
|
9091
9244
|
if (!p) return null;
|
|
@@ -9111,9 +9264,10 @@ function registerDemoProductRoutes(app, deps) {
|
|
|
9111
9264
|
}));
|
|
9112
9265
|
app.get("/api/demo-product-thumbnails/:file", async (req, reply) => {
|
|
9113
9266
|
const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
|
|
9114
|
-
|
|
9267
|
+
if (!m) return reply.status(404).send({ error: "no preview" });
|
|
9268
|
+
const path = demoProductThumbPath(m[1]);
|
|
9115
9269
|
if (!path || !existsSync(path)) return reply.status(404).send({ error: "no preview" });
|
|
9116
|
-
return
|
|
9270
|
+
return serveJpegSized(req, reply, path, thumbs, fileKey("demo", m[1], path));
|
|
9117
9271
|
});
|
|
9118
9272
|
app.get("/api/demo-product-previews/:id", async (req, reply) => {
|
|
9119
9273
|
const id = /^[a-z0-9-]+$/.exec(String(req.params.id))?.[0];
|
|
@@ -9354,22 +9508,6 @@ function registerCodexSetupRoutes(app, deps) {
|
|
|
9354
9508
|
}
|
|
9355
9509
|
});
|
|
9356
9510
|
}
|
|
9357
|
-
var EXPORT_PRESETS = [
|
|
9358
|
-
{ id: "original", label: "Original", width: null, height: null },
|
|
9359
|
-
{ id: "ig-post", label: "Instagram post 1080\xD71080", width: 1080, height: 1080 },
|
|
9360
|
-
{ id: "ig-story", label: "Story 1080\xD71920", width: 1080, height: 1920 },
|
|
9361
|
-
{ id: "banner", label: "Banner 1200\xD7628", width: 1200, height: 628 }
|
|
9362
|
-
];
|
|
9363
|
-
async function buildExportZip(image, baseName, presetIds) {
|
|
9364
|
-
const zip = new JSZip();
|
|
9365
|
-
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
9366
|
-
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
9367
|
-
for (const p of chosen) {
|
|
9368
|
-
const buf = p.width && p.height ? await sharp21(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
9369
|
-
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
9370
|
-
}
|
|
9371
|
-
return zip.generateAsync({ type: "nodebuffer" });
|
|
9372
|
-
}
|
|
9373
9511
|
var slug = (v, fallback) => {
|
|
9374
9512
|
const s = String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
9375
9513
|
return s || fallback;
|
|
@@ -9511,96 +9649,6 @@ function readme(json, missing) {
|
|
|
9511
9649
|
${missing} referenced image${missing === 1 ? " was" : "s were"} missing and left out.` : ""
|
|
9512
9650
|
].join("\n");
|
|
9513
9651
|
}
|
|
9514
|
-
var THUMB_WIDTHS = [640, 160];
|
|
9515
|
-
var isThumbWidth = (w) => THUMB_WIDTHS.includes(w);
|
|
9516
|
-
var QUALITY = { 640: 82, 160: 75 };
|
|
9517
|
-
function createThumbStore(core, opts = {}) {
|
|
9518
|
-
const dir = join(core.home, "thumbs");
|
|
9519
|
-
let enabled = true;
|
|
9520
|
-
try {
|
|
9521
|
-
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
9522
|
-
} catch {
|
|
9523
|
-
enabled = false;
|
|
9524
|
-
}
|
|
9525
|
-
const pathFor = (hash, w) => join(dir, `${hash}-w${w}.webp`);
|
|
9526
|
-
const inflight = /* @__PURE__ */ new Map();
|
|
9527
|
-
const failed = /* @__PURE__ */ new Set();
|
|
9528
|
-
const concurrency = Math.max(1, opts.concurrency ?? 2);
|
|
9529
|
-
let active = 0;
|
|
9530
|
-
const waiting = [];
|
|
9531
|
-
const acquire = () => new Promise((resolve) => {
|
|
9532
|
-
if (active < concurrency) {
|
|
9533
|
-
active++;
|
|
9534
|
-
resolve();
|
|
9535
|
-
} else waiting.push(resolve);
|
|
9536
|
-
});
|
|
9537
|
-
const release = () => {
|
|
9538
|
-
const next = waiting.shift();
|
|
9539
|
-
if (next) next();
|
|
9540
|
-
else active--;
|
|
9541
|
-
};
|
|
9542
|
-
async function make(hash, w) {
|
|
9543
|
-
const final = pathFor(hash, w);
|
|
9544
|
-
const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
9545
|
-
await acquire();
|
|
9546
|
-
try {
|
|
9547
|
-
await sharp21(core.images.pathFor(hash)).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
|
|
9548
|
-
await rename(tmp, final);
|
|
9549
|
-
return final;
|
|
9550
|
-
} catch {
|
|
9551
|
-
await unlink(tmp).catch(() => {
|
|
9552
|
-
});
|
|
9553
|
-
failed.add(`${hash}-w${w}`);
|
|
9554
|
-
return null;
|
|
9555
|
-
} finally {
|
|
9556
|
-
release();
|
|
9557
|
-
}
|
|
9558
|
-
}
|
|
9559
|
-
return {
|
|
9560
|
-
dir,
|
|
9561
|
-
async ensure(hash, w) {
|
|
9562
|
-
if (!enabled || !/^[a-f0-9]{32}$/.test(hash)) return null;
|
|
9563
|
-
const key = `${hash}-w${w}`;
|
|
9564
|
-
if (failed.has(key)) return null;
|
|
9565
|
-
const final = pathFor(hash, w);
|
|
9566
|
-
try {
|
|
9567
|
-
await access(final);
|
|
9568
|
-
return final;
|
|
9569
|
-
} catch {
|
|
9570
|
-
}
|
|
9571
|
-
let job = inflight.get(key);
|
|
9572
|
-
if (!job) {
|
|
9573
|
-
job = make(hash, w).finally(() => inflight.delete(key));
|
|
9574
|
-
inflight.set(key, job);
|
|
9575
|
-
}
|
|
9576
|
-
return job;
|
|
9577
|
-
},
|
|
9578
|
-
warm(hash) {
|
|
9579
|
-
for (const w of THUMB_WIDTHS) void this.ensure(hash, w);
|
|
9580
|
-
},
|
|
9581
|
-
async settle() {
|
|
9582
|
-
await Promise.allSettled([...inflight.values()]);
|
|
9583
|
-
},
|
|
9584
|
-
clear() {
|
|
9585
|
-
failed.clear();
|
|
9586
|
-
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
9587
|
-
try {
|
|
9588
|
-
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
9589
|
-
} catch {
|
|
9590
|
-
enabled = false;
|
|
9591
|
-
}
|
|
9592
|
-
},
|
|
9593
|
-
stream: (path) => createReadStream(path)
|
|
9594
|
-
};
|
|
9595
|
-
}
|
|
9596
|
-
async function fileSize(path) {
|
|
9597
|
-
try {
|
|
9598
|
-
const s = await stat(path);
|
|
9599
|
-
return s.isFile() ? s.size : null;
|
|
9600
|
-
} catch {
|
|
9601
|
-
return null;
|
|
9602
|
-
}
|
|
9603
|
-
}
|
|
9604
9652
|
|
|
9605
9653
|
// src/routes/images.ts
|
|
9606
9654
|
var IMMUTABLE = "public, max-age=31536000, immutable";
|
|
@@ -9621,7 +9669,7 @@ function registerImageRoutes(app, deps) {
|
|
|
9621
9669
|
app.get("/api/images/:hash/thumb", async (req, reply) => {
|
|
9622
9670
|
const hash = String(req.params.hash);
|
|
9623
9671
|
const w = Number(req.query?.w);
|
|
9624
|
-
if (!isThumbWidth(w)) return reply.status(400).send({ error:
|
|
9672
|
+
if (!isThumbWidth(w)) return reply.status(400).send({ error: `w must be one of ${THUMB_WIDTH_LIST}` });
|
|
9625
9673
|
if (!/^[a-f0-9]{32}$/.test(hash)) return reply.status(404).send({ error: "image not found" });
|
|
9626
9674
|
const etag = `"${hash}-w${w}"`;
|
|
9627
9675
|
if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", IMMUTABLE).send();
|
|
@@ -9639,8 +9687,8 @@ function registerImageRoutes(app, deps) {
|
|
|
9639
9687
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
9640
9688
|
const buf = await part.toBuffer();
|
|
9641
9689
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
9642
|
-
const fmt = (await
|
|
9643
|
-
const png = fmt === "svg" ? await toMarkPng(buf) : await
|
|
9690
|
+
const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
|
|
9691
|
+
const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
|
|
9644
9692
|
return { hash: core.images.save(png) };
|
|
9645
9693
|
});
|
|
9646
9694
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -9651,7 +9699,6 @@ function registerImageRoutes(app, deps) {
|
|
|
9651
9699
|
const heatmapHash = core.images.save(d.heatmap);
|
|
9652
9700
|
return { score: d.score, heatmapHash, width: d.width, height: d.height };
|
|
9653
9701
|
});
|
|
9654
|
-
app.get("/api/export/presets", async () => EXPORT_PRESETS);
|
|
9655
9702
|
app.get("/api/brands/:id/export", async (req, reply) => {
|
|
9656
9703
|
const brandId = String(req.params.id);
|
|
9657
9704
|
if (!core.store.getBrand(brandId)) return reply.status(404).send({ error: "brand not found" });
|
|
@@ -9659,22 +9706,32 @@ function registerImageRoutes(app, deps) {
|
|
|
9659
9706
|
reply.header("content-type", "application/zip").header("content-disposition", `attachment; filename="${filename}"`);
|
|
9660
9707
|
return reply.send(zip);
|
|
9661
9708
|
});
|
|
9662
|
-
app.post("/api/export", async (req, reply) => {
|
|
9663
|
-
const { imageHash, presets, baseName = "scenri-export" } = req.body;
|
|
9664
|
-
if (!core.images.has(String(imageHash))) return reply.status(404).send({ error: "image not found" });
|
|
9665
|
-
const safeBase = String(baseName).replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 60) || "export";
|
|
9666
|
-
const zip = await buildExportZip(
|
|
9667
|
-
core.images.read(String(imageHash)),
|
|
9668
|
-
safeBase,
|
|
9669
|
-
Array.isArray(presets) ? presets.map(String) : []
|
|
9670
|
-
);
|
|
9671
|
-
reply.header("content-type", "application/zip").header("content-disposition", `attachment; filename="${safeBase}.zip"`);
|
|
9672
|
-
return reply.send(zip);
|
|
9673
|
-
});
|
|
9674
9709
|
}
|
|
9675
9710
|
|
|
9676
9711
|
// src/release/notes.data.ts
|
|
9677
9712
|
var RELEASES = [
|
|
9713
|
+
{
|
|
9714
|
+
version: "0.8.3",
|
|
9715
|
+
date: "2026-09-06",
|
|
9716
|
+
sections: [
|
|
9717
|
+
{
|
|
9718
|
+
heading: "Create",
|
|
9719
|
+
body: "The + beside the prompt is a picker for adding to the shot: products, presenters, scenes, brand colours, your logo and finished shots in one grid, with search, Upload image and paste. A tile pressed again takes its chip out, and every tab can make a new one of its own."
|
|
9720
|
+
},
|
|
9721
|
+
{
|
|
9722
|
+
heading: "Shots",
|
|
9723
|
+
body: "An open shot has the rest of the feed beside it as a rail, and its own history under the picture as a trail of tiles, the original and each refinement. A right click on the picture holds its actions, Download is one click, and Compare is gone."
|
|
9724
|
+
},
|
|
9725
|
+
{
|
|
9726
|
+
heading: "Refine",
|
|
9727
|
+
body: "Refining is the ask alone. The field names the picture it is about and follows the stage as you step, and a refinement is recorded as what you asked, not the references that rode along."
|
|
9728
|
+
},
|
|
9729
|
+
{
|
|
9730
|
+
heading: "Scenes",
|
|
9731
|
+
body: "A scene with nobody attached renders its set alone. No stand-in person appears."
|
|
9732
|
+
}
|
|
9733
|
+
]
|
|
9734
|
+
},
|
|
9678
9735
|
{
|
|
9679
9736
|
version: "0.8.2",
|
|
9680
9737
|
date: "2026-09-03",
|
|
@@ -10618,7 +10675,7 @@ function buildServer(opts) {
|
|
|
10618
10675
|
// Measured as stored (post-toMarkPng), so the scrape judges the same
|
|
10619
10676
|
// pixels the compiler will one day attach.
|
|
10620
10677
|
probeLongEdge: async (buf) => {
|
|
10621
|
-
const m = await
|
|
10678
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
10622
10679
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
10623
10680
|
},
|
|
10624
10681
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -10707,7 +10764,7 @@ function buildServer(opts) {
|
|
|
10707
10764
|
fetchImpl: opts.fetchImpl,
|
|
10708
10765
|
saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
|
|
10709
10766
|
probeLongEdge: async (buf) => {
|
|
10710
|
-
const m = await
|
|
10767
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
10711
10768
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
10712
10769
|
},
|
|
10713
10770
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -10802,14 +10859,14 @@ function buildServer(opts) {
|
|
|
10802
10859
|
});
|
|
10803
10860
|
registerCatalogImportRoutes(app, { core, fetchImpl: opts.fetchImpl });
|
|
10804
10861
|
const templatesRoot = opts.templatesDir ?? defaultScenesDir();
|
|
10805
|
-
registerSceneRoutes(app, { templatesRoot, scenes });
|
|
10862
|
+
registerSceneRoutes(app, { templatesRoot, scenes, thumbs });
|
|
10806
10863
|
const presentersDir = join(templatesRoot, "presenters");
|
|
10807
10864
|
const { presenters } = loadPresenters(presentersDir);
|
|
10808
|
-
registerPresenterRoutes(app, { templatesRoot, presenters });
|
|
10865
|
+
registerPresenterRoutes(app, { templatesRoot, presenters, thumbs });
|
|
10809
10866
|
registerAssetBuildRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters });
|
|
10810
10867
|
const { demoProducts } = loadDemoProducts(join(templatesRoot, "demo-products"));
|
|
10811
10868
|
const demoProductById = demoProductResolver(demoProducts);
|
|
10812
|
-
registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
|
|
10869
|
+
registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById, thumbs });
|
|
10813
10870
|
registerShowcaseRoutes(app, { templatesRoot });
|
|
10814
10871
|
app.get("/api/formats", async () => FORMATS);
|
|
10815
10872
|
function briefInputsOnly(brief) {
|
|
@@ -11080,11 +11137,11 @@ function buildServer(opts) {
|
|
|
11080
11137
|
const out = [];
|
|
11081
11138
|
for (const h of images) {
|
|
11082
11139
|
const buf = core.images.read(h);
|
|
11083
|
-
const meta2 = await
|
|
11140
|
+
const meta2 = await sharp20(buf).metadata().catch(() => null);
|
|
11084
11141
|
if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
|
|
11085
11142
|
const oriented = (meta2.orientation ?? 1) !== 1;
|
|
11086
11143
|
out.push(
|
|
11087
|
-
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await
|
|
11144
|
+
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
|
|
11088
11145
|
);
|
|
11089
11146
|
}
|
|
11090
11147
|
return out;
|
|
@@ -11096,7 +11153,7 @@ function buildServer(opts) {
|
|
|
11096
11153
|
const out = [];
|
|
11097
11154
|
for (const h of images) {
|
|
11098
11155
|
const buf = core.images.read(h);
|
|
11099
|
-
const meta2 = await
|
|
11156
|
+
const meta2 = await sharp20(buf).metadata();
|
|
11100
11157
|
if (!meta2.width || !meta2.height) {
|
|
11101
11158
|
out.push(h);
|
|
11102
11159
|
continue;
|
|
@@ -11109,7 +11166,7 @@ function buildServer(opts) {
|
|
|
11109
11166
|
}
|
|
11110
11167
|
const w = got > target ? Math.round(meta2.height * target) : meta2.width;
|
|
11111
11168
|
const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
|
|
11112
|
-
const cropped = await
|
|
11169
|
+
const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
11113
11170
|
app.log.info(
|
|
11114
11171
|
{ nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
|
|
11115
11172
|
"canvas: cropped a drifted frame to the asked ratio"
|
|
@@ -11128,7 +11185,7 @@ function buildServer(opts) {
|
|
|
11128
11185
|
async function assertAspect(images, expect) {
|
|
11129
11186
|
const want = expect.width / expect.height;
|
|
11130
11187
|
for (const h of images) {
|
|
11131
|
-
const meta2 = await
|
|
11188
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
11132
11189
|
if (!meta2.width || !meta2.height) continue;
|
|
11133
11190
|
const got = meta2.width / meta2.height;
|
|
11134
11191
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -11159,7 +11216,7 @@ function buildServer(opts) {
|
|
|
11159
11216
|
if (post) own = await post(own);
|
|
11160
11217
|
if (expect) await assertAspect(own, expect);
|
|
11161
11218
|
try {
|
|
11162
|
-
const meta2 = await
|
|
11219
|
+
const meta2 = await sharp20(core.images.read(own[0])).metadata();
|
|
11163
11220
|
const node = core.store.getNode(id);
|
|
11164
11221
|
if (node && meta2.width && meta2.height) {
|
|
11165
11222
|
const brief = node.brief ?? {};
|
|
@@ -11268,7 +11325,7 @@ function buildServer(opts) {
|
|
|
11268
11325
|
crop: window
|
|
11269
11326
|
});
|
|
11270
11327
|
const work2 = async () => ({
|
|
11271
|
-
images: [core.images.save(await
|
|
11328
|
+
images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
|
|
11272
11329
|
costUsd: 0
|
|
11273
11330
|
});
|
|
11274
11331
|
void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
@@ -11293,7 +11350,7 @@ function buildServer(opts) {
|
|
|
11293
11350
|
if (!srcHash || !core.images.has(String(srcHash)))
|
|
11294
11351
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
11295
11352
|
const srcBuf = core.images.read(String(srcHash));
|
|
11296
|
-
const srcMeta = await
|
|
11353
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
11297
11354
|
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
11298
11355
|
return runCropNode({
|
|
11299
11356
|
parentId: cropParentId,
|
|
@@ -11342,7 +11399,7 @@ function buildServer(opts) {
|
|
|
11342
11399
|
);
|
|
11343
11400
|
extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
|
|
11344
11401
|
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
11345
|
-
return reply.status(400).send({ error: "the
|
|
11402
|
+
return reply.status(400).send({ error: "the prompt is empty" });
|
|
11346
11403
|
} else {
|
|
11347
11404
|
const brandJson = await brandJsonWithIdentityCrops(
|
|
11348
11405
|
core,
|
|
@@ -11370,7 +11427,7 @@ function buildServer(opts) {
|
|
|
11370
11427
|
template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
|
|
11371
11428
|
templateById: sceneById
|
|
11372
11429
|
});
|
|
11373
|
-
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the
|
|
11430
|
+
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the prompt is empty" });
|
|
11374
11431
|
}
|
|
11375
11432
|
}
|
|
11376
11433
|
let finalPrompt = String(prompt ?? "");
|
|
@@ -11409,7 +11466,7 @@ function buildServer(opts) {
|
|
|
11409
11466
|
});
|
|
11410
11467
|
if (productId && !compiled2.attachments.some((a) => a.role === "product"))
|
|
11411
11468
|
return reply.status(400).send({ error: "product has no usable shots" });
|
|
11412
|
-
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the
|
|
11469
|
+
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the prompt is empty" });
|
|
11413
11470
|
}
|
|
11414
11471
|
let estimate;
|
|
11415
11472
|
let work;
|
|
@@ -11508,7 +11565,7 @@ function buildServer(opts) {
|
|
|
11508
11565
|
);
|
|
11509
11566
|
}
|
|
11510
11567
|
const srcBuf = core.images.read(String(srcHash));
|
|
11511
|
-
const srcMeta = await
|
|
11568
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
11512
11569
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
11513
11570
|
const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
|
|
11514
11571
|
const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
|
|
@@ -11542,7 +11599,7 @@ function buildServer(opts) {
|
|
|
11542
11599
|
} else if (decision.op === "extend") {
|
|
11543
11600
|
if (decision.assist) {
|
|
11544
11601
|
expandAssist = { width: decision.assist.width, height: decision.assist.height };
|
|
11545
|
-
workBuf = await
|
|
11602
|
+
workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
|
|
11546
11603
|
workSize = { width: decision.assist.width, height: decision.assist.height };
|
|
11547
11604
|
}
|
|
11548
11605
|
expandPlan = planExpand(workSize, targetRatio);
|
|
@@ -11563,7 +11620,7 @@ function buildServer(opts) {
|
|
|
11563
11620
|
const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
|
|
11564
11621
|
if (fit.scale < 1) {
|
|
11565
11622
|
expandPlan = fit.plan;
|
|
11566
|
-
workBuf = await
|
|
11623
|
+
workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
|
|
11567
11624
|
workSize = fit.source;
|
|
11568
11625
|
extraWarnings.push(
|
|
11569
11626
|
`${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
|
|
@@ -11586,7 +11643,7 @@ function buildServer(opts) {
|
|
|
11586
11643
|
if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
|
|
11587
11644
|
sentSize = stepped;
|
|
11588
11645
|
budgetSourceHash = core.images.save(
|
|
11589
|
-
await
|
|
11646
|
+
await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
|
|
11590
11647
|
);
|
|
11591
11648
|
if (!gradeOnlyAsk)
|
|
11592
11649
|
extraWarnings.push(
|
|
@@ -11726,11 +11783,11 @@ function buildServer(opts) {
|
|
|
11726
11783
|
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
11727
11784
|
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
11728
11785
|
const enforceEditCanvas = async (images) => {
|
|
11729
|
-
const srcMeta = await
|
|
11786
|
+
const srcMeta = await sharp20(original).metadata();
|
|
11730
11787
|
if (!srcMeta.width || !srcMeta.height) return images;
|
|
11731
11788
|
const out = [];
|
|
11732
11789
|
for (const h of images) {
|
|
11733
|
-
const meta2 = await
|
|
11790
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
11734
11791
|
const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
|
|
11735
11792
|
const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
|
|
11736
11793
|
pixelBudget: runEngine.capabilities().editPixelBudget
|
|
@@ -11760,7 +11817,7 @@ function buildServer(opts) {
|
|
|
11760
11817
|
);
|
|
11761
11818
|
out.push(
|
|
11762
11819
|
core.images.save(
|
|
11763
|
-
await
|
|
11820
|
+
await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
|
|
11764
11821
|
)
|
|
11765
11822
|
);
|
|
11766
11823
|
try {
|
|
@@ -11782,7 +11839,7 @@ function buildServer(opts) {
|
|
|
11782
11839
|
const out = [];
|
|
11783
11840
|
for (const h of images) {
|
|
11784
11841
|
const answer = core.images.read(h);
|
|
11785
|
-
const got = await
|
|
11842
|
+
const got = await sharp20(answer).metadata();
|
|
11786
11843
|
if (got.width !== plan.width || got.height !== plan.height)
|
|
11787
11844
|
app.log.info(
|
|
11788
11845
|
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
@@ -12118,8 +12175,8 @@ async function verify() {
|
|
|
12118
12175
|
const db = new Database2(":memory:");
|
|
12119
12176
|
db.pragma("user_version");
|
|
12120
12177
|
db.close();
|
|
12121
|
-
const { default:
|
|
12122
|
-
await
|
|
12178
|
+
const { default: sharp21 } = await import('sharp');
|
|
12179
|
+
await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
12123
12180
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
12124
12181
|
} catch (err) {
|
|
12125
12182
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|