scenri 0.4.7 → 0.5.1
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 +56 -0
- package/dist/serve.js +1041 -152
- 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) => ({
|
|
@@ -1243,9 +1238,9 @@ function libraryMethods(db) {
|
|
|
1243
1238
|
local: String(i.sourceUrl).startsWith("local:")
|
|
1244
1239
|
});
|
|
1245
1240
|
const seen = /* @__PURE__ */ new Set();
|
|
1246
|
-
const
|
|
1247
|
-
const shots =
|
|
1248
|
-
const hiddenShots =
|
|
1241
|
+
const usable2 = images.filter((i) => i.assetRef && !seen.has(i.assetRef) && seen.add(i.assetRef));
|
|
1242
|
+
const shots = usable2.filter((i) => !i.excluded).map(shot);
|
|
1243
|
+
const hiddenShots = usable2.filter((i) => i.excluded).map(shot);
|
|
1249
1244
|
return {
|
|
1250
1245
|
id: `cat-${p.id}`,
|
|
1251
1246
|
name: p.title,
|
|
@@ -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 };
|
|
5698
5831
|
});
|
|
5699
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)) };
|
|
5857
|
+
});
|
|
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,436 @@ 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 extra = direction.trim().replace(/^[.\s]+/, "");
|
|
6145
|
+
const own = extra ? `
|
|
6146
|
+
Also: ${extra}` : "";
|
|
6147
|
+
return `Fill only the soft blurred margin at the ${where} of this frame so the photograph continues into it.
|
|
6148
|
+
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 + `
|
|
6149
|
+
Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
|
|
6150
|
+
Avoid: new objects, products, people, text or watermarks.` + own;
|
|
6151
|
+
}
|
|
6152
|
+
|
|
6153
|
+
// src/cropRules.ts
|
|
6154
|
+
function planCrop(source, targetRatio) {
|
|
6155
|
+
if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
|
|
6156
|
+
const current = source.width / source.height;
|
|
6157
|
+
if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
|
|
6158
|
+
if (targetRatio < current) {
|
|
6159
|
+
const width = Math.max(1, Math.min(source.width, Math.round(source.height * targetRatio)));
|
|
6160
|
+
return { left: Math.floor((source.width - width) / 2), top: 0, width, height: source.height, axis: "width" };
|
|
6161
|
+
}
|
|
6162
|
+
const height = Math.max(1, Math.min(source.height, Math.round(source.width / targetRatio)));
|
|
6163
|
+
return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height, axis: "height" };
|
|
6164
|
+
}
|
|
6165
|
+
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
6166
|
+
try {
|
|
6167
|
+
const { info } = await sharp7(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
6168
|
+
const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
|
|
6169
|
+
const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
|
|
6170
|
+
const left = Math.round((attnLeft + plan.left) / 2);
|
|
6171
|
+
const top = Math.round((attnTop + plan.top) / 2);
|
|
6172
|
+
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) };
|
|
6173
|
+
} catch {
|
|
6174
|
+
return { left: plan.left, top: plan.top };
|
|
6175
|
+
}
|
|
6176
|
+
}
|
|
6177
|
+
|
|
6178
|
+
// src/outpaint/membrane.ts
|
|
6179
|
+
var SOLVE_MAX_EDGE = 192;
|
|
6180
|
+
var SOLVE_MIN_EDGE = 8;
|
|
6181
|
+
var SWEEPS_PER_EDGE = 2;
|
|
6182
|
+
var MIN_SWEEPS = 40;
|
|
6183
|
+
var POLISH_SWEEPS = 32;
|
|
6184
|
+
var POLISH_MAX_CELLS = 4e6;
|
|
6185
|
+
function solveMembrane(req) {
|
|
6186
|
+
const { width, height, axis, seamAt } = req;
|
|
6187
|
+
const along = axis === "width" ? height : width;
|
|
6188
|
+
const depth = axis === "width" ? width : height;
|
|
6189
|
+
const out = new Float32Array(width * height * 3);
|
|
6190
|
+
if (width < 1 || height < 1 || along < 1 || depth < 1) return out;
|
|
6191
|
+
if (depth === 1) {
|
|
6192
|
+
for (let a = 0; a < along; a++) {
|
|
6193
|
+
const off = marginOffset(0, a, width, axis, seamAt, depth) * 3;
|
|
6194
|
+
for (let c = 0; c < 3; c++) out[off + c] = req.seam[a * 3 + c];
|
|
6195
|
+
}
|
|
6196
|
+
return out;
|
|
6197
|
+
}
|
|
6198
|
+
const coarse = cascade(req.seam, along, depth);
|
|
6199
|
+
if (depth * along <= POLISH_MAX_CELLS) {
|
|
6200
|
+
const full = { along, depth, data: new Float32Array(along * depth * 3) };
|
|
6201
|
+
prolong(coarse, full);
|
|
6202
|
+
relax(full, req.seam, POLISH_SWEEPS);
|
|
6203
|
+
for (let d = 0; d < depth; d++) {
|
|
6204
|
+
for (let a = 0; a < along; a++) {
|
|
6205
|
+
const off = marginOffset(d, a, width, axis, seamAt, depth) * 3;
|
|
6206
|
+
const src = (d * along + a) * 3;
|
|
6207
|
+
out[off] = full.data[src];
|
|
6208
|
+
out[off + 1] = full.data[src + 1];
|
|
6209
|
+
out[off + 2] = full.data[src + 2];
|
|
6210
|
+
}
|
|
6211
|
+
}
|
|
6212
|
+
return out;
|
|
6213
|
+
}
|
|
6214
|
+
for (let d = 0; d < depth; d++) {
|
|
6215
|
+
for (let a = 0; a < along; a++) {
|
|
6216
|
+
const off = marginOffset(d, a, width, axis, seamAt, depth) * 3;
|
|
6217
|
+
sampleGrid(coarse, d, a, depth, along, out, off);
|
|
6218
|
+
}
|
|
6219
|
+
}
|
|
6220
|
+
return out;
|
|
5961
6221
|
}
|
|
6222
|
+
function marginOffset(d, a, width, axis, seamAt, depth) {
|
|
6223
|
+
if (axis === "width") {
|
|
6224
|
+
const x = seamAt === "far" ? depth - 1 - d : d;
|
|
6225
|
+
return a * width + x;
|
|
6226
|
+
}
|
|
6227
|
+
const y = seamAt === "far" ? depth - 1 - d : d;
|
|
6228
|
+
return y * width + a;
|
|
6229
|
+
}
|
|
6230
|
+
function cascade(seam, along, depth) {
|
|
6231
|
+
const scale = Math.max(1, Math.ceil(Math.max(along, depth) / SOLVE_MAX_EDGE));
|
|
6232
|
+
const targetAlong = Math.max(2, Math.round(along / scale));
|
|
6233
|
+
const targetDepth = Math.max(2, Math.round(depth / scale));
|
|
6234
|
+
const edges = [];
|
|
6235
|
+
for (let e = SOLVE_MIN_EDGE; e < Math.max(targetAlong, targetDepth); e *= 2) edges.push(e);
|
|
6236
|
+
edges.push(Math.max(targetAlong, targetDepth));
|
|
6237
|
+
let grid = null;
|
|
6238
|
+
for (const edge of edges) {
|
|
6239
|
+
const ratio = edge / Math.max(targetAlong, targetDepth);
|
|
6240
|
+
const la = Math.max(2, Math.round(targetAlong * ratio));
|
|
6241
|
+
const ld = Math.max(2, Math.round(targetDepth * ratio));
|
|
6242
|
+
const next = { along: la, depth: ld, data: new Float32Array(la * ld * 3) };
|
|
6243
|
+
if (grid) prolong(grid, next);
|
|
6244
|
+
relax(next, resampleSeam(seam, along, la));
|
|
6245
|
+
grid = next;
|
|
6246
|
+
}
|
|
6247
|
+
return grid;
|
|
6248
|
+
}
|
|
6249
|
+
function resampleSeam(seam, along, to) {
|
|
6250
|
+
const out = new Float32Array(to * 3);
|
|
6251
|
+
for (let i = 0; i < to; i++) {
|
|
6252
|
+
const src = to === 1 ? 0 : i * (along - 1) / (to - 1);
|
|
6253
|
+
const lo = Math.floor(src);
|
|
6254
|
+
const hi = Math.min(along - 1, lo + 1);
|
|
6255
|
+
const t = src - lo;
|
|
6256
|
+
for (let c = 0; c < 3; c++) out[i * 3 + c] = seam[lo * 3 + c] * (1 - t) + seam[hi * 3 + c] * t;
|
|
6257
|
+
}
|
|
6258
|
+
return out;
|
|
6259
|
+
}
|
|
6260
|
+
function prolong(from, to) {
|
|
6261
|
+
for (let d = 0; d < to.depth; d++) {
|
|
6262
|
+
const sd = to.depth === 1 ? 0 : d * (from.depth - 1) / (to.depth - 1);
|
|
6263
|
+
for (let a = 0; a < to.along; a++) {
|
|
6264
|
+
const sa = to.along === 1 ? 0 : a * (from.along - 1) / (to.along - 1);
|
|
6265
|
+
sampleGrid(from, sd, sa, from.depth, from.along, to.data, (d * to.along + a) * 3);
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
}
|
|
6269
|
+
function sampleGrid(grid, d, a, fromDepth, fromAlong, into, off) {
|
|
6270
|
+
const sd = fromDepth === grid.depth ? d : grid.depth === 1 ? 0 : d * (grid.depth - 1) / (fromDepth - 1);
|
|
6271
|
+
const sa = fromAlong === grid.along ? a : grid.along === 1 ? 0 : a * (grid.along - 1) / (fromAlong - 1);
|
|
6272
|
+
const d0 = Math.floor(sd);
|
|
6273
|
+
const a0 = Math.floor(sa);
|
|
6274
|
+
const d1 = Math.min(grid.depth - 1, d0 + 1);
|
|
6275
|
+
const a1 = Math.min(grid.along - 1, a0 + 1);
|
|
6276
|
+
const td = sd - d0;
|
|
6277
|
+
const ta = sa - a0;
|
|
6278
|
+
const i00 = (d0 * grid.along + a0) * 3;
|
|
6279
|
+
const i01 = (d0 * grid.along + a1) * 3;
|
|
6280
|
+
const i10 = (d1 * grid.along + a0) * 3;
|
|
6281
|
+
const i11 = (d1 * grid.along + a1) * 3;
|
|
6282
|
+
for (let c = 0; c < 3; c++) {
|
|
6283
|
+
const top = grid.data[i00 + c] * (1 - ta) + grid.data[i01 + c] * ta;
|
|
6284
|
+
const bot = grid.data[i10 + c] * (1 - ta) + grid.data[i11 + c] * ta;
|
|
6285
|
+
into[off + c] = top * (1 - td) + bot * td;
|
|
6286
|
+
}
|
|
6287
|
+
}
|
|
6288
|
+
function relax(grid, seam, fixedSweeps) {
|
|
6289
|
+
const { depth, along, data } = grid;
|
|
6290
|
+
for (let a = 0; a < along; a++) {
|
|
6291
|
+
const off = a * 3;
|
|
6292
|
+
for (let c = 0; c < 3; c++) data[off + c] = seam[off + c];
|
|
6293
|
+
}
|
|
6294
|
+
if (depth < 2) return;
|
|
6295
|
+
const n = Math.max(depth, along);
|
|
6296
|
+
const omega = Math.min(1.99, 2 / (1 + Math.sin(Math.PI / Math.max(2, n))));
|
|
6297
|
+
const sweeps = fixedSweeps ?? Math.max(MIN_SWEEPS, SWEEPS_PER_EDGE * n);
|
|
6298
|
+
for (let sweep = 0; sweep < sweeps; sweep++) {
|
|
6299
|
+
for (let parity = 0; parity < 2; parity++) {
|
|
6300
|
+
for (let d = 1; d < depth; d++) {
|
|
6301
|
+
const row = d * along;
|
|
6302
|
+
const rowUp = (d - 1) * along;
|
|
6303
|
+
const rowDn = (d < depth - 1 ? d + 1 : d - 1) * along;
|
|
6304
|
+
for (let a = (d + parity) % 2; a < along; a += 2) {
|
|
6305
|
+
const iL = row + (a > 0 ? a - 1 : Math.min(1, along - 1));
|
|
6306
|
+
const iR = row + (a < along - 1 ? a + 1 : Math.max(0, along - 2));
|
|
6307
|
+
const i = row + a;
|
|
6308
|
+
for (let c = 0; c < 3; c++) {
|
|
6309
|
+
const avg = (data[(rowUp + a) * 3 + c] + data[(rowDn + a) * 3 + c] + data[iL * 3 + c] + data[iR * 3 + c]) * 0.25;
|
|
6310
|
+
data[i * 3 + c] += omega * (avg - data[i * 3 + c]);
|
|
6311
|
+
}
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
}
|
|
6315
|
+
}
|
|
6316
|
+
}
|
|
6317
|
+
|
|
6318
|
+
// src/expand.ts
|
|
5962
6319
|
async function expandCanvas(source, plan) {
|
|
5963
|
-
const bed = await
|
|
5964
|
-
return
|
|
6320
|
+
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();
|
|
6321
|
+
return sharp7(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5965
6322
|
}
|
|
5966
6323
|
async function compositeExpand(engineImage, source, plan) {
|
|
5967
|
-
const meta = await
|
|
6324
|
+
const meta = await sharp7(engineImage).metadata();
|
|
5968
6325
|
const want = plan.width / plan.height;
|
|
5969
6326
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
5970
6327
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
5971
6328
|
const aligned = sameOrientation;
|
|
5972
|
-
const
|
|
5973
|
-
const
|
|
6329
|
+
const exact = meta.width === plan.width && meta.height === plan.height;
|
|
6330
|
+
const surround = aligned ? exact ? engineImage : await sharp7(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
6331
|
+
const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
|
|
6332
|
+
const image = await sharp7(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5974
6333
|
return { image, aligned };
|
|
5975
6334
|
}
|
|
6335
|
+
async function matchMarginsToSeam(surround, source, plan) {
|
|
6336
|
+
const src = await sharp7(source).metadata();
|
|
6337
|
+
if (!src.width || !src.height) return surround;
|
|
6338
|
+
const SW = src.width;
|
|
6339
|
+
const SH = src.height;
|
|
6340
|
+
const sides = [];
|
|
6341
|
+
if (plan.axis === "width") {
|
|
6342
|
+
if (plan.left > 0)
|
|
6343
|
+
sides.push({
|
|
6344
|
+
margin: { left: 0, top: plan.top, width: plan.left, height: SH },
|
|
6345
|
+
srcEdge: { left: 0, top: 0, width: 1, height: SH },
|
|
6346
|
+
seamAt: "far"
|
|
6347
|
+
});
|
|
6348
|
+
const rightAt = plan.left + SW;
|
|
6349
|
+
if (rightAt < plan.width)
|
|
6350
|
+
sides.push({
|
|
6351
|
+
margin: { left: rightAt, top: plan.top, width: plan.width - rightAt, height: SH },
|
|
6352
|
+
srcEdge: { left: SW - 1, top: 0, width: 1, height: SH },
|
|
6353
|
+
seamAt: "near"
|
|
6354
|
+
});
|
|
6355
|
+
} else {
|
|
6356
|
+
if (plan.top > 0)
|
|
6357
|
+
sides.push({
|
|
6358
|
+
margin: { left: plan.left, top: 0, width: SW, height: plan.top },
|
|
6359
|
+
srcEdge: { left: 0, top: 0, width: SW, height: 1 },
|
|
6360
|
+
seamAt: "far"
|
|
6361
|
+
});
|
|
6362
|
+
const bottomAt = plan.top + SH;
|
|
6363
|
+
if (bottomAt < plan.height)
|
|
6364
|
+
sides.push({
|
|
6365
|
+
margin: { left: plan.left, top: bottomAt, width: SW, height: plan.height - bottomAt },
|
|
6366
|
+
srcEdge: { left: 0, top: SH - 1, width: SW, height: 1 },
|
|
6367
|
+
seamAt: "near"
|
|
6368
|
+
});
|
|
6369
|
+
}
|
|
6370
|
+
let out = surround;
|
|
6371
|
+
for (const side of sides) {
|
|
6372
|
+
try {
|
|
6373
|
+
out = await reconcile(out, source, side, plan.axis);
|
|
6374
|
+
} catch {
|
|
6375
|
+
}
|
|
6376
|
+
}
|
|
6377
|
+
return out;
|
|
6378
|
+
}
|
|
6379
|
+
var MAX_CORRECTION = 60;
|
|
6380
|
+
async function reconcile(surround, source, side, axis) {
|
|
6381
|
+
const { margin } = side;
|
|
6382
|
+
if (margin.width < 1 || margin.height < 1) return surround;
|
|
6383
|
+
const marginRaw = await sharp7(surround).extract(margin).removeAlpha().raw().toBuffer();
|
|
6384
|
+
const edgeRaw = await sharp7(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
|
|
6385
|
+
const W = margin.width;
|
|
6386
|
+
const H = margin.height;
|
|
6387
|
+
const along = axis === "width" ? H : W;
|
|
6388
|
+
const seamIndex = side.seamAt === "far" ? axis === "width" ? W - 1 : H - 1 : 0;
|
|
6389
|
+
const err = new Float32Array(along * 3);
|
|
6390
|
+
for (let i = 0; i < along; i++) {
|
|
6391
|
+
const mOff = axis === "width" ? (i * W + seamIndex) * 3 : (seamIndex * W + i) * 3;
|
|
6392
|
+
for (let c = 0; c < 3; c++) {
|
|
6393
|
+
const want = edgeRaw[i * 3 + c];
|
|
6394
|
+
const have = marginRaw[mOff + c];
|
|
6395
|
+
err[i * 3 + c] = Math.max(-MAX_CORRECTION, Math.min(MAX_CORRECTION, want - have));
|
|
6396
|
+
}
|
|
6397
|
+
}
|
|
6398
|
+
const radius = Math.max(2, Math.round(along / 64));
|
|
6399
|
+
const smooth = new Float32Array(err.length);
|
|
6400
|
+
for (let i = 0; i < along; i++) {
|
|
6401
|
+
for (let c = 0; c < 3; c++) {
|
|
6402
|
+
let sum = 0;
|
|
6403
|
+
let n = 0;
|
|
6404
|
+
for (let k = -radius; k <= radius; k++) {
|
|
6405
|
+
const j = i + k;
|
|
6406
|
+
if (j < 0 || j >= along) continue;
|
|
6407
|
+
sum += err[j * 3 + c];
|
|
6408
|
+
n++;
|
|
6409
|
+
}
|
|
6410
|
+
smooth[i * 3 + c] = sum / n;
|
|
6411
|
+
}
|
|
6412
|
+
}
|
|
6413
|
+
const half = Math.max(1, Math.floor(along / 2));
|
|
6414
|
+
const centreLo = (half - 1) / 2;
|
|
6415
|
+
const centreHi = half + (along - half - 1) / 2;
|
|
6416
|
+
const span = Math.max(1, centreHi - centreLo);
|
|
6417
|
+
const base = [0, 0, 0];
|
|
6418
|
+
const slope = [0, 0, 0];
|
|
6419
|
+
for (let c = 0; c < 3; c++) {
|
|
6420
|
+
const lo = medianOf(smooth, c, 0, half);
|
|
6421
|
+
const hi = medianOf(smooth, c, half, along);
|
|
6422
|
+
slope[c] = (hi - lo) / span;
|
|
6423
|
+
base[c] = lo - slope[c] * centreLo;
|
|
6424
|
+
for (let i = 0; i < along; i++) smooth[i * 3 + c] -= base[c] + slope[c] * i;
|
|
6425
|
+
}
|
|
6426
|
+
const field = solveMembrane({ width: W, height: H, axis, seamAt: side.seamAt, seam: smooth });
|
|
6427
|
+
const corrected = Buffer.from(marginRaw);
|
|
6428
|
+
for (let y = 0; y < H; y++) {
|
|
6429
|
+
for (let x = 0; x < W; x++) {
|
|
6430
|
+
const i = axis === "width" ? y : x;
|
|
6431
|
+
const off = (y * W + x) * 3;
|
|
6432
|
+
for (let c = 0; c < 3; c++) {
|
|
6433
|
+
const v = corrected[off + c] + base[c] + slope[c] * i + field[off + c];
|
|
6434
|
+
corrected[off + c] = v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
|
|
6435
|
+
}
|
|
6436
|
+
}
|
|
6437
|
+
}
|
|
6438
|
+
const patch2 = await sharp7(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
6439
|
+
return sharp7(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
|
|
6440
|
+
}
|
|
5976
6441
|
async function expandCanvasBedOnly(source, plan) {
|
|
5977
|
-
return
|
|
6442
|
+
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();
|
|
6443
|
+
}
|
|
6444
|
+
function medianOf(rgb, channel, from, to) {
|
|
6445
|
+
const n = to - from;
|
|
6446
|
+
if (n < 1) return 0;
|
|
6447
|
+
const values = new Float64Array(n);
|
|
6448
|
+
for (let i = 0; i < n; i++) values[i] = rgb[(from + i) * 3 + channel];
|
|
6449
|
+
values.sort();
|
|
6450
|
+
return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
|
|
6451
|
+
}
|
|
6452
|
+
async function seamScore(image, plan, source) {
|
|
6453
|
+
const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
6454
|
+
const W = info.width;
|
|
6455
|
+
const H = info.height;
|
|
6456
|
+
const horizontal = plan.axis === "width";
|
|
6457
|
+
const line = (i) => {
|
|
6458
|
+
let sum = 0;
|
|
6459
|
+
if (horizontal) {
|
|
6460
|
+
if (i < 1 || i >= W) return 0;
|
|
6461
|
+
for (let y = 0; y < H; y++) sum += Math.abs(data[y * W + i] - data[y * W + i - 1]);
|
|
6462
|
+
return sum / H;
|
|
6463
|
+
}
|
|
6464
|
+
if (i < 1 || i >= H) return 0;
|
|
6465
|
+
for (let x = 0; x < W; x++) sum += Math.abs(data[i * W + x] - data[(i - 1) * W + x]);
|
|
6466
|
+
return sum / W;
|
|
6467
|
+
};
|
|
6468
|
+
const at = (seam) => {
|
|
6469
|
+
const near = [];
|
|
6470
|
+
for (let d = 6; d <= 30; d++) {
|
|
6471
|
+
near.push(line(seam - d));
|
|
6472
|
+
near.push(line(seam + d));
|
|
6473
|
+
}
|
|
6474
|
+
const ordinary = near.reduce((a, b) => a + b, 0) / near.length;
|
|
6475
|
+
if (ordinary < 0.05) return 1;
|
|
6476
|
+
return line(seam) / ordinary;
|
|
6477
|
+
};
|
|
6478
|
+
const first = horizontal ? plan.left : plan.top;
|
|
6479
|
+
const second = first + (horizontal ? source.width : source.height);
|
|
6480
|
+
return Math.max(at(first), at(second));
|
|
6481
|
+
}
|
|
6482
|
+
var SEAM_VISIBLE = 2.2;
|
|
6483
|
+
var OFFSET = 4;
|
|
6484
|
+
var RESIDUAL_VISIBLE = 15;
|
|
6485
|
+
async function seamResidual(image, plan, source) {
|
|
6486
|
+
const { data, info } = await sharp7(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
6487
|
+
const W = info.width;
|
|
6488
|
+
const H = info.height;
|
|
6489
|
+
const ch = info.channels;
|
|
6490
|
+
const horizontal = plan.axis === "width";
|
|
6491
|
+
const between = (a, b) => {
|
|
6492
|
+
const limit = horizontal ? W : H;
|
|
6493
|
+
if (a < 0 || b < 0 || a >= limit || b >= limit) return null;
|
|
6494
|
+
const run2 = horizontal ? H : W;
|
|
6495
|
+
let sum = 0;
|
|
6496
|
+
for (let i = 0; i < run2; i++) {
|
|
6497
|
+
const ia = (horizontal ? i * W + a : a * W + i) * ch;
|
|
6498
|
+
const ib = (horizontal ? i * W + b : b * W + i) * ch;
|
|
6499
|
+
sum += Math.abs(data[ia] - data[ib]) + Math.abs(data[ia + 1] - data[ib + 1]) + Math.abs(data[ia + 2] - data[ib + 2]);
|
|
6500
|
+
}
|
|
6501
|
+
return sum / (run2 * 3);
|
|
6502
|
+
};
|
|
6503
|
+
const near = horizontal ? plan.left : plan.top;
|
|
6504
|
+
const far = near + (horizontal ? source.width : source.height);
|
|
6505
|
+
const both = [
|
|
6506
|
+
// The margin before the picture starts, against the picture just inside it.
|
|
6507
|
+
between(near - OFFSET, near + OFFSET - 1),
|
|
6508
|
+
// The picture just before it ends, against the margin just after.
|
|
6509
|
+
between(far - OFFSET, far + OFFSET - 1)
|
|
6510
|
+
].filter((v) => v !== null);
|
|
6511
|
+
return both.length ? Math.max(...both) : 0;
|
|
6512
|
+
}
|
|
6513
|
+
function seamPenalty(score, residual) {
|
|
6514
|
+
return Math.max(score / SEAM_VISIBLE, residual / RESIDUAL_VISIBLE);
|
|
6515
|
+
}
|
|
6516
|
+
var MIN_SHARE = 0.2;
|
|
6517
|
+
var MAX_SHARE = 0.8;
|
|
6518
|
+
async function subjectFraction(src, source, axis) {
|
|
6519
|
+
try {
|
|
6520
|
+
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)) };
|
|
6521
|
+
const { info } = await sharp7(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
6522
|
+
const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
|
|
6523
|
+
const span = axis === "width" ? source.width : source.height;
|
|
6524
|
+
const extent = axis === "width" ? window.width : window.height;
|
|
6525
|
+
const centre = (offset + extent / 2) / span;
|
|
6526
|
+
return Math.min(1, Math.max(0, (centre + 0.5) / 2));
|
|
6527
|
+
} catch {
|
|
6528
|
+
return 0.5;
|
|
6529
|
+
}
|
|
6530
|
+
}
|
|
6531
|
+
function placeExpand(plan, source, fraction) {
|
|
6532
|
+
const share = Math.min(MAX_SHARE, Math.max(MIN_SHARE, fraction));
|
|
6533
|
+
if (plan.axis === "width") {
|
|
6534
|
+
const room2 = plan.width - source.width;
|
|
6535
|
+
if (room2 <= 0) return plan;
|
|
6536
|
+
return { ...plan, left: Math.min(room2, Math.max(0, Math.round(room2 * share))) };
|
|
6537
|
+
}
|
|
6538
|
+
const room = plan.height - source.height;
|
|
6539
|
+
if (room <= 0) return plan;
|
|
6540
|
+
return { ...plan, top: Math.min(room, Math.max(0, Math.round(room * share))) };
|
|
6541
|
+
}
|
|
6542
|
+
|
|
6543
|
+
// src/outpaint/route.ts
|
|
6544
|
+
var canOutpaint = (e) => {
|
|
6545
|
+
const caps = e.capabilities();
|
|
6546
|
+
return caps.supportsOutpaint === true;
|
|
6547
|
+
};
|
|
6548
|
+
var usable = async (e) => {
|
|
6549
|
+
try {
|
|
6550
|
+
return (await e.isAvailable()).ok;
|
|
6551
|
+
} catch {
|
|
6552
|
+
return false;
|
|
6553
|
+
}
|
|
6554
|
+
};
|
|
6555
|
+
async function resolveOutpaintRoute(all, shot) {
|
|
6556
|
+
if (canOutpaint(shot)) return { engine: shot, method: "outpaint", crossed: false };
|
|
6557
|
+
const shotId = shot.capabilities().id;
|
|
6558
|
+
for (const candidate of all) {
|
|
6559
|
+
const caps = candidate.capabilities();
|
|
6560
|
+
if (caps.id === shotId || caps.placeholder || !canOutpaint(candidate)) continue;
|
|
6561
|
+
if (await usable(candidate)) return { engine: candidate, method: "outpaint", crossed: true };
|
|
6562
|
+
}
|
|
6563
|
+
return { engine: shot, method: "reframe", crossed: false };
|
|
5978
6564
|
}
|
|
5979
6565
|
async function driftDiff(a, b) {
|
|
5980
|
-
const metaA = await
|
|
5981
|
-
const metaB = await
|
|
6566
|
+
const metaA = await sharp7(a).metadata();
|
|
6567
|
+
const metaB = await sharp7(b).metadata();
|
|
5982
6568
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
5983
6569
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
5984
6570
|
const [rawA, rawB] = await Promise.all(
|
|
5985
|
-
[a, b].map((buf) =>
|
|
6571
|
+
[a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
5986
6572
|
);
|
|
5987
6573
|
const out = new PNG({ width, height });
|
|
5988
6574
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -5994,11 +6580,11 @@ async function driftDiff(a, b) {
|
|
|
5994
6580
|
};
|
|
5995
6581
|
}
|
|
5996
6582
|
async function changeMask(a, b, cap2 = 1024) {
|
|
5997
|
-
const metaA = await
|
|
6583
|
+
const metaA = await sharp7(a).metadata();
|
|
5998
6584
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
5999
6585
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
6000
6586
|
const [rawA, rawB] = await Promise.all(
|
|
6001
|
-
[a, b].map((buf) =>
|
|
6587
|
+
[a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
6002
6588
|
);
|
|
6003
6589
|
const out = new PNG({ width, height });
|
|
6004
6590
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -6047,8 +6633,8 @@ function dilationFor(longEdge) {
|
|
|
6047
6633
|
// src/localEdit.ts
|
|
6048
6634
|
async function preserveOutsideChange(source, edited) {
|
|
6049
6635
|
try {
|
|
6050
|
-
const srcMeta = await
|
|
6051
|
-
const outMeta = await
|
|
6636
|
+
const srcMeta = await sharp7(source).metadata();
|
|
6637
|
+
const outMeta = await sharp7(edited).metadata();
|
|
6052
6638
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
6053
6639
|
return { image: edited, outcome: "error", changed: 0 };
|
|
6054
6640
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -6058,15 +6644,15 @@ async function preserveOutsideChange(source, edited) {
|
|
|
6058
6644
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
6059
6645
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
6060
6646
|
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
|
|
6647
|
+
const spread = await sharp7(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
|
|
6648
|
+
const dilated = await sharp7(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
|
|
6649
|
+
const feathered = await sharp7(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
|
|
6650
|
+
const grown = await sharp7(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
6651
|
+
const editedRgb = await sharp7(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
6652
|
+
const masked = await sharp7(editedRgb, {
|
|
6067
6653
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
6068
6654
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
6069
|
-
const image = await
|
|
6655
|
+
const image = await sharp7(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
6070
6656
|
return { image, outcome: "composited", changed: shape.changed };
|
|
6071
6657
|
} catch {
|
|
6072
6658
|
return { image: edited, outcome: "error", changed: 0 };
|
|
@@ -6095,7 +6681,7 @@ var assetHash2 = (ref) => {
|
|
|
6095
6681
|
};
|
|
6096
6682
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
6097
6683
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
6098
|
-
var toPng = (buf) =>
|
|
6684
|
+
var toPng = (buf) => sharp7(buf).rotate().png().toBuffer();
|
|
6099
6685
|
var COST_PROBE = {
|
|
6100
6686
|
prompt: "",
|
|
6101
6687
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -6104,7 +6690,7 @@ var COST_PROBE = {
|
|
|
6104
6690
|
count: 1
|
|
6105
6691
|
};
|
|
6106
6692
|
var MARK_MAX_EDGE = 2048;
|
|
6107
|
-
var toMarkPng = (buf) =>
|
|
6693
|
+
var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
6108
6694
|
var readImagePart = async (core, req, normalize2) => {
|
|
6109
6695
|
const part = await req.file();
|
|
6110
6696
|
if (!part) return { error: "multipart file field required" };
|
|
@@ -6281,7 +6867,7 @@ async function vibrantColor(input) {
|
|
|
6281
6867
|
let data;
|
|
6282
6868
|
let channels;
|
|
6283
6869
|
try {
|
|
6284
|
-
const out = await
|
|
6870
|
+
const out = await sharp7(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
6285
6871
|
data = out.data;
|
|
6286
6872
|
channels = out.info.channels;
|
|
6287
6873
|
} catch {
|
|
@@ -6304,7 +6890,7 @@ async function vibrantColor(input) {
|
|
|
6304
6890
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
6305
6891
|
if (best.score <= 0) {
|
|
6306
6892
|
try {
|
|
6307
|
-
const { dominant } = await
|
|
6893
|
+
const { dominant } = await sharp7(input).stats();
|
|
6308
6894
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
6309
6895
|
} catch {
|
|
6310
6896
|
return null;
|
|
@@ -6412,6 +6998,39 @@ function registerPresenterRoutes(app, deps) {
|
|
|
6412
6998
|
});
|
|
6413
6999
|
}
|
|
6414
7000
|
|
|
7001
|
+
// src/presenterRepair.ts
|
|
7002
|
+
function presenterCropMode(firstShotFile, firstSourceFile) {
|
|
7003
|
+
return firstShotFile && firstShotFile === firstSourceFile ? "upload" : "generated";
|
|
7004
|
+
}
|
|
7005
|
+
async function repairPresenterCrops(core, log = () => {
|
|
7006
|
+
}) {
|
|
7007
|
+
let repaired = 0;
|
|
7008
|
+
for (const brand of core.store.listBrands()) {
|
|
7009
|
+
for (const c of brandCharacters(brand.json)) {
|
|
7010
|
+
if (!isCustomPresenter(c)) continue;
|
|
7011
|
+
try {
|
|
7012
|
+
const firstShot = c.shots?.[0]?.file;
|
|
7013
|
+
const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
|
|
7014
|
+
if (!hash) continue;
|
|
7015
|
+
const mode = presenterCropMode(firstShot, c.sourceRefs?.[0]?.file);
|
|
7016
|
+
const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
|
|
7017
|
+
const preview = previewHash ? `asset:${previewHash}` : void 0;
|
|
7018
|
+
const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
|
|
7019
|
+
if ((!preview || preview === c.preview) && (!avatar || avatar === c.avatar)) continue;
|
|
7020
|
+
commit(core, brand.id, (json) => {
|
|
7021
|
+
json.characters = brandCharacters(json).map(
|
|
7022
|
+
(row) => row.id === c.id ? { ...row, ...preview ? { preview } : {}, ...avatar ? { avatar } : {} } : row
|
|
7023
|
+
);
|
|
7024
|
+
});
|
|
7025
|
+
repaired += 1;
|
|
7026
|
+
log(`Repaired presenter thumbnails: ${c.name ?? c.id}`);
|
|
7027
|
+
} catch {
|
|
7028
|
+
}
|
|
7029
|
+
}
|
|
7030
|
+
}
|
|
7031
|
+
return { repaired };
|
|
7032
|
+
}
|
|
7033
|
+
|
|
6415
7034
|
// src/routes/assetBuilds.ts
|
|
6416
7035
|
function registerAssetBuildRoutes(app, deps) {
|
|
6417
7036
|
const { core, engines, scenes, presenters } = deps;
|
|
@@ -6514,7 +7133,7 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
6514
7133
|
app.post("/api/brands/:id/presenters", async (req, reply) => {
|
|
6515
7134
|
const brand = brandOr404(req, reply);
|
|
6516
7135
|
if (!brand) return;
|
|
6517
|
-
const built = presenterRecordFrom(req.body ?? {});
|
|
7136
|
+
const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}));
|
|
6518
7137
|
if (!built.ok) return reply.status(400).send({ error: built.error });
|
|
6519
7138
|
try {
|
|
6520
7139
|
commit(core, brand.id, (json) => {
|
|
@@ -6532,7 +7151,7 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
6532
7151
|
const base = brandCharacters(brand.json).find((c) => c.id === id);
|
|
6533
7152
|
if (!base) return reply.status(404).send({ error: "presenter not found" });
|
|
6534
7153
|
if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this presenter is not editable" });
|
|
6535
|
-
const built = presenterRecordFrom(req.body ?? {}, base);
|
|
7154
|
+
const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}, base), base);
|
|
6536
7155
|
if (!built.ok) return reply.status(400).send({ error: built.error });
|
|
6537
7156
|
try {
|
|
6538
7157
|
commit(core, brand.id, (json) => {
|
|
@@ -6631,6 +7250,18 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
6631
7250
|
return { preview: `asset:${hash}`, brand: core.store.getBrand(brand.id) };
|
|
6632
7251
|
});
|
|
6633
7252
|
}
|
|
7253
|
+
async function withDerivedCrops(core, body, base) {
|
|
7254
|
+
const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
|
|
7255
|
+
if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
|
|
7256
|
+
const firstShot = `asset:${String(shots[0])}`;
|
|
7257
|
+
const firstSource = Array.isArray(body.sourceHashes) ? `asset:${String(body.sourceHashes[0])}` : base?.sourceRefs?.[0]?.file;
|
|
7258
|
+
const derived = await presenterCrops(core, String(shots[0]), presenterCropMode(firstShot, firstSource));
|
|
7259
|
+
return {
|
|
7260
|
+
...body,
|
|
7261
|
+
...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
|
|
7262
|
+
...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
|
|
7263
|
+
};
|
|
7264
|
+
}
|
|
6634
7265
|
function registerDemoProductRoutes(app, deps) {
|
|
6635
7266
|
const { templatesRoot, demoProducts, demoProductById } = deps;
|
|
6636
7267
|
const demoProductThumbPath = (id) => {
|
|
@@ -6847,7 +7478,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
6847
7478
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
6848
7479
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
6849
7480
|
for (const p of chosen) {
|
|
6850
|
-
const buf = p.width && p.height ? await
|
|
7481
|
+
const buf = p.width && p.height ? await sharp7(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
6851
7482
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
6852
7483
|
}
|
|
6853
7484
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -7008,7 +7639,7 @@ function registerImageRoutes(app, deps) {
|
|
|
7008
7639
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
7009
7640
|
const buf = await part.toBuffer();
|
|
7010
7641
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
7011
|
-
const png = await
|
|
7642
|
+
const png = await sharp7(buf).rotate().png().toBuffer();
|
|
7012
7643
|
return { hash: core.images.save(png) };
|
|
7013
7644
|
});
|
|
7014
7645
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -7043,6 +7674,39 @@ function registerImageRoutes(app, deps) {
|
|
|
7043
7674
|
|
|
7044
7675
|
// src/release/notes.data.ts
|
|
7045
7676
|
var RELEASES = [
|
|
7677
|
+
{
|
|
7678
|
+
version: "0.5.1",
|
|
7679
|
+
date: "2026-08-26",
|
|
7680
|
+
sections: [
|
|
7681
|
+
{
|
|
7682
|
+
heading: "Shots",
|
|
7683
|
+
body: "An extended shot keeps its subject where it was composed, so a product standing near one edge stays near that edge in the wider frame rather than drifting to the middle. The new margin now meets the picture across the whole join instead of only at the seam, so a difference in tone or light no longer survives out at the frame edge. Where a connected engine can paint a margin directly, an extension is handed to it."
|
|
7684
|
+
}
|
|
7685
|
+
]
|
|
7686
|
+
},
|
|
7687
|
+
{
|
|
7688
|
+
version: "0.5.0",
|
|
7689
|
+
date: "2026-08-26",
|
|
7690
|
+
title: "A finished shot can change shape, and a presenter you attach is a requirement rather than a suggestion.",
|
|
7691
|
+
sections: [
|
|
7692
|
+
{
|
|
7693
|
+
heading: "Create",
|
|
7694
|
+
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."
|
|
7695
|
+
},
|
|
7696
|
+
{
|
|
7697
|
+
heading: "Shots",
|
|
7698
|
+
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."
|
|
7699
|
+
},
|
|
7700
|
+
{
|
|
7701
|
+
heading: "Presenters",
|
|
7702
|
+
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."
|
|
7703
|
+
},
|
|
7704
|
+
{
|
|
7705
|
+
heading: "Fixes",
|
|
7706
|
+
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."
|
|
7707
|
+
}
|
|
7708
|
+
]
|
|
7709
|
+
},
|
|
7046
7710
|
{
|
|
7047
7711
|
version: "0.4.7",
|
|
7048
7712
|
date: "2026-08-24",
|
|
@@ -7570,6 +8234,14 @@ function registerSystemRoutes(app, deps) {
|
|
|
7570
8234
|
}
|
|
7571
8235
|
|
|
7572
8236
|
// src/server.ts
|
|
8237
|
+
function seedFor(sourceHash, width, height) {
|
|
8238
|
+
let h = 2166136261;
|
|
8239
|
+
for (const ch of `${sourceHash}:${width}x${height}`) {
|
|
8240
|
+
h ^= ch.charCodeAt(0);
|
|
8241
|
+
h = Math.imul(h, 16777619);
|
|
8242
|
+
}
|
|
8243
|
+
return Math.abs(h) % 2147483647;
|
|
8244
|
+
}
|
|
7573
8245
|
var SECRET_KEYS = ["openrouter_api_key", "replicate_api_token", "fal_key"];
|
|
7574
8246
|
function buildServer(opts) {
|
|
7575
8247
|
const { core, engines } = opts;
|
|
@@ -7647,7 +8319,14 @@ function buildServer(opts) {
|
|
|
7647
8319
|
const part = await readImagePart(core, req, toPng);
|
|
7648
8320
|
if ("error" in part) return reply.status(400).send({ error: part.error });
|
|
7649
8321
|
hashes = [part.hash];
|
|
7650
|
-
|
|
8322
|
+
const explicit = part.fields?.name?.value;
|
|
8323
|
+
const fromFile = String(part.filename ?? "").replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
|
8324
|
+
name = Array.from(
|
|
8325
|
+
String(explicit ?? (fromFile || spec.fallback)).replace(
|
|
8326
|
+
/[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g,
|
|
8327
|
+
""
|
|
8328
|
+
)
|
|
8329
|
+
).slice(0, 80).join("").trim() || spec.fallback;
|
|
7651
8330
|
}
|
|
7652
8331
|
const id = `${spec.prefix}-${randomUUID().slice(0, 8)}`;
|
|
7653
8332
|
const json = { ...brand.json };
|
|
@@ -7785,8 +8464,81 @@ function buildServer(opts) {
|
|
|
7785
8464
|
registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
|
|
7786
8465
|
registerShowcaseRoutes(app, { templatesRoot });
|
|
7787
8466
|
app.get("/api/formats", async () => FORMATS);
|
|
8467
|
+
async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
|
|
8468
|
+
const borrowed = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
|
|
8469
|
+
const already = new Set(
|
|
8470
|
+
brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
|
|
8471
|
+
);
|
|
8472
|
+
const inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
|
|
8473
|
+
const combined = [...brief.tokens, ...inheritedTokens];
|
|
8474
|
+
const brandJson = await brandJsonWithResolvedPresenters(
|
|
8475
|
+
core,
|
|
8476
|
+
templatesRoot,
|
|
8477
|
+
presenters,
|
|
8478
|
+
await brandJsonWithResolvedDemoProducts(
|
|
8479
|
+
core,
|
|
8480
|
+
templatesRoot,
|
|
8481
|
+
demoProducts,
|
|
8482
|
+
brandJsonWithCatalogProducts(core, brandId),
|
|
8483
|
+
combined
|
|
8484
|
+
),
|
|
8485
|
+
combined
|
|
8486
|
+
);
|
|
8487
|
+
const sceneById = sceneFor(brandJson);
|
|
8488
|
+
const uncapped = { ...engineCaps, maxReferenceImages: 32 };
|
|
8489
|
+
const verdict = scopeOfInstruction(
|
|
8490
|
+
brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
8491
|
+
);
|
|
8492
|
+
const compiled2 = compileBrief(brief, {
|
|
8493
|
+
brand: brandJson,
|
|
8494
|
+
images: core.images,
|
|
8495
|
+
engineCaps: uncapped,
|
|
8496
|
+
template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
|
|
8497
|
+
templateById: sceneById,
|
|
8498
|
+
mode: "edit",
|
|
8499
|
+
editScope: verdict.scope,
|
|
8500
|
+
editRemoval: verdict.removal ?? false,
|
|
8501
|
+
inheritedIdentity: inheritedTokens.length > 0,
|
|
8502
|
+
// Only the explicit op drops the dimension promise: an implicit legacy
|
|
8503
|
+
// expansion keeps its historical prompt byte for byte.
|
|
8504
|
+
...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
|
|
8505
|
+
});
|
|
8506
|
+
let inheritedAttachments = [];
|
|
8507
|
+
if (inheritedTokens.length) {
|
|
8508
|
+
const identity = compileBrief(
|
|
8509
|
+
{ tokens: inheritedTokens },
|
|
8510
|
+
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
|
|
8511
|
+
);
|
|
8512
|
+
inheritedAttachments = identity.attachments.filter((a) => a.essential || a.role === "brand" || a.role === "reference").map((a) => ({ ...a, inherited: true }));
|
|
8513
|
+
}
|
|
8514
|
+
const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
|
|
8515
|
+
const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
|
|
8516
|
+
const warnings = [...compiled2.warnings];
|
|
8517
|
+
if (merged.dropped.length) {
|
|
8518
|
+
if (engineCaps.maxReferenceImages <= 1) {
|
|
8519
|
+
warnings.push(
|
|
8520
|
+
`${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
|
|
8521
|
+
);
|
|
8522
|
+
} else {
|
|
8523
|
+
const names = [...new Set(merged.dropped.map((d) => d.label))];
|
|
8524
|
+
warnings.push(
|
|
8525
|
+
`${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
|
|
8526
|
+
" and "
|
|
8527
|
+
)} ${names.length === 1 ? "was" : "were"} left out.`
|
|
8528
|
+
);
|
|
8529
|
+
}
|
|
8530
|
+
}
|
|
8531
|
+
return {
|
|
8532
|
+
compiled: compiled2,
|
|
8533
|
+
inheritedTokens,
|
|
8534
|
+
merged,
|
|
8535
|
+
warnings,
|
|
8536
|
+
editScope: verdict.scope,
|
|
8537
|
+
editRemoval: verdict.removal ?? false
|
|
8538
|
+
};
|
|
8539
|
+
}
|
|
7788
8540
|
app.post("/api/brief/preview", async (req, reply) => {
|
|
7789
|
-
const { brief, engineId, brandId } = req.body;
|
|
8541
|
+
const { brief, engineId, brandId, parentId } = req.body;
|
|
7790
8542
|
const brand = core.store.getBrand(String(brandId));
|
|
7791
8543
|
if (!brand) return reply.status(404).send({ error: "brand not found" });
|
|
7792
8544
|
const engine = engines.get(String(engineId));
|
|
@@ -7795,6 +8547,20 @@ function buildServer(opts) {
|
|
|
7795
8547
|
return reply.status(400).send({ error: "brief.tokens must be an array" });
|
|
7796
8548
|
const briefErrors = validateBrief(brief);
|
|
7797
8549
|
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
8550
|
+
if (parentId && core.store.getNode(String(parentId))) {
|
|
8551
|
+
const previewReshape = req.body.reshape;
|
|
8552
|
+
const edit = await compileEditBrief(brand.id, String(parentId), brief, engine.capabilities(), {
|
|
8553
|
+
reshape: previewReshape === "extend" ? "extend" : previewReshape === "crop" ? "crop" : void 0
|
|
8554
|
+
});
|
|
8555
|
+
const { referenceImages: referenceImages2, ...rest2 } = edit.compiled;
|
|
8556
|
+
return {
|
|
8557
|
+
...rest2,
|
|
8558
|
+
attachments: edit.merged.kept,
|
|
8559
|
+
dropped: edit.merged.dropped,
|
|
8560
|
+
warnings: edit.warnings,
|
|
8561
|
+
referenceCount: edit.merged.kept.length
|
|
8562
|
+
};
|
|
8563
|
+
}
|
|
7798
8564
|
const brandJson = await brandJsonWithResolvedPresenters(
|
|
7799
8565
|
core,
|
|
7800
8566
|
templatesRoot,
|
|
@@ -7883,14 +8649,14 @@ function buildServer(opts) {
|
|
|
7883
8649
|
const out = [];
|
|
7884
8650
|
for (const h of images) {
|
|
7885
8651
|
const buf = core.images.read(h);
|
|
7886
|
-
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await
|
|
8652
|
+
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp7(buf).png().toBuffer()));
|
|
7887
8653
|
}
|
|
7888
8654
|
return out;
|
|
7889
8655
|
}
|
|
7890
8656
|
async function assertAspect(images, expect) {
|
|
7891
8657
|
const want = expect.width / expect.height;
|
|
7892
8658
|
for (const h of images) {
|
|
7893
|
-
const meta2 = await
|
|
8659
|
+
const meta2 = await sharp7(core.images.read(h)).metadata();
|
|
7894
8660
|
if (!meta2.width || !meta2.height) continue;
|
|
7895
8661
|
const got = meta2.width / meta2.height;
|
|
7896
8662
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -7901,7 +8667,7 @@ function buildServer(opts) {
|
|
|
7901
8667
|
}
|
|
7902
8668
|
const NODE_TIMEOUT_MS = 6e5;
|
|
7903
8669
|
async function runNode(nodeId, engine, estimate, work, expect, post) {
|
|
7904
|
-
const engineId = engine
|
|
8670
|
+
const engineId = engine?.capabilities().id ?? "local";
|
|
7905
8671
|
reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
|
|
7906
8672
|
const ctrl = new AbortController();
|
|
7907
8673
|
runningGenerations.set(nodeId, ctrl);
|
|
@@ -7920,13 +8686,15 @@ function buildServer(opts) {
|
|
|
7920
8686
|
try {
|
|
7921
8687
|
const sizes = [];
|
|
7922
8688
|
for (const h of result.images) {
|
|
7923
|
-
const meta2 = await
|
|
8689
|
+
const meta2 = await sharp7(core.images.read(h)).metadata();
|
|
7924
8690
|
if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
|
|
7925
8691
|
}
|
|
7926
8692
|
const node = core.store.getNode(nodeId);
|
|
7927
8693
|
if (node && sizes.length) {
|
|
7928
8694
|
const brief = node.brief ?? {};
|
|
7929
|
-
|
|
8695
|
+
const raw = result.raw;
|
|
8696
|
+
const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
|
|
8697
|
+
core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors } });
|
|
7930
8698
|
}
|
|
7931
8699
|
} catch {
|
|
7932
8700
|
}
|
|
@@ -7959,6 +8727,56 @@ function buildServer(opts) {
|
|
|
7959
8727
|
let { width = 1024, height = 1024 } = req.body;
|
|
7960
8728
|
const project = core.store.getProject(String(projectId));
|
|
7961
8729
|
if (!project) return reply.status(404).send({ error: "project not found" });
|
|
8730
|
+
const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
|
|
8731
|
+
const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
|
|
8732
|
+
if (kind === "edit" && reshape === "crop") {
|
|
8733
|
+
const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
|
|
8734
|
+
if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
|
|
8735
|
+
const cropParentId = parentId ? String(parentId) : rootForCrop.id;
|
|
8736
|
+
if (brief && Array.isArray(brief.tokens)) {
|
|
8737
|
+
const briefErrors = validateBrief(brief);
|
|
8738
|
+
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
8739
|
+
}
|
|
8740
|
+
const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
|
|
8741
|
+
(t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
|
|
8742
|
+
) : void 0;
|
|
8743
|
+
if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
|
|
8744
|
+
const parent = core.store.getNode(cropParentId);
|
|
8745
|
+
const srcHash = req.body.sourceImage ?? parent?.images[0];
|
|
8746
|
+
if (!srcHash || !core.images.has(String(srcHash)))
|
|
8747
|
+
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
8748
|
+
const srcBuf = core.images.read(String(srcHash));
|
|
8749
|
+
const srcMeta = await sharp7(srcBuf).metadata();
|
|
8750
|
+
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
8751
|
+
const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
|
|
8752
|
+
if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
|
|
8753
|
+
const origin = await attentionCropOrigin(srcBuf, { width: srcMeta.width, height: srcMeta.height }, plan2);
|
|
8754
|
+
const window = { left: origin.left, top: origin.top, width: plan2.width, height: plan2.height };
|
|
8755
|
+
const label = FORMATS.find((f) => f.id === fmt.id)?.label ?? `${fmt.w}x${fmt.h}`;
|
|
8756
|
+
const node2 = core.store.addNode({
|
|
8757
|
+
projectId: project.id,
|
|
8758
|
+
parentId: cropParentId,
|
|
8759
|
+
kind: "edit",
|
|
8760
|
+
prompt: `Cropped to ${label}`,
|
|
8761
|
+
// No provider was asked; recording the engine the client HAPPENED to
|
|
8762
|
+
// have selected made the overlay display a name that did nothing.
|
|
8763
|
+
engineId: "local"
|
|
8764
|
+
});
|
|
8765
|
+
core.store.setBrief(node2.id, {
|
|
8766
|
+
...brief ?? {},
|
|
8767
|
+
sourceImage: String(srcHash),
|
|
8768
|
+
reshape: "crop",
|
|
8769
|
+
crop: window
|
|
8770
|
+
});
|
|
8771
|
+
const work2 = async () => ({
|
|
8772
|
+
images: [core.images.save(await sharp7(srcBuf).extract(window).png().toBuffer())],
|
|
8773
|
+
costUsd: 0
|
|
8774
|
+
});
|
|
8775
|
+
void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
8776
|
+
(err) => app.log.error({ err }, "crop run failed")
|
|
8777
|
+
);
|
|
8778
|
+
return reply.status(202).send(node2);
|
|
8779
|
+
}
|
|
7962
8780
|
const engine = engines.get(String(engineId));
|
|
7963
8781
|
if (!engine) return reply.status(400).send({ error: `unknown engine ${engineId}` });
|
|
7964
8782
|
const avail = await engine.isAvailable();
|
|
@@ -7971,58 +8789,48 @@ function buildServer(opts) {
|
|
|
7971
8789
|
const ctx = brandContext(core, project.brandId);
|
|
7972
8790
|
let compiled2 = null;
|
|
7973
8791
|
let inheritedTokens = [];
|
|
7974
|
-
let
|
|
8792
|
+
let mergedEdit = null;
|
|
7975
8793
|
let editScope = "global";
|
|
7976
|
-
let editRemoval = false;
|
|
7977
8794
|
const extraWarnings = [];
|
|
7978
8795
|
let expandPlan = null;
|
|
7979
8796
|
let expandSourceHash = null;
|
|
7980
8797
|
if (brief && Array.isArray(brief.tokens)) {
|
|
7981
8798
|
const briefErrors = validateBrief(brief);
|
|
7982
8799
|
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
8800
|
+
if (kind === "edit") {
|
|
8801
|
+
const edit = await compileEditBrief(project.brandId, resolvedParentId, brief, engine.capabilities(), {
|
|
8802
|
+
reshape
|
|
8803
|
+
});
|
|
8804
|
+
compiled2 = edit.compiled;
|
|
8805
|
+
inheritedTokens = edit.inheritedTokens;
|
|
8806
|
+
mergedEdit = edit.merged;
|
|
8807
|
+
editScope = edit.editScope;
|
|
8808
|
+
extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
|
|
8809
|
+
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
8810
|
+
return reply.status(400).send({ error: "the brief is empty" });
|
|
8811
|
+
} else {
|
|
8812
|
+
const brandJson = await brandJsonWithResolvedPresenters(
|
|
7988
8813
|
core,
|
|
7989
8814
|
templatesRoot,
|
|
7990
|
-
|
|
7991
|
-
|
|
8815
|
+
presenters,
|
|
8816
|
+
await brandJsonWithResolvedDemoProducts(
|
|
8817
|
+
core,
|
|
8818
|
+
templatesRoot,
|
|
8819
|
+
demoProducts,
|
|
8820
|
+
brandJsonWithCatalogProducts(core, project.brandId),
|
|
8821
|
+
brief.tokens
|
|
8822
|
+
),
|
|
7992
8823
|
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
8824
|
);
|
|
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);
|
|
8825
|
+
const sceneById = sceneFor(brandJson);
|
|
8826
|
+
compiled2 = compileBrief(brief, {
|
|
8827
|
+
brand: brandJson,
|
|
8828
|
+
images: core.images,
|
|
8829
|
+
engineCaps: engine.capabilities(),
|
|
8830
|
+
template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
|
|
8831
|
+
templateById: sceneById
|
|
8832
|
+
});
|
|
8833
|
+
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
|
|
8026
8834
|
}
|
|
8027
8835
|
}
|
|
8028
8836
|
let finalPrompt = String(prompt ?? "");
|
|
@@ -8066,6 +8874,8 @@ function buildServer(opts) {
|
|
|
8066
8874
|
let work;
|
|
8067
8875
|
let expectShape;
|
|
8068
8876
|
let editedFrom = null;
|
|
8877
|
+
let runEngine = engine;
|
|
8878
|
+
let expandMethod = null;
|
|
8069
8879
|
if (compiled2) {
|
|
8070
8880
|
finalPrompt = compiled2.prompt;
|
|
8071
8881
|
referenceImages = compiled2.referenceImages;
|
|
@@ -8103,23 +8913,30 @@ function buildServer(opts) {
|
|
|
8103
8913
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
8104
8914
|
if (!engine.capabilities().supportsEdit)
|
|
8105
8915
|
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
|
-
);
|
|
8916
|
+
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
8917
|
const srcBuf = core.images.read(String(srcHash));
|
|
8115
|
-
const srcMeta = await
|
|
8918
|
+
const srcMeta = await sharp7(srcBuf).metadata();
|
|
8116
8919
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
8117
8920
|
if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
|
|
8118
8921
|
expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
|
|
8119
8922
|
}
|
|
8923
|
+
if (reshape === "extend" && !expandPlan)
|
|
8924
|
+
return reply.status(400).send({ error: "the picture is already this shape" });
|
|
8925
|
+
if (expandPlan && srcMeta.width && srcMeta.height) {
|
|
8926
|
+
const size = { width: srcMeta.width, height: srcMeta.height };
|
|
8927
|
+
expandPlan = placeExpand(expandPlan, size, await subjectFraction(srcBuf, size, expandPlan.axis));
|
|
8928
|
+
}
|
|
8929
|
+
if (expandPlan) {
|
|
8930
|
+
const route = await resolveOutpaintRoute(engines.all(), engine);
|
|
8931
|
+
runEngine = route.engine;
|
|
8932
|
+
expandMethod = route.method;
|
|
8933
|
+
}
|
|
8934
|
+
const canOutpaint2 = expandMethod === "outpaint";
|
|
8120
8935
|
if (expandPlan) {
|
|
8121
|
-
|
|
8122
|
-
|
|
8936
|
+
if (!canOutpaint2) {
|
|
8937
|
+
const canvas = await expandCanvas(srcBuf, expandPlan);
|
|
8938
|
+
expandSourceHash = core.images.save(canvas);
|
|
8939
|
+
}
|
|
8123
8940
|
expectShape = { width: expandPlan.width, height: expandPlan.height };
|
|
8124
8941
|
}
|
|
8125
8942
|
const editReq = {
|
|
@@ -8127,27 +8944,98 @@ function buildServer(opts) {
|
|
|
8127
8944
|
sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
|
|
8128
8945
|
brand: ctx,
|
|
8129
8946
|
...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
|
|
8130
|
-
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {}
|
|
8947
|
+
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
|
|
8948
|
+
// An answer at the planned size lets compositeExpand skip its rescale,
|
|
8949
|
+
// which is one whole class of seam misalignment gone when honored.
|
|
8950
|
+
...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : {},
|
|
8951
|
+
// Only an engine that can genuinely paint a margin is told where the
|
|
8952
|
+
// picture sits; the rest would ignore it anyway.
|
|
8953
|
+
...expandPlan && canOutpaint2 ? {
|
|
8954
|
+
expand: {
|
|
8955
|
+
left: expandPlan.left,
|
|
8956
|
+
top: expandPlan.top,
|
|
8957
|
+
width: srcMeta.width ?? 0,
|
|
8958
|
+
height: srcMeta.height ?? 0
|
|
8959
|
+
},
|
|
8960
|
+
// Derived from the picture and the shape asked for, so the same
|
|
8961
|
+
// extend of the same shot is the same picture every time. Without
|
|
8962
|
+
// it the margin is a fresh roll of the dice on every run, which
|
|
8963
|
+
// is not something a person can iterate against — or that a test
|
|
8964
|
+
// can measure.
|
|
8965
|
+
seed: seedFor(String(editedFrom ?? srcHash), expandPlan.width, expandPlan.height)
|
|
8966
|
+
} : {}
|
|
8131
8967
|
};
|
|
8132
|
-
estimate = await
|
|
8133
|
-
|
|
8968
|
+
estimate = await runEngine.costEstimate(editReq);
|
|
8969
|
+
const plan2 = expandPlan;
|
|
8970
|
+
const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
|
|
8971
|
+
const original2 = srcBuf;
|
|
8972
|
+
work = plan2 && !canOutpaint2 ? async (signal) => {
|
|
8973
|
+
const draws = await Promise.all([
|
|
8974
|
+
runEngine.edit(editReq, signal),
|
|
8975
|
+
runEngine.edit(editReq, signal).catch(() => null)
|
|
8976
|
+
]);
|
|
8977
|
+
const scored = await Promise.all(
|
|
8978
|
+
draws.map(async (got) => {
|
|
8979
|
+
const first = got?.images[0];
|
|
8980
|
+
if (!got || !first) return null;
|
|
8981
|
+
const { image } = await compositeExpand(core.images.read(first), original2, plan2);
|
|
8982
|
+
const [score, residual] = await Promise.all([
|
|
8983
|
+
seamScore(image, plan2, srcSize),
|
|
8984
|
+
seamResidual(image, plan2, srcSize)
|
|
8985
|
+
]);
|
|
8986
|
+
return { got, penalty: seamPenalty(score, residual) };
|
|
8987
|
+
})
|
|
8988
|
+
);
|
|
8989
|
+
const best = scored.filter((x) => x !== null).sort((a, b) => a.penalty - b.penalty)[0];
|
|
8990
|
+
return best?.got ?? draws[0];
|
|
8991
|
+
} : (signal) => runEngine.edit(editReq, signal);
|
|
8134
8992
|
}
|
|
8135
|
-
|
|
8993
|
+
const billedId = runEngine.capabilities().id;
|
|
8994
|
+
core.ledger.assertUnderCap(billedId, estimate + (reserved.get(billedId) ?? 0));
|
|
8136
8995
|
const node = core.store.addNode({
|
|
8137
8996
|
projectId: project.id,
|
|
8138
8997
|
parentId: resolvedParentId,
|
|
8139
8998
|
kind,
|
|
8140
8999
|
prompt: finalPrompt,
|
|
8141
|
-
engineId:
|
|
9000
|
+
engineId: billedId
|
|
8142
9001
|
});
|
|
8143
|
-
if (brief)
|
|
9002
|
+
if (brief)
|
|
9003
|
+
core.store.setBrief(node.id, {
|
|
9004
|
+
...brief,
|
|
9005
|
+
...editedFrom ? { sourceImage: editedFrom } : {},
|
|
9006
|
+
...kind === "edit" && reshape ? { reshape } : {},
|
|
9007
|
+
// How the margin was actually made, and by whom. An extend may be
|
|
9008
|
+
// handed to a different engine than the shot used, and a record that
|
|
9009
|
+
// does not say so cannot be read back later.
|
|
9010
|
+
...expandMethod && expandPlan ? {
|
|
9011
|
+
expand: {
|
|
9012
|
+
method: expandMethod,
|
|
9013
|
+
engineId: billedId,
|
|
9014
|
+
// Where the protected picture sits in the frame it grew into.
|
|
9015
|
+
// Placement is no longer always centred, so a reader that
|
|
9016
|
+
// assumes it is would be looking in the wrong place.
|
|
9017
|
+
left: expandPlan.left,
|
|
9018
|
+
top: expandPlan.top
|
|
9019
|
+
}
|
|
9020
|
+
} : {},
|
|
9021
|
+
// What the refinement carried, recorded apart from what it asked for:
|
|
9022
|
+
// the detail view shows both, and remix reads tokens alone.
|
|
9023
|
+
...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
|
|
9024
|
+
});
|
|
8144
9025
|
const plan = expandPlan;
|
|
8145
9026
|
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
8146
9027
|
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
8147
9028
|
const post = plan ? async (images) => {
|
|
8148
9029
|
const out = [];
|
|
8149
9030
|
for (const h of images) {
|
|
8150
|
-
const
|
|
9031
|
+
const answer = core.images.read(h);
|
|
9032
|
+
const got = await sharp7(answer).metadata();
|
|
9033
|
+
if (got.width !== plan.width || got.height !== plan.height)
|
|
9034
|
+
app.log.info(
|
|
9035
|
+
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
9036
|
+
"expand: engine size differs from plan"
|
|
9037
|
+
);
|
|
9038
|
+
const { image, aligned } = await compositeExpand(answer, original, plan);
|
|
8151
9039
|
if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
|
|
8152
9040
|
out.push(core.images.save(image));
|
|
8153
9041
|
}
|
|
@@ -8161,7 +9049,7 @@ function buildServer(opts) {
|
|
|
8161
9049
|
}
|
|
8162
9050
|
return out;
|
|
8163
9051
|
} : void 0;
|
|
8164
|
-
void runNode(node.id,
|
|
9052
|
+
void runNode(node.id, runEngine, estimate, work, expectShape, post).catch(
|
|
8165
9053
|
(err) => app.log.error({ err }, "node run failed")
|
|
8166
9054
|
);
|
|
8167
9055
|
const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
|
|
@@ -8317,6 +9205,7 @@ async function run() {
|
|
|
8317
9205
|
const onlyThisMachine = LOOPBACK2.includes(HOST);
|
|
8318
9206
|
const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
|
|
8319
9207
|
const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
|
|
9208
|
+
await repairPresenterCrops(core, (line) => console.log(line));
|
|
8320
9209
|
const app = buildServer({
|
|
8321
9210
|
core,
|
|
8322
9211
|
engines,
|
|
@@ -8421,8 +9310,8 @@ async function verify() {
|
|
|
8421
9310
|
const db = new Database2(":memory:");
|
|
8422
9311
|
db.pragma("user_version");
|
|
8423
9312
|
db.close();
|
|
8424
|
-
const { default:
|
|
8425
|
-
await
|
|
9313
|
+
const { default: sharp18 } = await import('sharp');
|
|
9314
|
+
await sharp18({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
8426
9315
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
8427
9316
|
} catch (err) {
|
|
8428
9317
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|