scenri 0.4.7 → 0.5.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 +48 -0
- package/dist/serve.js +750 -145
- package/package.json +1 -1
- package/studio-dist/assets/index-BCsZlYNL.js +103 -0
- package/studio-dist/assets/{index-C8G-udTA.css → index-DdyktISx.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index--uXeZKHA.js +0 -102
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, mkdtemp, rm, readdir, stat, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
|
-
import
|
|
13
|
+
import sharp7 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
15
15
|
import fastifyStatic from '@fastify/static';
|
|
16
16
|
import fastifyMultipart from '@fastify/multipart';
|
|
@@ -643,14 +643,9 @@ function createStore(db) {
|
|
|
643
643
|
if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
|
|
644
644
|
}
|
|
645
645
|
const id = randomUUID();
|
|
646
|
-
db.prepare(
|
|
647
|
-
id,
|
|
648
|
-
|
|
649
|
-
input.parentId,
|
|
650
|
-
input.kind,
|
|
651
|
-
input.prompt,
|
|
652
|
-
input.engineId
|
|
653
|
-
);
|
|
646
|
+
db.prepare(
|
|
647
|
+
"INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at) VALUES (?,?,?,?,?,?, strftime('%Y-%m-%d %H:%M:%f','now'))"
|
|
648
|
+
).run(id, input.projectId, input.parentId, input.kind, input.prompt, input.engineId);
|
|
654
649
|
return this.getNode(id);
|
|
655
650
|
},
|
|
656
651
|
completeNode(id, result) {
|
|
@@ -672,7 +667,7 @@ function createStore(db) {
|
|
|
672
667
|
return r ? rowToNode(r) : null;
|
|
673
668
|
},
|
|
674
669
|
treeFor(projectId) {
|
|
675
|
-
return db.prepare("SELECT * FROM nodes WHERE project_id=? ORDER BY created_at").all(projectId).map(
|
|
670
|
+
return db.prepare("SELECT * FROM nodes WHERE project_id=? ORDER BY created_at, id").all(projectId).map(
|
|
676
671
|
rowToNode
|
|
677
672
|
);
|
|
678
673
|
},
|
|
@@ -697,7 +692,7 @@ function createStore(db) {
|
|
|
697
692
|
WHERE p.brand_id = ?
|
|
698
693
|
AND n.kind != 'root'
|
|
699
694
|
AND (n.status = 'running' OR n.created_at >= datetime('now', '-2 days'))
|
|
700
|
-
ORDER BY n.created_at DESC
|
|
695
|
+
ORDER BY n.created_at DESC, n.id DESC
|
|
701
696
|
LIMIT ?`
|
|
702
697
|
).all(brandId, limit);
|
|
703
698
|
return rows.map((r) => ({
|
|
@@ -1491,6 +1486,7 @@ function createOpenRouterEngine(opts) {
|
|
|
1491
1486
|
var API_BASE = "https://api.replicate.com/v1";
|
|
1492
1487
|
var DEFAULT_MODEL2 = "black-forest-labs/flux-schnell";
|
|
1493
1488
|
var DEFAULT_EDIT_MODEL = "black-forest-labs/flux-kontext-pro";
|
|
1489
|
+
var DEFAULT_EXPAND_MODEL = "bria/expand-image";
|
|
1494
1490
|
var DEFAULT_POLL_INTERVAL_MS = 1500;
|
|
1495
1491
|
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
1496
1492
|
var GENERATE_COST_PER_IMAGE_USD = 3e-3;
|
|
@@ -1552,6 +1548,7 @@ function createReplicateEngine(opts) {
|
|
|
1552
1548
|
fetchImpl = globalThis.fetch,
|
|
1553
1549
|
model = DEFAULT_MODEL2,
|
|
1554
1550
|
editModel = DEFAULT_EDIT_MODEL,
|
|
1551
|
+
expandModel = DEFAULT_EXPAND_MODEL,
|
|
1555
1552
|
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
1556
1553
|
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
1557
1554
|
} = opts;
|
|
@@ -1646,6 +1643,8 @@ function createReplicateEngine(opts) {
|
|
|
1646
1643
|
localOnly: false,
|
|
1647
1644
|
supportsEdit: true,
|
|
1648
1645
|
supportsMask: false,
|
|
1646
|
+
// bria/expand-image: the picture plus the canvas it belongs in.
|
|
1647
|
+
supportsOutpaint: true,
|
|
1649
1648
|
// 0, deliberately — see the same note in the fal adapter. generate()
|
|
1650
1649
|
// sends only prompt/num_outputs/aspect_ratio, so a declared capacity
|
|
1651
1650
|
// of 1 was a promise this adapter never kept.
|
|
@@ -1684,12 +1683,30 @@ function createReplicateEngine(opts) {
|
|
|
1684
1683
|
async edit(req, signal) {
|
|
1685
1684
|
const key = requireKey();
|
|
1686
1685
|
const file = await readFile(req.sourceImage);
|
|
1686
|
+
const dataUri = `data:image/png;base64,${file.toString("base64")}`;
|
|
1687
|
+
if (req.expand && req.width && req.height) {
|
|
1688
|
+
const started = await createPrediction(
|
|
1689
|
+
key,
|
|
1690
|
+
expandModel,
|
|
1691
|
+
{
|
|
1692
|
+
image_url: dataUri,
|
|
1693
|
+
canvas_size: [req.width, req.height],
|
|
1694
|
+
original_image_size: [req.expand.width, req.expand.height],
|
|
1695
|
+
original_image_location: [req.expand.left, req.expand.top],
|
|
1696
|
+
...req.instruction.trim() ? { prompt: req.instruction } : {},
|
|
1697
|
+
...typeof req.seed === "number" ? { seed: req.seed } : {}
|
|
1698
|
+
},
|
|
1699
|
+
signal
|
|
1700
|
+
);
|
|
1701
|
+
const done = await waitForCompletion(key, started, signal);
|
|
1702
|
+
return { images: await downloadOutputs(done, signal), costUsd: EDIT_COST_USD, raw: done };
|
|
1703
|
+
}
|
|
1687
1704
|
const created = await createPrediction(
|
|
1688
1705
|
key,
|
|
1689
1706
|
editModel,
|
|
1690
1707
|
{
|
|
1691
1708
|
prompt: req.instruction,
|
|
1692
|
-
input_image:
|
|
1709
|
+
input_image: dataUri
|
|
1693
1710
|
},
|
|
1694
1711
|
signal
|
|
1695
1712
|
);
|
|
@@ -1705,6 +1722,7 @@ function createReplicateEngine(opts) {
|
|
|
1705
1722
|
}
|
|
1706
1723
|
var DEFAULT_MODEL3 = "fal-ai/flux/schnell";
|
|
1707
1724
|
var DEFAULT_EDIT_MODEL2 = "fal-ai/flux-kontext/dev";
|
|
1725
|
+
var DEFAULT_EXPAND_MODEL2 = "fal-ai/bria/expand";
|
|
1708
1726
|
var GENERATE_COST_PER_IMAGE_USD2 = 3e-3;
|
|
1709
1727
|
var EDIT_COST_USD2 = 0.025;
|
|
1710
1728
|
function snippetOf(text) {
|
|
@@ -1754,6 +1772,7 @@ function createFalEngine(opts) {
|
|
|
1754
1772
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
1755
1773
|
const model = opts.model ?? DEFAULT_MODEL3;
|
|
1756
1774
|
const editModel = opts.editModel ?? DEFAULT_EDIT_MODEL2;
|
|
1775
|
+
const expandModel = opts.expandModel ?? DEFAULT_EXPAND_MODEL2;
|
|
1757
1776
|
function requireKey() {
|
|
1758
1777
|
const key = opts.getKey();
|
|
1759
1778
|
if (!key) throw new Error("fal.ai API key not set. Set a fal.ai key in Settings.");
|
|
@@ -1811,6 +1830,8 @@ function createFalEngine(opts) {
|
|
|
1811
1830
|
localOnly: false,
|
|
1812
1831
|
supportsEdit: true,
|
|
1813
1832
|
supportsMask: false,
|
|
1833
|
+
// bria/expand: the picture plus where it sits, margin only.
|
|
1834
|
+
supportsOutpaint: true,
|
|
1814
1835
|
// 0, deliberately. This adapter's generate() sends only prompt/size/
|
|
1815
1836
|
// count — it has never forwarded a reference image to the model. It
|
|
1816
1837
|
// previously advertised 1, so compileBrief would resolve a product
|
|
@@ -1849,6 +1870,26 @@ function createFalEngine(opts) {
|
|
|
1849
1870
|
async edit(req, signal) {
|
|
1850
1871
|
const key = requireKey();
|
|
1851
1872
|
const imageUrl = await sourceImageToDataUri(req.sourceImage);
|
|
1873
|
+
if (req.expand && req.width && req.height) {
|
|
1874
|
+
const json2 = await postJson(
|
|
1875
|
+
expandModel,
|
|
1876
|
+
key,
|
|
1877
|
+
{
|
|
1878
|
+
image_url: imageUrl,
|
|
1879
|
+
canvas_size: [req.width, req.height],
|
|
1880
|
+
original_image_size: [req.expand.width, req.expand.height],
|
|
1881
|
+
original_image_location: [req.expand.left, req.expand.top],
|
|
1882
|
+
...req.instruction.trim() ? { prompt: req.instruction } : {},
|
|
1883
|
+
// Same picture, same shape, same margin: an extend a user runs
|
|
1884
|
+
// twice should not be a different picture twice.
|
|
1885
|
+
...typeof req.seed === "number" ? { seed: req.seed } : {}
|
|
1886
|
+
},
|
|
1887
|
+
signal
|
|
1888
|
+
);
|
|
1889
|
+
const urls2 = extractImageUrls(json2);
|
|
1890
|
+
const images2 = await saveAll(urls2, signal);
|
|
1891
|
+
return { images: images2, costUsd: EDIT_COST_USD2, raw: json2 };
|
|
1892
|
+
}
|
|
1852
1893
|
const json = await postJson(editModel, key, { prompt: req.instruction, image_url: imageUrl }, signal);
|
|
1853
1894
|
const urls = extractImageUrls(json);
|
|
1854
1895
|
const images = await saveAll(urls, signal);
|
|
@@ -2508,7 +2549,14 @@ function createCodexEngine(opts) {
|
|
|
2508
2549
|
} finally {
|
|
2509
2550
|
signal?.removeEventListener("abort", onOuterAbort);
|
|
2510
2551
|
}
|
|
2511
|
-
const images =
|
|
2552
|
+
const images = [];
|
|
2553
|
+
const variantIndexes = [];
|
|
2554
|
+
for (const [i, slot] of results.entries()) {
|
|
2555
|
+
for (const hash of slot ?? []) {
|
|
2556
|
+
images.push(hash);
|
|
2557
|
+
variantIndexes.push(i);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2512
2560
|
if (!images.length && failures.length) throw fatal ?? failures[0];
|
|
2513
2561
|
if (failures.length) {
|
|
2514
2562
|
console.warn(
|
|
@@ -2517,7 +2565,11 @@ function createCodexEngine(opts) {
|
|
|
2517
2565
|
return {
|
|
2518
2566
|
images,
|
|
2519
2567
|
costUsd: 0,
|
|
2520
|
-
raw: {
|
|
2568
|
+
raw: {
|
|
2569
|
+
requested: count,
|
|
2570
|
+
variantIndexes,
|
|
2571
|
+
partialFailures: failures.map((f) => String(f?.message ?? f))
|
|
2572
|
+
}
|
|
2521
2573
|
};
|
|
2522
2574
|
}
|
|
2523
2575
|
return { images, costUsd: 0 };
|
|
@@ -2534,7 +2586,9 @@ function createCodexEngine(opts) {
|
|
|
2534
2586
|
await copyFile(editRefs[i], join(dir, name));
|
|
2535
2587
|
refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
|
|
2536
2588
|
}
|
|
2537
|
-
const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it)
|
|
2589
|
+
const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it).` + // sips is already licensed by the parenthesis above; an exact-size
|
|
2590
|
+
// answer lets the caller's compositing pass skip its rescale.
|
|
2591
|
+
(req.width && req.height ? ` Save the result at exactly ${req.width}x${req.height} pixels.` : "") + ` Nothing else.`;
|
|
2538
2592
|
const args = execArgs(dir);
|
|
2539
2593
|
for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
|
|
2540
2594
|
args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
|
|
@@ -2607,7 +2661,7 @@ function createDemoEngine(saveImage) {
|
|
|
2607
2661
|
<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>
|
|
2608
2662
|
<text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
|
|
2609
2663
|
</svg>`;
|
|
2610
|
-
return
|
|
2664
|
+
return sharp7(Buffer.from(svg)).png().toBuffer();
|
|
2611
2665
|
}
|
|
2612
2666
|
return {
|
|
2613
2667
|
capabilities() {
|
|
@@ -2617,6 +2671,11 @@ function createDemoEngine(saveImage) {
|
|
|
2617
2671
|
localOnly: false,
|
|
2618
2672
|
supportsEdit: true,
|
|
2619
2673
|
supportsMask: false,
|
|
2674
|
+
// It draws a placeholder for everything, expansions included, so it
|
|
2675
|
+
// stands in for a capable engine rather than blocking the path in
|
|
2676
|
+
// development and in the end-to-end suite. `placeholder` below is what
|
|
2677
|
+
// says none of this is a real picture.
|
|
2678
|
+
supportsOutpaint: true,
|
|
2620
2679
|
maxReferenceImages: 0,
|
|
2621
2680
|
placeholder: true
|
|
2622
2681
|
};
|
|
@@ -2770,7 +2829,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
|
2770
2829
|
for (const [slot, angle] of PRESENTER_ANGLES) {
|
|
2771
2830
|
const path = presenterRefPath(templatesRoot, presenter.id, slot);
|
|
2772
2831
|
if (!existsSync(path)) continue;
|
|
2773
|
-
const png = await
|
|
2832
|
+
const png = await sharp7(readFileSync(path)).png().toBuffer();
|
|
2774
2833
|
const hash = core.images.save(png);
|
|
2775
2834
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
2776
2835
|
}
|
|
@@ -2864,7 +2923,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
|
|
|
2864
2923
|
for (const angle of angles) {
|
|
2865
2924
|
const path = demoProductRefPath(templatesRoot, product.id, angle);
|
|
2866
2925
|
if (!existsSync(path)) continue;
|
|
2867
|
-
const png = await
|
|
2926
|
+
const png = await sharp7(readFileSync(path)).png().toBuffer();
|
|
2868
2927
|
const hash = core.images.save(png);
|
|
2869
2928
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
2870
2929
|
}
|
|
@@ -2913,6 +2972,67 @@ function defaultDemoProductsDir() {
|
|
|
2913
2972
|
return join(here, "..", "..", "..", "templates", "demo-products");
|
|
2914
2973
|
}
|
|
2915
2974
|
|
|
2975
|
+
// src/attachmentBudget.ts
|
|
2976
|
+
var ROLE_PRIORITY = {
|
|
2977
|
+
product: 0,
|
|
2978
|
+
character: 1,
|
|
2979
|
+
brand: 2,
|
|
2980
|
+
scene: 3,
|
|
2981
|
+
composition: 4,
|
|
2982
|
+
reference: 5,
|
|
2983
|
+
style: 6
|
|
2984
|
+
};
|
|
2985
|
+
function allocateAttachments(attachments, cap2) {
|
|
2986
|
+
const max = Math.max(0, cap2);
|
|
2987
|
+
const indexed = attachments.map((a, i) => ({ a, i }));
|
|
2988
|
+
const legacyOrder = [...indexed].sort(
|
|
2989
|
+
(x, y) => Number(!!y.a.essential) - Number(!!x.a.essential) || ROLE_PRIORITY[x.a.role] - ROLE_PRIORITY[y.a.role] || x.i - y.i
|
|
2990
|
+
);
|
|
2991
|
+
const groupOf = (a) => `${a.role}:${a.id ?? a.hash}`;
|
|
2992
|
+
const kept = /* @__PURE__ */ new Set();
|
|
2993
|
+
const keptGroups = /* @__PURE__ */ new Set();
|
|
2994
|
+
const admit = (x) => {
|
|
2995
|
+
kept.add(x.i);
|
|
2996
|
+
keptGroups.add(groupOf(x.a));
|
|
2997
|
+
};
|
|
2998
|
+
for (const x of legacyOrder) {
|
|
2999
|
+
if (kept.size >= max) break;
|
|
3000
|
+
if (x.a.essential) admit(x);
|
|
3001
|
+
}
|
|
3002
|
+
for (const x of legacyOrder) {
|
|
3003
|
+
if (kept.size >= max) break;
|
|
3004
|
+
if (!kept.has(x.i) && !keptGroups.has(groupOf(x.a))) admit(x);
|
|
3005
|
+
}
|
|
3006
|
+
const queues = /* @__PURE__ */ new Map();
|
|
3007
|
+
for (const x of legacyOrder) {
|
|
3008
|
+
if (kept.has(x.i)) continue;
|
|
3009
|
+
const g = groupOf(x.a);
|
|
3010
|
+
if (!queues.has(g)) queues.set(g, []);
|
|
3011
|
+
queues.get(g).push(x);
|
|
3012
|
+
}
|
|
3013
|
+
let admitted = true;
|
|
3014
|
+
while (kept.size < max && admitted) {
|
|
3015
|
+
admitted = false;
|
|
3016
|
+
for (const queue of queues.values()) {
|
|
3017
|
+
if (kept.size >= max) break;
|
|
3018
|
+
const x = queue.shift();
|
|
3019
|
+
if (x) {
|
|
3020
|
+
admit(x);
|
|
3021
|
+
admitted = true;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
return {
|
|
3026
|
+
kept: legacyOrder.filter((x) => kept.has(x.i)).map((x) => x.a),
|
|
3027
|
+
dropped: legacyOrder.filter((x) => !kept.has(x.i)).map((x) => x.a)
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
function mergeEditAttachments(own, inherited, cap2) {
|
|
3031
|
+
const seen = new Set(own.map((a) => a.hash));
|
|
3032
|
+
const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
|
|
3033
|
+
return allocateAttachments([...own, ...borrowed], cap2);
|
|
3034
|
+
}
|
|
3035
|
+
|
|
2916
3036
|
// src/briefDirectives.ts
|
|
2917
3037
|
function productFidelityDirective(attached) {
|
|
2918
3038
|
if (attached <= 1) {
|
|
@@ -2934,7 +3054,7 @@ function garmentDisplayDirective() {
|
|
|
2934
3054
|
return "No person is part of this brief. Present the garment as a product, laid, hung, folded or dressed on a plain form, never on a person, a partial figure or an invisible body, unless the direction above explicitly asks for it worn.";
|
|
2935
3055
|
}
|
|
2936
3056
|
function shotSpecifiesCamera(text) {
|
|
2937
|
-
return /\b\d{2,3}\s?mm\b|\bf\/\d|\blens\b|\bcamera\b|\bshot from\b|\beye[- ]level\b|\blow angle\b|\bhigh angle\b|\boverhead\b|\btop[- ]down\b|\bbird'?s[- ]eye\b|\bclose[- ]up\b|\bmacro\b|\bwide shot\b|\bcrop(?:ped)?\b|\bframing\b|\bdepth of field\b|\bbokeh\b|\bshallow (?:focus|depth)\b|\bdeep focus\b/i.test(
|
|
3057
|
+
return /\b\d{2,3}\s?mm\b|\bf\/\d|\blens\b|\bcamera\b|\bshot from\b|\beye[- ]level\b|\blow angle\b|\bhigh angle\b|\boverhead\b|\btop[- ]down\b|\bbird'?s[- ]eye\b|\bclose[- ]?up\b|\bmacro\b|\bwide shot\b|\bcrop(?:ped)?\b|\bframing\b|\bDOF\b|\bdepth of field\b|\bbokeh\b|\bshallow (?:focus|depth)\b|\bdeep focus\b/i.test(
|
|
2938
3058
|
text
|
|
2939
3059
|
);
|
|
2940
3060
|
}
|
|
@@ -2949,6 +3069,9 @@ function sceneGuardDirectives(opts) {
|
|
|
2949
3069
|
out.push(
|
|
2950
3070
|
"Disregard any wardrobe, accessory, or garment brand named in the scene direction above \u2014 dress the attached person reference only in the generic material and color terms described; do not print, stitch, or render any brand name or wordmark from the scene text onto them."
|
|
2951
3071
|
);
|
|
3072
|
+
out.push(
|
|
3073
|
+
"The scene direction above describes the set, not the cast. If it says the space is empty or that no people appear, disregard that: the attached person stands in this set, clearly visible. A ban on props or extra objects in the scene direction is about set dressing only \u2014 it never applies to the presenter or to the product in their hands."
|
|
3074
|
+
);
|
|
2952
3075
|
}
|
|
2953
3076
|
return out;
|
|
2954
3077
|
}
|
|
@@ -3099,6 +3222,9 @@ function compileBrief(brief, ctx) {
|
|
|
3099
3222
|
chashes.forEach((chash, i) => {
|
|
3100
3223
|
attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
|
|
3101
3224
|
});
|
|
3225
|
+
personDirectives.push(
|
|
3226
|
+
`${c.promptName ?? c.name} is in this photograph: a real person, clearly visible in the frame. Do not leave them out, crop them out, or reduce them to a reflection or a shadow.`
|
|
3227
|
+
);
|
|
3102
3228
|
personDirectives.push(
|
|
3103
3229
|
"The attached person reference is the same person every time: match their face, facial structure, skin, hair and build exactly. Their outfit, pose, background and lighting are neutral studio capture conditions, not styling direction: dress and style them for this shot, to a commercial standard, following any wardrobe the direction itself specifies. Where the direction specifies none, dress them for the place and the occasion the frame shows, and never return them to the plain base layers they were photographed in."
|
|
3104
3230
|
);
|
|
@@ -3199,11 +3325,21 @@ function compileBrief(brief, ctx) {
|
|
|
3199
3325
|
const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
|
|
3200
3326
|
const guard = scene ? sceneGuardDirectives({ hasProduct: !!productId, hasPerson }) : [];
|
|
3201
3327
|
const pairDirectives = productId && hasPerson ? [
|
|
3202
|
-
"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."
|
|
3328
|
+
"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.",
|
|
3329
|
+
// A product's own notes are written for solo packshots, and several
|
|
3330
|
+
// catalog records literally say "no props, hands, or presenter in
|
|
3331
|
+
// frame". That data already shipped, so the override lives here in
|
|
3332
|
+
// the compiler: fired only when a person is attached, worded like
|
|
3333
|
+
// the mark exception — the ban stays for invented people, the
|
|
3334
|
+
// attached one is deliberate.
|
|
3335
|
+
"Any earlier instruction that bans props, hands, people, or a presenter from the frame is a solo-packshot rule for this product and does not apply to this shot: the attached presenter is deliberate and must appear as directed."
|
|
3336
|
+
] : [];
|
|
3337
|
+
const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
|
|
3338
|
+
"The tight framing includes the presenter: keep at least their hand in genuine contact with the product, and as much more of them as the crop allows. A close-up is never a reason to leave the person out of the photograph."
|
|
3203
3339
|
] : [];
|
|
3204
3340
|
const brandLines = brandRuleDirectives(ctx.brand);
|
|
3205
3341
|
const preservation = ctx.mode === "edit" ? [
|
|
3206
|
-
editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval }),
|
|
3342
|
+
...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
|
|
3207
3343
|
...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
|
|
3208
3344
|
] : [];
|
|
3209
3345
|
const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
|
|
@@ -3215,6 +3351,7 @@ function compileBrief(brief, ctx) {
|
|
|
3215
3351
|
...productDirectives,
|
|
3216
3352
|
...personDirectives,
|
|
3217
3353
|
...pairDirectives,
|
|
3354
|
+
...closeUpDirectives,
|
|
3218
3355
|
...otherDirectives,
|
|
3219
3356
|
...cameraDirectives,
|
|
3220
3357
|
...apparelUnworn,
|
|
@@ -3223,25 +3360,8 @@ function compileBrief(brief, ctx) {
|
|
|
3223
3360
|
...preservation
|
|
3224
3361
|
];
|
|
3225
3362
|
if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
|
|
3226
|
-
const ROLE_PRIORITY = {
|
|
3227
|
-
product: 0,
|
|
3228
|
-
character: 1,
|
|
3229
|
-
brand: 2,
|
|
3230
|
-
scene: 3,
|
|
3231
|
-
composition: 4,
|
|
3232
|
-
reference: 5,
|
|
3233
|
-
style: 6
|
|
3234
|
-
};
|
|
3235
|
-
const ordered = attachments.map((a, i) => ({ a, i })).sort(
|
|
3236
|
-
(x, y) => (
|
|
3237
|
-
// Essential identity first, so a tight cap sheds extra product angles
|
|
3238
|
-
// and style references before it sheds a subject entirely.
|
|
3239
|
-
Number(!!y.a.essential) - Number(!!x.a.essential) || ROLE_PRIORITY[x.a.role] - ROLE_PRIORITY[y.a.role] || x.i - y.i
|
|
3240
|
-
)
|
|
3241
|
-
).map((x) => x.a);
|
|
3242
3363
|
const max = ctx.engineCaps.maxReferenceImages;
|
|
3243
|
-
const kept =
|
|
3244
|
-
const dropped = ordered.slice(kept.length);
|
|
3364
|
+
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
3245
3365
|
if (dropped.length) {
|
|
3246
3366
|
const names = [...new Set(dropped.map((d) => d.label))];
|
|
3247
3367
|
warnings.push(
|
|
@@ -5213,8 +5333,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
5213
5333
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
5214
5334
|
return;
|
|
5215
5335
|
}
|
|
5216
|
-
const png = await
|
|
5217
|
-
const meta = await
|
|
5336
|
+
const png = await sharp7(buf).rotate().png().toBuffer();
|
|
5337
|
+
const meta = await sharp7(png).metadata();
|
|
5218
5338
|
const hash = core.images.save(png);
|
|
5219
5339
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
5220
5340
|
width: meta.width,
|
|
@@ -5558,8 +5678,8 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
|
5558
5678
|
}
|
|
5559
5679
|
if (signal.aborted) throw new Error("cancelled");
|
|
5560
5680
|
patch(job, { stage: "saving", message: null });
|
|
5561
|
-
const
|
|
5562
|
-
const avatarHash = await
|
|
5681
|
+
const generated = shotHashes !== hashes;
|
|
5682
|
+
const { previewHash, avatarHash } = await presenterCrops(core, shotHashes[0], generated ? "generated" : "upload");
|
|
5563
5683
|
const built = presenterRecordFrom({
|
|
5564
5684
|
name: job.name,
|
|
5565
5685
|
shotHashes,
|
|
@@ -5624,7 +5744,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
5624
5744
|
return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
|
|
5625
5745
|
}
|
|
5626
5746
|
async function edgeBarGeometry(buf) {
|
|
5627
|
-
const { data, info } = await
|
|
5747
|
+
const { data, info } = await sharp7(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
5628
5748
|
const W = info.width;
|
|
5629
5749
|
const H = info.height;
|
|
5630
5750
|
const scan = (len, cross, at) => {
|
|
@@ -5678,7 +5798,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
5678
5798
|
const width = g.right - g.left + 1;
|
|
5679
5799
|
const height = g.bottom - g.top + 1;
|
|
5680
5800
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
5681
|
-
const png = await
|
|
5801
|
+
const png = await sharp7(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
5682
5802
|
return core.images.save(png);
|
|
5683
5803
|
} catch {
|
|
5684
5804
|
return hash;
|
|
@@ -5692,19 +5812,80 @@ async function cardCrop(core, hash) {
|
|
|
5692
5812
|
});
|
|
5693
5813
|
}
|
|
5694
5814
|
async function avatarCrop(core, hash) {
|
|
5815
|
+
if (!hash || !core.images.has(hash)) return void 0;
|
|
5816
|
+
let box = null;
|
|
5817
|
+
try {
|
|
5818
|
+
box = await figureBox(core.images.read(hash));
|
|
5819
|
+
} catch {
|
|
5820
|
+
box = null;
|
|
5821
|
+
}
|
|
5695
5822
|
return crop(core, hash, (w, h) => {
|
|
5696
|
-
|
|
5697
|
-
|
|
5823
|
+
if (!box) {
|
|
5824
|
+
const size2 = Math.min(w, h, Math.round(h * 0.16));
|
|
5825
|
+
return { left: Math.max(0, Math.round((w - size2) / 2)), top: 0, width: size2, height: size2 };
|
|
5826
|
+
}
|
|
5827
|
+
const size = Math.min(w, h, Math.max(16, Math.round(box.height * 0.27)));
|
|
5828
|
+
const top = Math.min(Math.max(0, Math.round(box.top - size * 0.08)), h - size);
|
|
5829
|
+
const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - size / 2)), w - size);
|
|
5830
|
+
return { left, top, width: size, height: size };
|
|
5831
|
+
});
|
|
5832
|
+
}
|
|
5833
|
+
async function figureBox(buf) {
|
|
5834
|
+
const meta = await sharp7(buf).metadata();
|
|
5835
|
+
const W = meta.width ?? 0;
|
|
5836
|
+
const H = meta.height ?? 0;
|
|
5837
|
+
if (!W || !H) return null;
|
|
5838
|
+
const { info } = await sharp7(buf).trim({ threshold: 12 }).toBuffer({ resolveWithObject: true });
|
|
5839
|
+
const left = Math.abs(info.trimOffsetLeft ?? 0);
|
|
5840
|
+
const top = Math.abs(info.trimOffsetTop ?? 0);
|
|
5841
|
+
const width = info.width ?? 0;
|
|
5842
|
+
const height = info.height ?? 0;
|
|
5843
|
+
if (!width || !height) return null;
|
|
5844
|
+
if (width >= W && height >= H) return null;
|
|
5845
|
+
if (height < H * 0.3 || width < W * 0.05) return null;
|
|
5846
|
+
return { left, top, width, height };
|
|
5847
|
+
}
|
|
5848
|
+
async function presenterCrops(core, hash, mode) {
|
|
5849
|
+
const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
|
|
5850
|
+
const avatarHash = mode === "generated" ? await avatarCrop(core, hash) ?? await avatarCropSmart(core, hash) : await avatarCropSmart(core, hash) ?? await avatarCrop(core, hash);
|
|
5851
|
+
return { previewHash, avatarHash };
|
|
5852
|
+
}
|
|
5853
|
+
async function cardCropSmart(core, hash) {
|
|
5854
|
+
return smartCover(core, hash, (w, h) => {
|
|
5855
|
+
if (w / h > 0.8) return { width: Math.round(h * 0.8), height: h };
|
|
5856
|
+
return { width: w, height: Math.min(h, Math.round(w / 0.8)) };
|
|
5698
5857
|
});
|
|
5699
5858
|
}
|
|
5859
|
+
async function avatarCropSmart(core, hash) {
|
|
5860
|
+
return smartCover(core, hash, (w, h) => {
|
|
5861
|
+
const size = Math.min(w, h, 512);
|
|
5862
|
+
return { width: size, height: size };
|
|
5863
|
+
});
|
|
5864
|
+
}
|
|
5865
|
+
async function smartCover(core, hash, box) {
|
|
5866
|
+
if (!hash || !core.images.has(hash)) return void 0;
|
|
5867
|
+
try {
|
|
5868
|
+
const buf = core.images.read(hash);
|
|
5869
|
+
const meta = await sharp7(buf).metadata();
|
|
5870
|
+
const w = meta.width ?? 0;
|
|
5871
|
+
const h = meta.height ?? 0;
|
|
5872
|
+
if (!w || !h) return void 0;
|
|
5873
|
+
const raw = box(w, h);
|
|
5874
|
+
const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
|
|
5875
|
+
const png = await sharp7(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
5876
|
+
return core.images.save(png);
|
|
5877
|
+
} catch {
|
|
5878
|
+
return void 0;
|
|
5879
|
+
}
|
|
5880
|
+
}
|
|
5700
5881
|
async function crop(core, hash, region) {
|
|
5701
5882
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
5702
5883
|
try {
|
|
5703
|
-
const meta = await
|
|
5884
|
+
const meta = await sharp7(core.images.read(hash)).metadata();
|
|
5704
5885
|
const w = meta.width ?? 0;
|
|
5705
5886
|
const h = meta.height ?? 0;
|
|
5706
5887
|
if (!w || !h) return void 0;
|
|
5707
|
-
const png = await
|
|
5888
|
+
const png = await sharp7(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
|
|
5708
5889
|
return core.images.save(png);
|
|
5709
5890
|
} catch {
|
|
5710
5891
|
return void 0;
|
|
@@ -5870,7 +6051,9 @@ function inheritedIdentityTokens(parentId, getNode) {
|
|
|
5870
6051
|
for (let hop = 0; hop < MAX_HOPS && id; hop++) {
|
|
5871
6052
|
const node = getNode(id);
|
|
5872
6053
|
if (!node || node.kind === "root") return [];
|
|
5873
|
-
const identity = tokensOf(node).filter(
|
|
6054
|
+
const identity = tokensOf(node).filter(
|
|
6055
|
+
(t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref"
|
|
6056
|
+
);
|
|
5874
6057
|
if (identity.length) return identity;
|
|
5875
6058
|
id = node.parentId;
|
|
5876
6059
|
}
|
|
@@ -5956,33 +6139,192 @@ function planExpand(source, targetRatio) {
|
|
|
5956
6139
|
};
|
|
5957
6140
|
}
|
|
5958
6141
|
function expandInstruction(plan, direction) {
|
|
5959
|
-
const where = plan.axis === "width" ? "
|
|
5960
|
-
|
|
6142
|
+
const where = plan.axis === "width" ? "left and right" : "top and bottom";
|
|
6143
|
+
const nearEdge = plan.axis === "height" ? "\nDepth: the bottom edge of the frame is the part of the surface nearest the camera; the top edge is the furthest away." : "";
|
|
6144
|
+
const own = direction.trim() ? `
|
|
6145
|
+
Also: ${direction.trim()}` : "";
|
|
6146
|
+
return `Fill only the soft blurred margin at the ${where} of this frame so the photograph continues into it.
|
|
6147
|
+
Continue: the same surface, the same light direction, the same colour temperature and the same depth of field that are already in the picture.` + nearEdge + `
|
|
6148
|
+
Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
|
|
6149
|
+
Avoid: new objects, products, people, text or watermarks.` + own;
|
|
6150
|
+
}
|
|
6151
|
+
|
|
6152
|
+
// src/cropRules.ts
|
|
6153
|
+
function planCrop(source, targetRatio) {
|
|
6154
|
+
if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
|
|
6155
|
+
const current = source.width / source.height;
|
|
6156
|
+
if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
|
|
6157
|
+
if (targetRatio < current) {
|
|
6158
|
+
const width = Math.max(1, Math.min(source.width, Math.round(source.height * targetRatio)));
|
|
6159
|
+
return { left: Math.floor((source.width - width) / 2), top: 0, width, height: source.height, axis: "width" };
|
|
6160
|
+
}
|
|
6161
|
+
const height = Math.max(1, Math.min(source.height, Math.round(source.width / targetRatio)));
|
|
6162
|
+
return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height, axis: "height" };
|
|
6163
|
+
}
|
|
6164
|
+
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
6165
|
+
try {
|
|
6166
|
+
const { info } = await sharp7(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
6167
|
+
const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
|
|
6168
|
+
const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
|
|
6169
|
+
const left = Math.round((attnLeft + plan.left) / 2);
|
|
6170
|
+
const top = Math.round((attnTop + plan.top) / 2);
|
|
6171
|
+
return plan.axis === "width" ? { left: Math.min(Math.max(0, left), source.width - plan.width), top: 0 } : { left: 0, top: Math.min(Math.max(0, top), source.height - plan.height) };
|
|
6172
|
+
} catch {
|
|
6173
|
+
return { left: plan.left, top: plan.top };
|
|
6174
|
+
}
|
|
5961
6175
|
}
|
|
5962
6176
|
async function expandCanvas(source, plan) {
|
|
5963
|
-
const bed = await
|
|
5964
|
-
return
|
|
6177
|
+
const bed = await sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
6178
|
+
return sharp7(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5965
6179
|
}
|
|
5966
6180
|
async function compositeExpand(engineImage, source, plan) {
|
|
5967
|
-
const meta = await
|
|
6181
|
+
const meta = await sharp7(engineImage).metadata();
|
|
5968
6182
|
const want = plan.width / plan.height;
|
|
5969
6183
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
5970
6184
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
5971
6185
|
const aligned = sameOrientation;
|
|
5972
|
-
const
|
|
5973
|
-
const
|
|
6186
|
+
const exact = meta.width === plan.width && meta.height === plan.height;
|
|
6187
|
+
const surround = aligned ? exact ? engineImage : await sharp7(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
6188
|
+
const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
|
|
6189
|
+
const image = await sharp7(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5974
6190
|
return { image, aligned };
|
|
5975
6191
|
}
|
|
6192
|
+
async function matchMarginsToSeam(surround, source, plan) {
|
|
6193
|
+
const src = await sharp7(source).metadata();
|
|
6194
|
+
if (!src.width || !src.height) return surround;
|
|
6195
|
+
const SW = src.width;
|
|
6196
|
+
const SH = src.height;
|
|
6197
|
+
const sides = [];
|
|
6198
|
+
if (plan.axis === "width") {
|
|
6199
|
+
if (plan.left > 0)
|
|
6200
|
+
sides.push({
|
|
6201
|
+
margin: { left: 0, top: plan.top, width: plan.left, height: SH },
|
|
6202
|
+
srcEdge: { left: 0, top: 0, width: 1, height: SH },
|
|
6203
|
+
seamAt: "far"
|
|
6204
|
+
});
|
|
6205
|
+
const rightAt = plan.left + SW;
|
|
6206
|
+
if (rightAt < plan.width)
|
|
6207
|
+
sides.push({
|
|
6208
|
+
margin: { left: rightAt, top: plan.top, width: plan.width - rightAt, height: SH },
|
|
6209
|
+
srcEdge: { left: SW - 1, top: 0, width: 1, height: SH },
|
|
6210
|
+
seamAt: "near"
|
|
6211
|
+
});
|
|
6212
|
+
} else {
|
|
6213
|
+
if (plan.top > 0)
|
|
6214
|
+
sides.push({
|
|
6215
|
+
margin: { left: plan.left, top: 0, width: SW, height: plan.top },
|
|
6216
|
+
srcEdge: { left: 0, top: 0, width: SW, height: 1 },
|
|
6217
|
+
seamAt: "far"
|
|
6218
|
+
});
|
|
6219
|
+
const bottomAt = plan.top + SH;
|
|
6220
|
+
if (bottomAt < plan.height)
|
|
6221
|
+
sides.push({
|
|
6222
|
+
margin: { left: plan.left, top: bottomAt, width: SW, height: plan.height - bottomAt },
|
|
6223
|
+
srcEdge: { left: 0, top: SH - 1, width: SW, height: 1 },
|
|
6224
|
+
seamAt: "near"
|
|
6225
|
+
});
|
|
6226
|
+
}
|
|
6227
|
+
let out = surround;
|
|
6228
|
+
for (const side of sides) {
|
|
6229
|
+
try {
|
|
6230
|
+
out = await reconcile(out, source, side, plan.axis);
|
|
6231
|
+
} catch {
|
|
6232
|
+
}
|
|
6233
|
+
}
|
|
6234
|
+
return out;
|
|
6235
|
+
}
|
|
6236
|
+
var MAX_CORRECTION = 60;
|
|
6237
|
+
async function reconcile(surround, source, side, axis) {
|
|
6238
|
+
const { margin } = side;
|
|
6239
|
+
if (margin.width < 1 || margin.height < 1) return surround;
|
|
6240
|
+
const marginRaw = await sharp7(surround).extract(margin).removeAlpha().raw().toBuffer();
|
|
6241
|
+
const edgeRaw = await sharp7(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
|
|
6242
|
+
const W = margin.width;
|
|
6243
|
+
const H = margin.height;
|
|
6244
|
+
const along = axis === "width" ? H : W;
|
|
6245
|
+
const seamIndex = side.seamAt === "far" ? axis === "width" ? W - 1 : H - 1 : 0;
|
|
6246
|
+
const err = new Float32Array(along * 3);
|
|
6247
|
+
for (let i = 0; i < along; i++) {
|
|
6248
|
+
const mOff = axis === "width" ? (i * W + seamIndex) * 3 : (seamIndex * W + i) * 3;
|
|
6249
|
+
for (let c = 0; c < 3; c++) {
|
|
6250
|
+
const want = edgeRaw[i * 3 + c];
|
|
6251
|
+
const have = marginRaw[mOff + c];
|
|
6252
|
+
err[i * 3 + c] = Math.max(-MAX_CORRECTION, Math.min(MAX_CORRECTION, want - have));
|
|
6253
|
+
}
|
|
6254
|
+
}
|
|
6255
|
+
const radius = Math.max(2, Math.round(along / 64));
|
|
6256
|
+
const smooth = new Float32Array(err.length);
|
|
6257
|
+
for (let i = 0; i < along; i++) {
|
|
6258
|
+
for (let c = 0; c < 3; c++) {
|
|
6259
|
+
let sum = 0;
|
|
6260
|
+
let n = 0;
|
|
6261
|
+
for (let k = -radius; k <= radius; k++) {
|
|
6262
|
+
const j = i + k;
|
|
6263
|
+
if (j < 0 || j >= along) continue;
|
|
6264
|
+
sum += err[j * 3 + c];
|
|
6265
|
+
n++;
|
|
6266
|
+
}
|
|
6267
|
+
smooth[i * 3 + c] = sum / n;
|
|
6268
|
+
}
|
|
6269
|
+
}
|
|
6270
|
+
const depth = axis === "width" ? W : H;
|
|
6271
|
+
const corrected = Buffer.from(marginRaw);
|
|
6272
|
+
for (let y = 0; y < H; y++) {
|
|
6273
|
+
for (let x = 0; x < W; x++) {
|
|
6274
|
+
const d = axis === "width" ? side.seamAt === "far" ? W - 1 - x : x : side.seamAt === "far" ? H - 1 - y : y;
|
|
6275
|
+
const fall = 1 - d / Math.max(1, depth - 1);
|
|
6276
|
+
if (fall <= 0) continue;
|
|
6277
|
+
const i = axis === "width" ? y : x;
|
|
6278
|
+
const off = (y * W + x) * 3;
|
|
6279
|
+
for (let c = 0; c < 3; c++) {
|
|
6280
|
+
const v = corrected[off + c] + smooth[i * 3 + c] * fall;
|
|
6281
|
+
corrected[off + c] = v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
|
|
6282
|
+
}
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
6285
|
+
const patch2 = await sharp7(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
6286
|
+
return sharp7(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
|
|
6287
|
+
}
|
|
5976
6288
|
async function expandCanvasBedOnly(source, plan) {
|
|
5977
|
-
return
|
|
6289
|
+
return sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
6290
|
+
}
|
|
6291
|
+
async function seamScore(image, plan, source) {
|
|
6292
|
+
const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
6293
|
+
const W = info.width;
|
|
6294
|
+
const H = info.height;
|
|
6295
|
+
const horizontal = plan.axis === "width";
|
|
6296
|
+
const line = (i) => {
|
|
6297
|
+
let sum = 0;
|
|
6298
|
+
if (horizontal) {
|
|
6299
|
+
if (i < 1 || i >= W) return 0;
|
|
6300
|
+
for (let y = 0; y < H; y++) sum += Math.abs(data[y * W + i] - data[y * W + i - 1]);
|
|
6301
|
+
return sum / H;
|
|
6302
|
+
}
|
|
6303
|
+
if (i < 1 || i >= H) return 0;
|
|
6304
|
+
for (let x = 0; x < W; x++) sum += Math.abs(data[i * W + x] - data[(i - 1) * W + x]);
|
|
6305
|
+
return sum / W;
|
|
6306
|
+
};
|
|
6307
|
+
const at = (seam) => {
|
|
6308
|
+
const near = [];
|
|
6309
|
+
for (let d = 6; d <= 30; d++) {
|
|
6310
|
+
near.push(line(seam - d));
|
|
6311
|
+
near.push(line(seam + d));
|
|
6312
|
+
}
|
|
6313
|
+
const ordinary = near.reduce((a, b) => a + b, 0) / near.length;
|
|
6314
|
+
if (ordinary < 0.05) return 1;
|
|
6315
|
+
return line(seam) / ordinary;
|
|
6316
|
+
};
|
|
6317
|
+
const first = horizontal ? plan.left : plan.top;
|
|
6318
|
+
const second = first + (horizontal ? source.width : source.height);
|
|
6319
|
+
return Math.max(at(first), at(second));
|
|
5978
6320
|
}
|
|
5979
6321
|
async function driftDiff(a, b) {
|
|
5980
|
-
const metaA = await
|
|
5981
|
-
const metaB = await
|
|
6322
|
+
const metaA = await sharp7(a).metadata();
|
|
6323
|
+
const metaB = await sharp7(b).metadata();
|
|
5982
6324
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
5983
6325
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
5984
6326
|
const [rawA, rawB] = await Promise.all(
|
|
5985
|
-
[a, b].map((buf) =>
|
|
6327
|
+
[a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
5986
6328
|
);
|
|
5987
6329
|
const out = new PNG({ width, height });
|
|
5988
6330
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -5994,11 +6336,11 @@ async function driftDiff(a, b) {
|
|
|
5994
6336
|
};
|
|
5995
6337
|
}
|
|
5996
6338
|
async function changeMask(a, b, cap2 = 1024) {
|
|
5997
|
-
const metaA = await
|
|
6339
|
+
const metaA = await sharp7(a).metadata();
|
|
5998
6340
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
5999
6341
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
6000
6342
|
const [rawA, rawB] = await Promise.all(
|
|
6001
|
-
[a, b].map((buf) =>
|
|
6343
|
+
[a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
6002
6344
|
);
|
|
6003
6345
|
const out = new PNG({ width, height });
|
|
6004
6346
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -6047,8 +6389,8 @@ function dilationFor(longEdge) {
|
|
|
6047
6389
|
// src/localEdit.ts
|
|
6048
6390
|
async function preserveOutsideChange(source, edited) {
|
|
6049
6391
|
try {
|
|
6050
|
-
const srcMeta = await
|
|
6051
|
-
const outMeta = await
|
|
6392
|
+
const srcMeta = await sharp7(source).metadata();
|
|
6393
|
+
const outMeta = await sharp7(edited).metadata();
|
|
6052
6394
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
6053
6395
|
return { image: edited, outcome: "error", changed: 0 };
|
|
6054
6396
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -6058,15 +6400,15 @@ async function preserveOutsideChange(source, edited) {
|
|
|
6058
6400
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
6059
6401
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
6060
6402
|
const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
|
|
6061
|
-
const spread = await
|
|
6062
|
-
const dilated = await
|
|
6063
|
-
const feathered = await
|
|
6064
|
-
const grown = await
|
|
6065
|
-
const editedRgb = await
|
|
6066
|
-
const masked = await
|
|
6403
|
+
const spread = await sharp7(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
|
|
6404
|
+
const dilated = await sharp7(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
|
|
6405
|
+
const feathered = await sharp7(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
|
|
6406
|
+
const grown = await sharp7(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
6407
|
+
const editedRgb = await sharp7(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
6408
|
+
const masked = await sharp7(editedRgb, {
|
|
6067
6409
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
6068
6410
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
6069
|
-
const image = await
|
|
6411
|
+
const image = await sharp7(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
6070
6412
|
return { image, outcome: "composited", changed: shape.changed };
|
|
6071
6413
|
} catch {
|
|
6072
6414
|
return { image: edited, outcome: "error", changed: 0 };
|
|
@@ -6095,7 +6437,7 @@ var assetHash2 = (ref) => {
|
|
|
6095
6437
|
};
|
|
6096
6438
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
6097
6439
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
6098
|
-
var toPng = (buf) =>
|
|
6440
|
+
var toPng = (buf) => sharp7(buf).rotate().png().toBuffer();
|
|
6099
6441
|
var COST_PROBE = {
|
|
6100
6442
|
prompt: "",
|
|
6101
6443
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -6104,7 +6446,7 @@ var COST_PROBE = {
|
|
|
6104
6446
|
count: 1
|
|
6105
6447
|
};
|
|
6106
6448
|
var MARK_MAX_EDGE = 2048;
|
|
6107
|
-
var toMarkPng = (buf) =>
|
|
6449
|
+
var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
6108
6450
|
var readImagePart = async (core, req, normalize2) => {
|
|
6109
6451
|
const part = await req.file();
|
|
6110
6452
|
if (!part) return { error: "multipart file field required" };
|
|
@@ -6281,7 +6623,7 @@ async function vibrantColor(input) {
|
|
|
6281
6623
|
let data;
|
|
6282
6624
|
let channels;
|
|
6283
6625
|
try {
|
|
6284
|
-
const out = await
|
|
6626
|
+
const out = await sharp7(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
6285
6627
|
data = out.data;
|
|
6286
6628
|
channels = out.info.channels;
|
|
6287
6629
|
} catch {
|
|
@@ -6304,7 +6646,7 @@ async function vibrantColor(input) {
|
|
|
6304
6646
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
6305
6647
|
if (best.score <= 0) {
|
|
6306
6648
|
try {
|
|
6307
|
-
const { dominant } = await
|
|
6649
|
+
const { dominant } = await sharp7(input).stats();
|
|
6308
6650
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
6309
6651
|
} catch {
|
|
6310
6652
|
return null;
|
|
@@ -6412,6 +6754,39 @@ function registerPresenterRoutes(app, deps) {
|
|
|
6412
6754
|
});
|
|
6413
6755
|
}
|
|
6414
6756
|
|
|
6757
|
+
// src/presenterRepair.ts
|
|
6758
|
+
function presenterCropMode(firstShotFile, firstSourceFile) {
|
|
6759
|
+
return firstShotFile && firstShotFile === firstSourceFile ? "upload" : "generated";
|
|
6760
|
+
}
|
|
6761
|
+
async function repairPresenterCrops(core, log = () => {
|
|
6762
|
+
}) {
|
|
6763
|
+
let repaired = 0;
|
|
6764
|
+
for (const brand of core.store.listBrands()) {
|
|
6765
|
+
for (const c of brandCharacters(brand.json)) {
|
|
6766
|
+
if (!isCustomPresenter(c)) continue;
|
|
6767
|
+
try {
|
|
6768
|
+
const firstShot = c.shots?.[0]?.file;
|
|
6769
|
+
const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
|
|
6770
|
+
if (!hash) continue;
|
|
6771
|
+
const mode = presenterCropMode(firstShot, c.sourceRefs?.[0]?.file);
|
|
6772
|
+
const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
|
|
6773
|
+
const preview = previewHash ? `asset:${previewHash}` : void 0;
|
|
6774
|
+
const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
|
|
6775
|
+
if ((!preview || preview === c.preview) && (!avatar || avatar === c.avatar)) continue;
|
|
6776
|
+
commit(core, brand.id, (json) => {
|
|
6777
|
+
json.characters = brandCharacters(json).map(
|
|
6778
|
+
(row) => row.id === c.id ? { ...row, ...preview ? { preview } : {}, ...avatar ? { avatar } : {} } : row
|
|
6779
|
+
);
|
|
6780
|
+
});
|
|
6781
|
+
repaired += 1;
|
|
6782
|
+
log(`Repaired presenter thumbnails: ${c.name ?? c.id}`);
|
|
6783
|
+
} catch {
|
|
6784
|
+
}
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6787
|
+
return { repaired };
|
|
6788
|
+
}
|
|
6789
|
+
|
|
6415
6790
|
// src/routes/assetBuilds.ts
|
|
6416
6791
|
function registerAssetBuildRoutes(app, deps) {
|
|
6417
6792
|
const { core, engines, scenes, presenters } = deps;
|
|
@@ -6514,7 +6889,7 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
6514
6889
|
app.post("/api/brands/:id/presenters", async (req, reply) => {
|
|
6515
6890
|
const brand = brandOr404(req, reply);
|
|
6516
6891
|
if (!brand) return;
|
|
6517
|
-
const built = presenterRecordFrom(req.body ?? {});
|
|
6892
|
+
const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}));
|
|
6518
6893
|
if (!built.ok) return reply.status(400).send({ error: built.error });
|
|
6519
6894
|
try {
|
|
6520
6895
|
commit(core, brand.id, (json) => {
|
|
@@ -6532,7 +6907,7 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
6532
6907
|
const base = brandCharacters(brand.json).find((c) => c.id === id);
|
|
6533
6908
|
if (!base) return reply.status(404).send({ error: "presenter not found" });
|
|
6534
6909
|
if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this presenter is not editable" });
|
|
6535
|
-
const built = presenterRecordFrom(req.body ?? {}, base);
|
|
6910
|
+
const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}, base), base);
|
|
6536
6911
|
if (!built.ok) return reply.status(400).send({ error: built.error });
|
|
6537
6912
|
try {
|
|
6538
6913
|
commit(core, brand.id, (json) => {
|
|
@@ -6631,6 +7006,18 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
6631
7006
|
return { preview: `asset:${hash}`, brand: core.store.getBrand(brand.id) };
|
|
6632
7007
|
});
|
|
6633
7008
|
}
|
|
7009
|
+
async function withDerivedCrops(core, body, base) {
|
|
7010
|
+
const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
|
|
7011
|
+
if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
|
|
7012
|
+
const firstShot = `asset:${String(shots[0])}`;
|
|
7013
|
+
const firstSource = Array.isArray(body.sourceHashes) ? `asset:${String(body.sourceHashes[0])}` : base?.sourceRefs?.[0]?.file;
|
|
7014
|
+
const derived = await presenterCrops(core, String(shots[0]), presenterCropMode(firstShot, firstSource));
|
|
7015
|
+
return {
|
|
7016
|
+
...body,
|
|
7017
|
+
...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
|
|
7018
|
+
...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
|
|
7019
|
+
};
|
|
7020
|
+
}
|
|
6634
7021
|
function registerDemoProductRoutes(app, deps) {
|
|
6635
7022
|
const { templatesRoot, demoProducts, demoProductById } = deps;
|
|
6636
7023
|
const demoProductThumbPath = (id) => {
|
|
@@ -6847,7 +7234,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
6847
7234
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
6848
7235
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
6849
7236
|
for (const p of chosen) {
|
|
6850
|
-
const buf = p.width && p.height ? await
|
|
7237
|
+
const buf = p.width && p.height ? await sharp7(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
6851
7238
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
6852
7239
|
}
|
|
6853
7240
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -7008,7 +7395,7 @@ function registerImageRoutes(app, deps) {
|
|
|
7008
7395
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
7009
7396
|
const buf = await part.toBuffer();
|
|
7010
7397
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
7011
|
-
const png = await
|
|
7398
|
+
const png = await sharp7(buf).rotate().png().toBuffer();
|
|
7012
7399
|
return { hash: core.images.save(png) };
|
|
7013
7400
|
});
|
|
7014
7401
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -7043,6 +7430,29 @@ function registerImageRoutes(app, deps) {
|
|
|
7043
7430
|
|
|
7044
7431
|
// src/release/notes.data.ts
|
|
7045
7432
|
var RELEASES = [
|
|
7433
|
+
{
|
|
7434
|
+
version: "0.5.0",
|
|
7435
|
+
date: "2026-08-26",
|
|
7436
|
+
title: "A finished shot can change shape, and a presenter you attach is a requirement rather than a suggestion.",
|
|
7437
|
+
sections: [
|
|
7438
|
+
{
|
|
7439
|
+
heading: "Create",
|
|
7440
|
+
body: "A brief can be rearranged by hand: drag its chips into a new order, or move them with the keyboard and on touch. Chips now sit on the sentence's own baseline, so a brief reads as a line of writing rather than a row of boxes."
|
|
7441
|
+
},
|
|
7442
|
+
{
|
|
7443
|
+
heading: "Shots",
|
|
7444
|
+
body: "Pick a new shape for a finished shot and Scenri works out from the geometry whether that means cropping or extending. A crop keeps every original pixel, follows the subject, costs nothing and calls no engine. An extension leaves the original untouched to the byte and paints only the new margin, and it is offered by the engines that can genuinely paint one."
|
|
7445
|
+
},
|
|
7446
|
+
{
|
|
7447
|
+
heading: "Presenters",
|
|
7448
|
+
body: "A presenter you attach now survives what used to drop them from the picture: product notes written for a solo packshot, close-up framing, and scene direction that bans props. A custom presenter is cropped for their avatar the way the built-in ones are, measured from the person rather than from the frame."
|
|
7449
|
+
},
|
|
7450
|
+
{
|
|
7451
|
+
heading: "Fixes",
|
|
7452
|
+
body: "What a shot says it used is what the engine received, including references and brand marks carried into a refine. Two shots sent in the same second keep their order, the newest shot is always top left, and text in right-to-left languages renders and travels correctly through the app."
|
|
7453
|
+
}
|
|
7454
|
+
]
|
|
7455
|
+
},
|
|
7046
7456
|
{
|
|
7047
7457
|
version: "0.4.7",
|
|
7048
7458
|
date: "2026-08-24",
|
|
@@ -7570,6 +7980,14 @@ function registerSystemRoutes(app, deps) {
|
|
|
7570
7980
|
}
|
|
7571
7981
|
|
|
7572
7982
|
// src/server.ts
|
|
7983
|
+
function seedFor(sourceHash, width, height) {
|
|
7984
|
+
let h = 2166136261;
|
|
7985
|
+
for (const ch of `${sourceHash}:${width}x${height}`) {
|
|
7986
|
+
h ^= ch.charCodeAt(0);
|
|
7987
|
+
h = Math.imul(h, 16777619);
|
|
7988
|
+
}
|
|
7989
|
+
return Math.abs(h) % 2147483647;
|
|
7990
|
+
}
|
|
7573
7991
|
var SECRET_KEYS = ["openrouter_api_key", "replicate_api_token", "fal_key"];
|
|
7574
7992
|
function buildServer(opts) {
|
|
7575
7993
|
const { core, engines } = opts;
|
|
@@ -7647,7 +8065,14 @@ function buildServer(opts) {
|
|
|
7647
8065
|
const part = await readImagePart(core, req, toPng);
|
|
7648
8066
|
if ("error" in part) return reply.status(400).send({ error: part.error });
|
|
7649
8067
|
hashes = [part.hash];
|
|
7650
|
-
|
|
8068
|
+
const explicit = part.fields?.name?.value;
|
|
8069
|
+
const fromFile = String(part.filename ?? "").replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
|
8070
|
+
name = Array.from(
|
|
8071
|
+
String(explicit ?? (fromFile || spec.fallback)).replace(
|
|
8072
|
+
/[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g,
|
|
8073
|
+
""
|
|
8074
|
+
)
|
|
8075
|
+
).slice(0, 80).join("").trim() || spec.fallback;
|
|
7651
8076
|
}
|
|
7652
8077
|
const id = `${spec.prefix}-${randomUUID().slice(0, 8)}`;
|
|
7653
8078
|
const json = { ...brand.json };
|
|
@@ -7785,8 +8210,81 @@ function buildServer(opts) {
|
|
|
7785
8210
|
registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
|
|
7786
8211
|
registerShowcaseRoutes(app, { templatesRoot });
|
|
7787
8212
|
app.get("/api/formats", async () => FORMATS);
|
|
8213
|
+
async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
|
|
8214
|
+
const borrowed = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
|
|
8215
|
+
const already = new Set(
|
|
8216
|
+
brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
|
|
8217
|
+
);
|
|
8218
|
+
const inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
|
|
8219
|
+
const combined = [...brief.tokens, ...inheritedTokens];
|
|
8220
|
+
const brandJson = await brandJsonWithResolvedPresenters(
|
|
8221
|
+
core,
|
|
8222
|
+
templatesRoot,
|
|
8223
|
+
presenters,
|
|
8224
|
+
await brandJsonWithResolvedDemoProducts(
|
|
8225
|
+
core,
|
|
8226
|
+
templatesRoot,
|
|
8227
|
+
demoProducts,
|
|
8228
|
+
brandJsonWithCatalogProducts(core, brandId),
|
|
8229
|
+
combined
|
|
8230
|
+
),
|
|
8231
|
+
combined
|
|
8232
|
+
);
|
|
8233
|
+
const sceneById = sceneFor(brandJson);
|
|
8234
|
+
const uncapped = { ...engineCaps, maxReferenceImages: 32 };
|
|
8235
|
+
const verdict = scopeOfInstruction(
|
|
8236
|
+
brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
8237
|
+
);
|
|
8238
|
+
const compiled2 = compileBrief(brief, {
|
|
8239
|
+
brand: brandJson,
|
|
8240
|
+
images: core.images,
|
|
8241
|
+
engineCaps: uncapped,
|
|
8242
|
+
template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
|
|
8243
|
+
templateById: sceneById,
|
|
8244
|
+
mode: "edit",
|
|
8245
|
+
editScope: verdict.scope,
|
|
8246
|
+
editRemoval: verdict.removal ?? false,
|
|
8247
|
+
inheritedIdentity: inheritedTokens.length > 0,
|
|
8248
|
+
// Only the explicit op drops the dimension promise: an implicit legacy
|
|
8249
|
+
// expansion keeps its historical prompt byte for byte.
|
|
8250
|
+
...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
|
|
8251
|
+
});
|
|
8252
|
+
let inheritedAttachments = [];
|
|
8253
|
+
if (inheritedTokens.length) {
|
|
8254
|
+
const identity = compileBrief(
|
|
8255
|
+
{ tokens: inheritedTokens },
|
|
8256
|
+
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
|
|
8257
|
+
);
|
|
8258
|
+
inheritedAttachments = identity.attachments.filter((a) => a.essential || a.role === "brand" || a.role === "reference").map((a) => ({ ...a, inherited: true }));
|
|
8259
|
+
}
|
|
8260
|
+
const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
|
|
8261
|
+
const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
|
|
8262
|
+
const warnings = [...compiled2.warnings];
|
|
8263
|
+
if (merged.dropped.length) {
|
|
8264
|
+
if (engineCaps.maxReferenceImages <= 1) {
|
|
8265
|
+
warnings.push(
|
|
8266
|
+
`${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
|
|
8267
|
+
);
|
|
8268
|
+
} else {
|
|
8269
|
+
const names = [...new Set(merged.dropped.map((d) => d.label))];
|
|
8270
|
+
warnings.push(
|
|
8271
|
+
`${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
|
|
8272
|
+
" and "
|
|
8273
|
+
)} ${names.length === 1 ? "was" : "were"} left out.`
|
|
8274
|
+
);
|
|
8275
|
+
}
|
|
8276
|
+
}
|
|
8277
|
+
return {
|
|
8278
|
+
compiled: compiled2,
|
|
8279
|
+
inheritedTokens,
|
|
8280
|
+
merged,
|
|
8281
|
+
warnings,
|
|
8282
|
+
editScope: verdict.scope,
|
|
8283
|
+
editRemoval: verdict.removal ?? false
|
|
8284
|
+
};
|
|
8285
|
+
}
|
|
7788
8286
|
app.post("/api/brief/preview", async (req, reply) => {
|
|
7789
|
-
const { brief, engineId, brandId } = req.body;
|
|
8287
|
+
const { brief, engineId, brandId, parentId } = req.body;
|
|
7790
8288
|
const brand = core.store.getBrand(String(brandId));
|
|
7791
8289
|
if (!brand) return reply.status(404).send({ error: "brand not found" });
|
|
7792
8290
|
const engine = engines.get(String(engineId));
|
|
@@ -7795,6 +8293,20 @@ function buildServer(opts) {
|
|
|
7795
8293
|
return reply.status(400).send({ error: "brief.tokens must be an array" });
|
|
7796
8294
|
const briefErrors = validateBrief(brief);
|
|
7797
8295
|
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
8296
|
+
if (parentId && core.store.getNode(String(parentId))) {
|
|
8297
|
+
const previewReshape = req.body.reshape;
|
|
8298
|
+
const edit = await compileEditBrief(brand.id, String(parentId), brief, engine.capabilities(), {
|
|
8299
|
+
reshape: previewReshape === "extend" ? "extend" : previewReshape === "crop" ? "crop" : void 0
|
|
8300
|
+
});
|
|
8301
|
+
const { referenceImages: referenceImages2, ...rest2 } = edit.compiled;
|
|
8302
|
+
return {
|
|
8303
|
+
...rest2,
|
|
8304
|
+
attachments: edit.merged.kept,
|
|
8305
|
+
dropped: edit.merged.dropped,
|
|
8306
|
+
warnings: edit.warnings,
|
|
8307
|
+
referenceCount: edit.merged.kept.length
|
|
8308
|
+
};
|
|
8309
|
+
}
|
|
7798
8310
|
const brandJson = await brandJsonWithResolvedPresenters(
|
|
7799
8311
|
core,
|
|
7800
8312
|
templatesRoot,
|
|
@@ -7883,14 +8395,14 @@ function buildServer(opts) {
|
|
|
7883
8395
|
const out = [];
|
|
7884
8396
|
for (const h of images) {
|
|
7885
8397
|
const buf = core.images.read(h);
|
|
7886
|
-
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await
|
|
8398
|
+
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp7(buf).png().toBuffer()));
|
|
7887
8399
|
}
|
|
7888
8400
|
return out;
|
|
7889
8401
|
}
|
|
7890
8402
|
async function assertAspect(images, expect) {
|
|
7891
8403
|
const want = expect.width / expect.height;
|
|
7892
8404
|
for (const h of images) {
|
|
7893
|
-
const meta2 = await
|
|
8405
|
+
const meta2 = await sharp7(core.images.read(h)).metadata();
|
|
7894
8406
|
if (!meta2.width || !meta2.height) continue;
|
|
7895
8407
|
const got = meta2.width / meta2.height;
|
|
7896
8408
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -7901,7 +8413,7 @@ function buildServer(opts) {
|
|
|
7901
8413
|
}
|
|
7902
8414
|
const NODE_TIMEOUT_MS = 6e5;
|
|
7903
8415
|
async function runNode(nodeId, engine, estimate, work, expect, post) {
|
|
7904
|
-
const engineId = engine
|
|
8416
|
+
const engineId = engine?.capabilities().id ?? "local";
|
|
7905
8417
|
reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
|
|
7906
8418
|
const ctrl = new AbortController();
|
|
7907
8419
|
runningGenerations.set(nodeId, ctrl);
|
|
@@ -7920,13 +8432,15 @@ function buildServer(opts) {
|
|
|
7920
8432
|
try {
|
|
7921
8433
|
const sizes = [];
|
|
7922
8434
|
for (const h of result.images) {
|
|
7923
|
-
const meta2 = await
|
|
8435
|
+
const meta2 = await sharp7(core.images.read(h)).metadata();
|
|
7924
8436
|
if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
|
|
7925
8437
|
}
|
|
7926
8438
|
const node = core.store.getNode(nodeId);
|
|
7927
8439
|
if (node && sizes.length) {
|
|
7928
8440
|
const brief = node.brief ?? {};
|
|
7929
|
-
|
|
8441
|
+
const raw = result.raw;
|
|
8442
|
+
const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
|
|
8443
|
+
core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors } });
|
|
7930
8444
|
}
|
|
7931
8445
|
} catch {
|
|
7932
8446
|
}
|
|
@@ -7959,6 +8473,56 @@ function buildServer(opts) {
|
|
|
7959
8473
|
let { width = 1024, height = 1024 } = req.body;
|
|
7960
8474
|
const project = core.store.getProject(String(projectId));
|
|
7961
8475
|
if (!project) return reply.status(404).send({ error: "project not found" });
|
|
8476
|
+
const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
|
|
8477
|
+
const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
|
|
8478
|
+
if (kind === "edit" && reshape === "crop") {
|
|
8479
|
+
const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
|
|
8480
|
+
if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
|
|
8481
|
+
const cropParentId = parentId ? String(parentId) : rootForCrop.id;
|
|
8482
|
+
if (brief && Array.isArray(brief.tokens)) {
|
|
8483
|
+
const briefErrors = validateBrief(brief);
|
|
8484
|
+
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
8485
|
+
}
|
|
8486
|
+
const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
|
|
8487
|
+
(t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
|
|
8488
|
+
) : void 0;
|
|
8489
|
+
if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
|
|
8490
|
+
const parent = core.store.getNode(cropParentId);
|
|
8491
|
+
const srcHash = req.body.sourceImage ?? parent?.images[0];
|
|
8492
|
+
if (!srcHash || !core.images.has(String(srcHash)))
|
|
8493
|
+
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
8494
|
+
const srcBuf = core.images.read(String(srcHash));
|
|
8495
|
+
const srcMeta = await sharp7(srcBuf).metadata();
|
|
8496
|
+
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
8497
|
+
const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
|
|
8498
|
+
if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
|
|
8499
|
+
const origin = await attentionCropOrigin(srcBuf, { width: srcMeta.width, height: srcMeta.height }, plan2);
|
|
8500
|
+
const window = { left: origin.left, top: origin.top, width: plan2.width, height: plan2.height };
|
|
8501
|
+
const label = FORMATS.find((f) => f.id === fmt.id)?.label ?? `${fmt.w}x${fmt.h}`;
|
|
8502
|
+
const node2 = core.store.addNode({
|
|
8503
|
+
projectId: project.id,
|
|
8504
|
+
parentId: cropParentId,
|
|
8505
|
+
kind: "edit",
|
|
8506
|
+
prompt: `Cropped to ${label}`,
|
|
8507
|
+
// No provider was asked; recording the engine the client HAPPENED to
|
|
8508
|
+
// have selected made the overlay display a name that did nothing.
|
|
8509
|
+
engineId: "local"
|
|
8510
|
+
});
|
|
8511
|
+
core.store.setBrief(node2.id, {
|
|
8512
|
+
...brief ?? {},
|
|
8513
|
+
sourceImage: String(srcHash),
|
|
8514
|
+
reshape: "crop",
|
|
8515
|
+
crop: window
|
|
8516
|
+
});
|
|
8517
|
+
const work2 = async () => ({
|
|
8518
|
+
images: [core.images.save(await sharp7(srcBuf).extract(window).png().toBuffer())],
|
|
8519
|
+
costUsd: 0
|
|
8520
|
+
});
|
|
8521
|
+
void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
8522
|
+
(err) => app.log.error({ err }, "crop run failed")
|
|
8523
|
+
);
|
|
8524
|
+
return reply.status(202).send(node2);
|
|
8525
|
+
}
|
|
7962
8526
|
const engine = engines.get(String(engineId));
|
|
7963
8527
|
if (!engine) return reply.status(400).send({ error: `unknown engine ${engineId}` });
|
|
7964
8528
|
const avail = await engine.isAvailable();
|
|
@@ -7971,58 +8535,48 @@ function buildServer(opts) {
|
|
|
7971
8535
|
const ctx = brandContext(core, project.brandId);
|
|
7972
8536
|
let compiled2 = null;
|
|
7973
8537
|
let inheritedTokens = [];
|
|
7974
|
-
let
|
|
8538
|
+
let mergedEdit = null;
|
|
7975
8539
|
let editScope = "global";
|
|
7976
|
-
let editRemoval = false;
|
|
7977
8540
|
const extraWarnings = [];
|
|
7978
8541
|
let expandPlan = null;
|
|
7979
8542
|
let expandSourceHash = null;
|
|
7980
8543
|
if (brief && Array.isArray(brief.tokens)) {
|
|
7981
8544
|
const briefErrors = validateBrief(brief);
|
|
7982
8545
|
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
8546
|
+
if (kind === "edit") {
|
|
8547
|
+
const edit = await compileEditBrief(project.brandId, resolvedParentId, brief, engine.capabilities(), {
|
|
8548
|
+
reshape
|
|
8549
|
+
});
|
|
8550
|
+
compiled2 = edit.compiled;
|
|
8551
|
+
inheritedTokens = edit.inheritedTokens;
|
|
8552
|
+
mergedEdit = edit.merged;
|
|
8553
|
+
editScope = edit.editScope;
|
|
8554
|
+
extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
|
|
8555
|
+
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
8556
|
+
return reply.status(400).send({ error: "the brief is empty" });
|
|
8557
|
+
} else {
|
|
8558
|
+
const brandJson = await brandJsonWithResolvedPresenters(
|
|
7988
8559
|
core,
|
|
7989
8560
|
templatesRoot,
|
|
7990
|
-
|
|
7991
|
-
|
|
8561
|
+
presenters,
|
|
8562
|
+
await brandJsonWithResolvedDemoProducts(
|
|
8563
|
+
core,
|
|
8564
|
+
templatesRoot,
|
|
8565
|
+
demoProducts,
|
|
8566
|
+
brandJsonWithCatalogProducts(core, project.brandId),
|
|
8567
|
+
brief.tokens
|
|
8568
|
+
),
|
|
7992
8569
|
brief.tokens
|
|
7993
|
-
),
|
|
7994
|
-
brief.tokens
|
|
7995
|
-
);
|
|
7996
|
-
const sceneById = sceneFor(brandJson);
|
|
7997
|
-
if (kind === "edit") {
|
|
7998
|
-
const borrowed = inheritedIdentityTokens(resolvedParentId, (id) => core.store.getNode(id));
|
|
7999
|
-
if (borrowed.length) {
|
|
8000
|
-
const already = new Set(
|
|
8001
|
-
brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark").map((t) => JSON.stringify(t))
|
|
8002
|
-
);
|
|
8003
|
-
inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
|
|
8004
|
-
}
|
|
8005
|
-
const verdict = scopeOfInstruction(
|
|
8006
|
-
brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
8007
8570
|
);
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
...kind === "edit" ? { mode: "edit", editScope, editRemoval, inheritedIdentity: inheritedTokens.length > 0 } : {}
|
|
8018
|
-
});
|
|
8019
|
-
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
|
|
8020
|
-
if (inheritedTokens.length) {
|
|
8021
|
-
const identity = compileBrief(
|
|
8022
|
-
{ tokens: inheritedTokens },
|
|
8023
|
-
{ brand: brandJson, images: core.images, engineCaps: engine.capabilities(), templateById: sceneById }
|
|
8024
|
-
);
|
|
8025
|
-
inheritedAttachments = identity.attachments.filter((a) => a.essential);
|
|
8571
|
+
const sceneById = sceneFor(brandJson);
|
|
8572
|
+
compiled2 = compileBrief(brief, {
|
|
8573
|
+
brand: brandJson,
|
|
8574
|
+
images: core.images,
|
|
8575
|
+
engineCaps: engine.capabilities(),
|
|
8576
|
+
template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
|
|
8577
|
+
templateById: sceneById
|
|
8578
|
+
});
|
|
8579
|
+
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
|
|
8026
8580
|
}
|
|
8027
8581
|
}
|
|
8028
8582
|
let finalPrompt = String(prompt ?? "");
|
|
@@ -8103,23 +8657,21 @@ function buildServer(opts) {
|
|
|
8103
8657
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
8104
8658
|
if (!engine.capabilities().supportsEdit)
|
|
8105
8659
|
return reply.status(400).send({ error: "engine does not support edits" });
|
|
8106
|
-
const
|
|
8107
|
-
const own = (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] }));
|
|
8108
|
-
const borrowedRefs = inheritedAttachments.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })).filter((r) => !own.some((o) => o.path === r.path));
|
|
8109
|
-
const editRefs = [...own, ...borrowedRefs].slice(0, cap2);
|
|
8110
|
-
if (cap2 === 0 && borrowedRefs.length)
|
|
8111
|
-
extraWarnings.push(
|
|
8112
|
-
`${engine.capabilities().displayName} cannot carry reference images, so the identity rides on the source frame alone.`
|
|
8113
|
-
);
|
|
8660
|
+
const editRefs = mergedEdit ? mergedEdit.kept.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })) : (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] })).slice(0, Math.max(0, engine.capabilities().maxReferenceImages - 1));
|
|
8114
8661
|
const srcBuf = core.images.read(String(srcHash));
|
|
8115
|
-
const srcMeta = await
|
|
8662
|
+
const srcMeta = await sharp7(srcBuf).metadata();
|
|
8116
8663
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
8117
8664
|
if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
|
|
8118
8665
|
expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
|
|
8119
8666
|
}
|
|
8667
|
+
if (reshape === "extend" && !expandPlan)
|
|
8668
|
+
return reply.status(400).send({ error: "the picture is already this shape" });
|
|
8669
|
+
const canOutpaint = expandPlan ? engine.capabilities().supportsOutpaint === true : false;
|
|
8120
8670
|
if (expandPlan) {
|
|
8121
|
-
|
|
8122
|
-
|
|
8671
|
+
if (!canOutpaint) {
|
|
8672
|
+
const canvas = await expandCanvas(srcBuf, expandPlan);
|
|
8673
|
+
expandSourceHash = core.images.save(canvas);
|
|
8674
|
+
}
|
|
8123
8675
|
expectShape = { width: expandPlan.width, height: expandPlan.height };
|
|
8124
8676
|
}
|
|
8125
8677
|
const editReq = {
|
|
@@ -8127,10 +8679,47 @@ function buildServer(opts) {
|
|
|
8127
8679
|
sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
|
|
8128
8680
|
brand: ctx,
|
|
8129
8681
|
...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
|
|
8130
|
-
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {}
|
|
8682
|
+
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
|
|
8683
|
+
// An answer at the planned size lets compositeExpand skip its rescale,
|
|
8684
|
+
// which is one whole class of seam misalignment gone when honored.
|
|
8685
|
+
...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : {},
|
|
8686
|
+
// Only an engine that can genuinely paint a margin is told where the
|
|
8687
|
+
// picture sits; the rest would ignore it anyway.
|
|
8688
|
+
...expandPlan && canOutpaint ? {
|
|
8689
|
+
expand: {
|
|
8690
|
+
left: expandPlan.left,
|
|
8691
|
+
top: expandPlan.top,
|
|
8692
|
+
width: srcMeta.width ?? 0,
|
|
8693
|
+
height: srcMeta.height ?? 0
|
|
8694
|
+
},
|
|
8695
|
+
// Derived from the picture and the shape asked for, so the same
|
|
8696
|
+
// extend of the same shot is the same picture every time. Without
|
|
8697
|
+
// it the margin is a fresh roll of the dice on every run, which
|
|
8698
|
+
// is not something a person can iterate against — or that a test
|
|
8699
|
+
// can measure.
|
|
8700
|
+
seed: seedFor(String(editedFrom ?? srcHash), expandPlan.width, expandPlan.height)
|
|
8701
|
+
} : {}
|
|
8131
8702
|
};
|
|
8132
8703
|
estimate = await engine.costEstimate(editReq);
|
|
8133
|
-
|
|
8704
|
+
const plan2 = expandPlan;
|
|
8705
|
+
const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
|
|
8706
|
+
const original2 = srcBuf;
|
|
8707
|
+
work = plan2 && !canOutpaint ? async (signal) => {
|
|
8708
|
+
const draws = await Promise.all([
|
|
8709
|
+
engine.edit(editReq, signal),
|
|
8710
|
+
engine.edit(editReq, signal).catch(() => null)
|
|
8711
|
+
]);
|
|
8712
|
+
const scored = await Promise.all(
|
|
8713
|
+
draws.map(async (got) => {
|
|
8714
|
+
const first = got?.images[0];
|
|
8715
|
+
if (!got || !first) return null;
|
|
8716
|
+
const { image } = await compositeExpand(core.images.read(first), original2, plan2);
|
|
8717
|
+
return { got, score: await seamScore(image, plan2, srcSize) };
|
|
8718
|
+
})
|
|
8719
|
+
);
|
|
8720
|
+
const best = scored.filter((x) => x !== null).sort((a, b) => a.score - b.score)[0];
|
|
8721
|
+
return best?.got ?? draws[0];
|
|
8722
|
+
} : (signal) => engine.edit(editReq, signal);
|
|
8134
8723
|
}
|
|
8135
8724
|
core.ledger.assertUnderCap(engine.capabilities().id, estimate + (reserved.get(engine.capabilities().id) ?? 0));
|
|
8136
8725
|
const node = core.store.addNode({
|
|
@@ -8140,14 +8729,29 @@ function buildServer(opts) {
|
|
|
8140
8729
|
prompt: finalPrompt,
|
|
8141
8730
|
engineId: String(engineId)
|
|
8142
8731
|
});
|
|
8143
|
-
if (brief)
|
|
8732
|
+
if (brief)
|
|
8733
|
+
core.store.setBrief(node.id, {
|
|
8734
|
+
...brief,
|
|
8735
|
+
...editedFrom ? { sourceImage: editedFrom } : {},
|
|
8736
|
+
...kind === "edit" && reshape ? { reshape } : {},
|
|
8737
|
+
// What the refinement carried, recorded apart from what it asked for:
|
|
8738
|
+
// the detail view shows both, and remix reads tokens alone.
|
|
8739
|
+
...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
|
|
8740
|
+
});
|
|
8144
8741
|
const plan = expandPlan;
|
|
8145
8742
|
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
8146
8743
|
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
8147
8744
|
const post = plan ? async (images) => {
|
|
8148
8745
|
const out = [];
|
|
8149
8746
|
for (const h of images) {
|
|
8150
|
-
const
|
|
8747
|
+
const answer = core.images.read(h);
|
|
8748
|
+
const got = await sharp7(answer).metadata();
|
|
8749
|
+
if (got.width !== plan.width || got.height !== plan.height)
|
|
8750
|
+
app.log.info(
|
|
8751
|
+
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
8752
|
+
"expand: engine size differs from plan"
|
|
8753
|
+
);
|
|
8754
|
+
const { image, aligned } = await compositeExpand(answer, original, plan);
|
|
8151
8755
|
if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
|
|
8152
8756
|
out.push(core.images.save(image));
|
|
8153
8757
|
}
|
|
@@ -8317,6 +8921,7 @@ async function run() {
|
|
|
8317
8921
|
const onlyThisMachine = LOOPBACK2.includes(HOST);
|
|
8318
8922
|
const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
|
|
8319
8923
|
const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
|
|
8924
|
+
await repairPresenterCrops(core, (line) => console.log(line));
|
|
8320
8925
|
const app = buildServer({
|
|
8321
8926
|
core,
|
|
8322
8927
|
engines,
|
|
@@ -8421,8 +9026,8 @@ async function verify() {
|
|
|
8421
9026
|
const db = new Database2(":memory:");
|
|
8422
9027
|
db.pragma("user_version");
|
|
8423
9028
|
db.close();
|
|
8424
|
-
const { default:
|
|
8425
|
-
await
|
|
9029
|
+
const { default: sharp16 } = await import('sharp');
|
|
9030
|
+
await sharp16({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
8426
9031
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
8427
9032
|
} catch (err) {
|
|
8428
9033
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|