scenri 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/dist/{chunk-3ZRMKVBB.js → chunk-WORM6GB3.js} +128 -3
- package/dist/{cli-MQO66C5O.js → cli-EXASA5BK.js} +3 -3
- package/dist/index.js +2 -2
- package/dist/serve.js +2156 -336
- package/dist/{src-3ITTV2UE.js → src-4N4TS3HO.js} +3 -3
- package/package.json +1 -1
- package/studio-dist/assets/facial-2-BmVnDzjJ.webp +0 -0
- package/studio-dist/assets/facial-3-DZtKCJBc.webp +0 -0
- package/studio-dist/assets/facial-4-D3bBk4Y-.webp +0 -0
- package/studio-dist/assets/facial-5-BZUOhBlt.webp +0 -0
- package/studio-dist/assets/facial-6-BjNlybGd.webp +0 -0
- package/studio-dist/assets/freckles-1-EZ0x3-vK.webp +0 -0
- package/studio-dist/assets/freckles-2-DuHEq26I.webp +0 -0
- package/studio-dist/assets/freckles-3-WGP6r0Fh.webp +0 -0
- package/studio-dist/assets/glasses-1-DPlo67j6.webp +0 -0
- package/studio-dist/assets/glasses-2-mRgaHYWV.webp +0 -0
- package/studio-dist/assets/glasses-3-DuTs0m8y.webp +0 -0
- package/studio-dist/assets/glasses-4-DGjoOjFS.webp +0 -0
- package/studio-dist/assets/glasses-5-DoEVdOaY.webp +0 -0
- package/studio-dist/assets/glasses-6-CcUVUrfz.webp +0 -0
- package/studio-dist/assets/{index-BGts89lc.css → index-CVbs0aDq.css} +1 -1
- package/studio-dist/assets/index-CyQ6cSKd.js +107 -0
- package/studio-dist/assets/length-1-androgynous-DiFL-HbJ.webp +0 -0
- package/studio-dist/assets/length-1-man-OI1u_7yA.webp +0 -0
- package/studio-dist/assets/length-1-woman-BbYkzjnG.webp +0 -0
- package/studio-dist/assets/length-2-androgynous-zBgi1XzH.webp +0 -0
- package/studio-dist/assets/length-2-man-Df3fVDUO.webp +0 -0
- package/studio-dist/assets/length-2-woman-BP1NSRcI.webp +0 -0
- package/studio-dist/assets/length-3-androgynous-BN_SLD6d.webp +0 -0
- package/studio-dist/assets/length-3-man-Dk_3OkKz.webp +0 -0
- package/studio-dist/assets/length-3-woman-BFV6b4If.webp +0 -0
- package/studio-dist/assets/length-4-androgynous-BblLWRi-.webp +0 -0
- package/studio-dist/assets/length-4-man-Cszq08t7.webp +0 -0
- package/studio-dist/assets/length-4-woman-B8gzWrbX.webp +0 -0
- package/studio-dist/assets/length-5-androgynous-DwxP_mgy.webp +0 -0
- package/studio-dist/assets/length-5-man-Yt6YRiWE.webp +0 -0
- package/studio-dist/assets/length-5-woman-BTz0YJMm.webp +0 -0
- package/studio-dist/assets/length-6-androgynous-BD7OoH6P.webp +0 -0
- package/studio-dist/assets/length-6-man-oceHSSPF.webp +0 -0
- package/studio-dist/assets/length-6-woman-BvX37wCz.webp +0 -0
- package/studio-dist/assets/makeup-1-CvGOSJO_.webp +0 -0
- package/studio-dist/assets/makeup-3-yoncgA8G.webp +0 -0
- package/studio-dist/assets/makeup-4-Buulx0wj.webp +0 -0
- package/studio-dist/assets/makeup-5-5Y_Qpn53.webp +0 -0
- package/studio-dist/assets/makeup-6-CSQm_m8e.webp +0 -0
- package/studio-dist/assets/prosthetic-1-C-bbKoKE.webp +0 -0
- package/studio-dist/assets/prosthetic-2-DCGXnKzb.webp +0 -0
- package/studio-dist/assets/prosthetic-3-BW_gdjlG.webp +0 -0
- package/studio-dist/assets/prosthetic-4-ec_5-7Tb.webp +0 -0
- package/studio-dist/assets/prosthetic-5-res5u7gj.webp +0 -0
- package/studio-dist/assets/prosthetic-6-0AvaEGMf.webp +0 -0
- package/studio-dist/assets/tattoo-3-33-3gd4C.webp +0 -0
- package/studio-dist/assets/tattoo-4-BW22hxdb.webp +0 -0
- package/studio-dist/assets/tattoo-5-BWJwjBfm.webp +0 -0
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-uiX1mW43.js +0 -104
package/dist/serve.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createUpdateChecker, classify, isReleaseTriplet, findNpm, stageVersion } from './chunk-FYQ5BAFA.js';
|
|
2
2
|
import { shouldAdoptRunning, anotherScenriLines, portBusyLines } from './chunk-CMLJGPYC.js';
|
|
3
|
-
import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, budgetSize, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, termMatches, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-
|
|
3
|
+
import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, budgetSize, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, termMatches, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-WORM6GB3.js';
|
|
4
4
|
import { detectInstallKind } from './chunk-PAIAXRAC.js';
|
|
5
5
|
export { detectInstallKind } from './chunk-PAIAXRAC.js';
|
|
6
6
|
import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
|
|
@@ -1238,6 +1238,15 @@ function createRunner(opts = {}) {
|
|
|
1238
1238
|
}
|
|
1239
1239
|
return { run: run2, withWorkDir, probe, invalidateProbe, connect, invalidateConnection, noteConnection };
|
|
1240
1240
|
}
|
|
1241
|
+
var PHOTO_VIEWS = [
|
|
1242
|
+
"portrait",
|
|
1243
|
+
"front",
|
|
1244
|
+
"three-quarter",
|
|
1245
|
+
"back",
|
|
1246
|
+
"left",
|
|
1247
|
+
"right",
|
|
1248
|
+
"other"
|
|
1249
|
+
];
|
|
1241
1250
|
var OUT_FILE = "analysis.json";
|
|
1242
1251
|
function createCodexAnalyzer(opts = {}) {
|
|
1243
1252
|
const runner = opts.runner ?? createRunner(opts);
|
|
@@ -1289,7 +1298,10 @@ function presenterBody(req, refCount) {
|
|
|
1289
1298
|
const same = refCount > 1 ? " Every attached photograph is the same person." : "";
|
|
1290
1299
|
const ask = req.instruction ? ` The person who supplied these adds: ${req.instruction}.` : "";
|
|
1291
1300
|
const categories = req.vocabulary?.categories?.length ? ` Choose "suitableCategories" only from this list: ${req.vocabulary.categories.join(", ")}.` : "";
|
|
1292
|
-
return `These are photographs of one person, supplied by the person casting them.${same}${ask} Write a casting sheet: what a photographer would need in order to recognise this person again in a different shot. Do not name, identify, or guess who this person is, and do not use any proper name anywhere in your answer. Describe the person, not the photograph: their build, face and hair belong to them, but the background, crop, pose, clothing and lighting of these pictures belong to a shoot and must be left out. ${OUT_FILE} must be a JSON object with exactly these keys: "promptName": a short noun phrase a generator can be handed, such as "a woman in her early thirties with dark shoulder-length waves"; "presentation": either "woman" or "man"; "descriptor": a three-beat casting caption joined by " \xB7 ", such as "Warm editorial \xB7 dark waves \xB7 composed"; "ageRange": an approximate range such as "early 30s"; "hair": colour, length, texture and how it is worn; "identityNotes": one paragraph naming the two or three features that must survive every generation, drawn from face shape, eyes, nose, mouth, jaw, skin, distinctive marks, and build where it is visible; "negativeConstraints": an array of short refusals for the drift these photographs invite, such as "no youth-smoothing that erases the natural lines"; "suitableCategories": the industries this person would be cast for.${categories} "coverage": an array of at most two short sentences naming a view that is missing and would make this person more consistent, such as "A three-quarter photo would pin the cheekbones down." Use an empty array when the coverage is already good
|
|
1301
|
+
return `These are photographs of one person, supplied by the person casting them.${same}${ask} Write a casting sheet: what a photographer would need in order to recognise this person again in a different shot. Do not name, identify, or guess who this person is, and do not use any proper name anywhere in your answer. Describe the person, not the photograph: their build, face and hair belong to them, but the background, crop, pose, clothing and lighting of these pictures belong to a shoot and must be left out. ${OUT_FILE} must be a JSON object with exactly these keys: "promptName": a short noun phrase a generator can be handed, such as "a woman in her early thirties with dark shoulder-length waves"; "presentation": either "woman" or "man"; "descriptor": a three-beat casting caption joined by " \xB7 ", such as "Warm editorial \xB7 dark waves \xB7 composed"; "ageRange": an approximate range such as "early 30s"; "hair": colour, length, texture and how it is worn; "identityNotes": one paragraph naming the two or three features that must survive every generation, drawn from face shape, eyes, nose, mouth, jaw, skin, distinctive marks, and build where it is visible; "negativeConstraints": an array of short refusals for the drift these photographs invite, such as "no youth-smoothing that erases the natural lines"; "suitableCategories": the industries this person would be cast for.${categories} "coverage": an array of at most two short sentences naming a view that is missing and would make this person more consistent, such as "A three-quarter photo would pin the cheekbones down." Use an empty array when the coverage is already good; "facial": bone structure in words, the face shape, jaw, cheekbones, eyes and brows a photographer would need to recognise them again; "skin": their skin tone and texture exactly as the photographs show it, never smoothed; "build": their body type and proportions where the photographs show them.` + (req.classifyPhotos ? photosClause(refCount) : "");
|
|
1302
|
+
}
|
|
1303
|
+
function photosClause(refCount) {
|
|
1304
|
+
return ` Also write "photos": an array with one entry per attached image in attachment order (ref-1.png is index 0, up to index ${Math.max(0, refCount - 1)}), each an object {"index", "view", "usable", "note"}, where "view" is exactly one of "portrait" (head and shoulders, the face large and facing the camera), "front" (full length, standing, facing the camera), "three-quarter" (turned about forty-five degrees, both eyes visible), "back" (full length, facing away from the camera), "left" (full length, the left side of the body to the camera), "right" (full length, the right side of the body to the camera), or "other"; "usable" is true only when the image is sharp, evenly lit, unobstructed, and shows this person clearly enough to stand in as that view; "note" is a few words on why. If the photographs appear to show more than one person, also write "conflict": one sentence saying which images disagree; otherwise leave "conflict" out.`;
|
|
1293
1305
|
}
|
|
1294
1306
|
function sceneBody(req) {
|
|
1295
1307
|
const ask = req.instruction ? ` The person who chose these references says what matters in them: ${req.instruction}. Treat that as the deciding word: whatever it calls essential IS essential even if only one reference shows it, and whatever it tells you to ignore stays out even if every reference contains it.` : "";
|
|
@@ -1332,10 +1344,34 @@ function parsePresenter(req, o) {
|
|
|
1332
1344
|
identityNotes: cap(identityNotes, 900),
|
|
1333
1345
|
negativeConstraints: list(o.negativeConstraints, 6, 160),
|
|
1334
1346
|
suitableCategories: pick(o.suitableCategories, req.vocabulary?.categories, 6),
|
|
1335
|
-
coverage: list(o.coverage, 2, 160)
|
|
1347
|
+
coverage: list(o.coverage, 2, 160),
|
|
1348
|
+
// Non-blocking, like scene's `camera`: a model that omits or fumbles
|
|
1349
|
+
// these must not burn the single retry that exists for a broken contract.
|
|
1350
|
+
...optional("facial", cap(str(o.facial), 300)),
|
|
1351
|
+
...optional("skin", cap(str(o.skin), 200)),
|
|
1352
|
+
...optional("build", cap(str(o.build), 200)),
|
|
1353
|
+
...req.classifyPhotos ? optional("photos", photoFilings(o.photos, req.imagePaths.length)) : {},
|
|
1354
|
+
...req.classifyPhotos ? optional("conflict", cap(str(o.conflict), 200)) : {}
|
|
1336
1355
|
}
|
|
1337
1356
|
};
|
|
1338
1357
|
}
|
|
1358
|
+
function optional(key, value) {
|
|
1359
|
+
return value === void 0 || value === "" || Array.isArray(value) && value.length === 0 ? {} : { [key]: value };
|
|
1360
|
+
}
|
|
1361
|
+
function photoFilings(raw, count) {
|
|
1362
|
+
if (!Array.isArray(raw)) return void 0;
|
|
1363
|
+
const out = [];
|
|
1364
|
+
for (const row of raw) {
|
|
1365
|
+
if (!row || typeof row !== "object") continue;
|
|
1366
|
+
const r = row;
|
|
1367
|
+
const index = Number(r.index);
|
|
1368
|
+
if (!Number.isInteger(index) || index < 0 || index >= count) continue;
|
|
1369
|
+
const view = PHOTO_VIEWS.includes(str(r.view).toLowerCase()) ? str(r.view).toLowerCase() : "other";
|
|
1370
|
+
const usable2 = r.usable === true || /^(true|yes)$/i.test(str(r.usable));
|
|
1371
|
+
out.push({ index, view, usable: usable2, note: cap(str(r.note), 160) });
|
|
1372
|
+
}
|
|
1373
|
+
return out.length ? out : void 0;
|
|
1374
|
+
}
|
|
1339
1375
|
function parseScene(req, o) {
|
|
1340
1376
|
const problems = [];
|
|
1341
1377
|
const prompt = str(o.prompt);
|
|
@@ -1839,9 +1875,13 @@ function demoOptionsFromEnv(env) {
|
|
|
1839
1875
|
const out = {};
|
|
1840
1876
|
const stagger = Number(env.SCENRI_DEMO_STAGGER_MS);
|
|
1841
1877
|
if (env.SCENRI_DEMO_STAGGER_MS && Number.isFinite(stagger) && stagger > 0) out.staggerMs = stagger;
|
|
1878
|
+
const delay = Number(env.SCENRI_DEMO_DELAY_MS);
|
|
1879
|
+
if (env.SCENRI_DEMO_DELAY_MS && Number.isFinite(delay) && delay > 0) out.delayMs = delay;
|
|
1842
1880
|
if (env.SCENRI_DEMO_ORDER === "reverse") out.order = "reverse";
|
|
1843
|
-
const
|
|
1844
|
-
if (env.SCENRI_DEMO_FAIL_SLOT && Number.isInteger(
|
|
1881
|
+
const fail2 = Number(env.SCENRI_DEMO_FAIL_SLOT);
|
|
1882
|
+
if (env.SCENRI_DEMO_FAIL_SLOT && Number.isInteger(fail2) && fail2 >= 0) out.failSlot = fail2;
|
|
1883
|
+
const refs = Number(env.SCENRI_DEMO_REFS);
|
|
1884
|
+
if (env.SCENRI_DEMO_REFS && Number.isInteger(refs) && refs > 0) out.maxReferenceImages = refs;
|
|
1845
1885
|
return out;
|
|
1846
1886
|
}
|
|
1847
1887
|
function sleep2(ms, signal) {
|
|
@@ -1894,7 +1934,7 @@ function createDemoEngine(saveImage, opts = {}) {
|
|
|
1894
1934
|
// development and in the end-to-end suite. `placeholder` below is what
|
|
1895
1935
|
// says none of this is a real picture.
|
|
1896
1936
|
supportsOutpaint: true,
|
|
1897
|
-
maxReferenceImages: 0,
|
|
1937
|
+
maxReferenceImages: opts.maxReferenceImages ?? 0,
|
|
1898
1938
|
placeholder: true
|
|
1899
1939
|
};
|
|
1900
1940
|
},
|
|
@@ -1912,6 +1952,7 @@ function createDemoEngine(saveImage, opts = {}) {
|
|
|
1912
1952
|
const landed = /* @__PURE__ */ new Map();
|
|
1913
1953
|
const failures = [];
|
|
1914
1954
|
for (const [position, slot] of slots.entries()) {
|
|
1955
|
+
if (opts.delayMs) await sleep2(opts.delayMs, signal);
|
|
1915
1956
|
if (position > 0 && opts.staggerMs) await sleep2(opts.staggerMs, signal);
|
|
1916
1957
|
if (signal?.aborted) {
|
|
1917
1958
|
if (signal.reason === BUDGET_EXHAUSTED) break;
|
|
@@ -1921,7 +1962,9 @@ function createDemoEngine(saveImage, opts = {}) {
|
|
|
1921
1962
|
failures.push(`demo: slot ${slot + 1} refused`);
|
|
1922
1963
|
continue;
|
|
1923
1964
|
}
|
|
1924
|
-
const
|
|
1965
|
+
const refs = req.referenceImages?.length ?? 0;
|
|
1966
|
+
const label = refs ? `refs=${refs} ${req.prompt}` : req.prompt;
|
|
1967
|
+
const hash = saveImage(await render(colors, label, req.width, req.height, slot + req.prompt.length));
|
|
1925
1968
|
landed.set(slot, hash);
|
|
1926
1969
|
onImage?.(slot, hash);
|
|
1927
1970
|
}
|
|
@@ -1943,6 +1986,30 @@ function createDemoEngine(saveImage, opts = {}) {
|
|
|
1943
1986
|
}
|
|
1944
1987
|
};
|
|
1945
1988
|
}
|
|
1989
|
+
function createDemoAnalyzer(opts = {}) {
|
|
1990
|
+
const rejects = opts.photos === "unusable";
|
|
1991
|
+
const filing = (i) => ({
|
|
1992
|
+
index: i,
|
|
1993
|
+
view: rejects || i > 0 ? "other" : "portrait",
|
|
1994
|
+
usable: !rejects,
|
|
1995
|
+
note: rejects ? "too unclear to read a face from" : "sharp enough"
|
|
1996
|
+
});
|
|
1997
|
+
return {
|
|
1998
|
+
isAvailable: async () => ({ ok: true }),
|
|
1999
|
+
analyze: async (req) => ({
|
|
2000
|
+
promptName: "a person in their thirties",
|
|
2001
|
+
presentation: "woman",
|
|
2002
|
+
descriptor: "Demo read",
|
|
2003
|
+
ageRange: "30s",
|
|
2004
|
+
hair: "dark hair",
|
|
2005
|
+
identityNotes: "read by the demo analyzer, which never looked at anything",
|
|
2006
|
+
negativeConstraints: [],
|
|
2007
|
+
suitableCategories: [],
|
|
2008
|
+
coverage: [],
|
|
2009
|
+
photos: req.imagePaths.map((_, i) => filing(i))
|
|
2010
|
+
})
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
1946
2013
|
var SUBJECTS = /* @__PURE__ */ new Set(["product", "person", "either"]);
|
|
1947
2014
|
var ID = /^[a-z0-9-]+$/;
|
|
1948
2015
|
function isScene(x) {
|
|
@@ -2246,6 +2313,10 @@ var SEAT_TIER = {
|
|
|
2246
2313
|
product: 0,
|
|
2247
2314
|
character: 0,
|
|
2248
2315
|
scene: 0,
|
|
2316
|
+
// One detail of a person, behind every chip they placed: a picture of the
|
|
2317
|
+
// exact glasses is worth having, and never worth the product or the face it
|
|
2318
|
+
// would push out of a four-slot engine.
|
|
2319
|
+
detail: 1,
|
|
2249
2320
|
composition: 1,
|
|
2250
2321
|
style: 2
|
|
2251
2322
|
};
|
|
@@ -2255,8 +2326,9 @@ var ROLE_PRIORITY = {
|
|
|
2255
2326
|
brand: 2,
|
|
2256
2327
|
reference: 3,
|
|
2257
2328
|
scene: 4,
|
|
2258
|
-
|
|
2259
|
-
|
|
2329
|
+
detail: 5,
|
|
2330
|
+
composition: 6,
|
|
2331
|
+
style: 7
|
|
2260
2332
|
};
|
|
2261
2333
|
function allocateAttachments(attachments, cap2) {
|
|
2262
2334
|
const max = Math.max(0, cap2);
|
|
@@ -2392,6 +2464,13 @@ function createThumbStore(core, opts = {}) {
|
|
|
2392
2464
|
async settle() {
|
|
2393
2465
|
await Promise.allSettled([...inflight.values()]);
|
|
2394
2466
|
},
|
|
2467
|
+
evict(hash) {
|
|
2468
|
+
if (!/^[a-f0-9]{32}$/.test(hash)) return;
|
|
2469
|
+
for (const w of THUMB_WIDTHS) {
|
|
2470
|
+
failed.delete(`${hash}-w${w}`);
|
|
2471
|
+
rmSync(pathFor(hash, w), { force: true });
|
|
2472
|
+
}
|
|
2473
|
+
},
|
|
2395
2474
|
clear() {
|
|
2396
2475
|
failed.clear();
|
|
2397
2476
|
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
@@ -2513,6 +2592,19 @@ var serveJpegSized = async (req, reply, path, thumbs, key) => {
|
|
|
2513
2592
|
reply.header("content-type", "image/webp").header("cache-control", immutable).header("etag", etag).header("content-length", String(size));
|
|
2514
2593
|
return reply.send(thumbs.stream(made));
|
|
2515
2594
|
};
|
|
2595
|
+
async function pickBuildEngine(engines, opts = {}) {
|
|
2596
|
+
const ordered = [...engines.all()].sort((a, b) => {
|
|
2597
|
+
const rank = (e) => e.capabilities().id === "codex-cli" ? 0 : 1;
|
|
2598
|
+
return rank(a) - rank(b);
|
|
2599
|
+
});
|
|
2600
|
+
for (const engine of ordered) {
|
|
2601
|
+
const caps = engine.capabilities();
|
|
2602
|
+
if (!caps.maxReferenceImages) continue;
|
|
2603
|
+
if (caps.placeholder && !opts.allowPlaceholder) continue;
|
|
2604
|
+
if ((await engine.isAvailable()).ok) return engine;
|
|
2605
|
+
}
|
|
2606
|
+
return null;
|
|
2607
|
+
}
|
|
2516
2608
|
|
|
2517
2609
|
// src/briefDirectives.ts
|
|
2518
2610
|
function productFidelityDirective(attached) {
|
|
@@ -2599,6 +2691,41 @@ function shotAsksForAPerson(text) {
|
|
|
2599
2691
|
text
|
|
2600
2692
|
);
|
|
2601
2693
|
}
|
|
2694
|
+
function askedView(text) {
|
|
2695
|
+
if (/\b(from behind|from the back|back view|walking away|rear view|seen from behind)\b/i.test(text)) return "back";
|
|
2696
|
+
if (/\b(in profile|profile view|side view|from the side)\b/i.test(text)) return "profile";
|
|
2697
|
+
if (/\b(three[- ]quarter|turned|over the shoulder|over-the-shoulder)\b/i.test(text)) return "three-quarter";
|
|
2698
|
+
return null;
|
|
2699
|
+
}
|
|
2700
|
+
var ANSWERING_ANGLES = {
|
|
2701
|
+
back: ["back"],
|
|
2702
|
+
profile: ["left", "right", "left-profile", "right-profile"],
|
|
2703
|
+
"three-quarter": ["three-quarter"]
|
|
2704
|
+
};
|
|
2705
|
+
function characterRefs(shots, userWords, max) {
|
|
2706
|
+
const cap2 = Math.max(0, max);
|
|
2707
|
+
const out = [];
|
|
2708
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2709
|
+
const board = (s) => {
|
|
2710
|
+
if (!s || out.length >= cap2) return;
|
|
2711
|
+
const file = s.file == null ? null : String(s.file);
|
|
2712
|
+
if (file !== null) {
|
|
2713
|
+
if (seen.has(file)) return;
|
|
2714
|
+
seen.add(file);
|
|
2715
|
+
}
|
|
2716
|
+
out.push(s);
|
|
2717
|
+
};
|
|
2718
|
+
const byAngle = (angle) => shots.find((s) => s.angle === angle);
|
|
2719
|
+
board(byAngle("portrait") ?? shots[0]);
|
|
2720
|
+
const asked = askedView(userWords);
|
|
2721
|
+
if (asked) {
|
|
2722
|
+
const answer = ANSWERING_ANGLES[asked].map(byAngle).find(Boolean);
|
|
2723
|
+
board(answer);
|
|
2724
|
+
}
|
|
2725
|
+
board(byAngle("front"));
|
|
2726
|
+
for (const s of shots) board(s);
|
|
2727
|
+
return out;
|
|
2728
|
+
}
|
|
2602
2729
|
function sceneFigureDirectives(opts) {
|
|
2603
2730
|
const figure = opts.figure.trim().replace(/[.\s]+$/, "");
|
|
2604
2731
|
if (!figure) return [];
|
|
@@ -2716,7 +2843,7 @@ function validateBrief(brief) {
|
|
|
2716
2843
|
const b = brief;
|
|
2717
2844
|
if (!b || typeof b !== "object") return ["brief must be an object"];
|
|
2718
2845
|
if (!Array.isArray(b.tokens)) return ["brief.tokens must be an array"];
|
|
2719
|
-
const
|
|
2846
|
+
const str5 = (v) => typeof v === "string" && v.length > 0;
|
|
2720
2847
|
b.tokens.forEach((raw, i) => {
|
|
2721
2848
|
const at = `tokens[${i}]`;
|
|
2722
2849
|
if (!raw || typeof raw !== "object") {
|
|
@@ -2729,19 +2856,19 @@ function validateBrief(brief) {
|
|
|
2729
2856
|
if (typeof t.v !== "string") errors.push(`${at}.v must be a string`);
|
|
2730
2857
|
break;
|
|
2731
2858
|
case "product":
|
|
2732
|
-
if (!
|
|
2733
|
-
if (t.angle !== void 0 && !
|
|
2859
|
+
if (!str5(t.id)) errors.push(`${at}.id must be a non-empty string`);
|
|
2860
|
+
if (t.angle !== void 0 && !str5(t.angle)) errors.push(`${at}.angle must be a non-empty string`);
|
|
2734
2861
|
break;
|
|
2735
2862
|
case "character":
|
|
2736
2863
|
case "template":
|
|
2737
|
-
if (!
|
|
2864
|
+
if (!str5(t.id)) errors.push(`${at}.id must be a non-empty string`);
|
|
2738
2865
|
break;
|
|
2739
2866
|
case "color":
|
|
2740
|
-
if (!
|
|
2867
|
+
if (!str5(t.hex) || !/^#[0-9a-fA-F]{6}$/.test(String(t.hex))) errors.push(`${at}.hex must be a #RRGGBB color`);
|
|
2741
2868
|
break;
|
|
2742
2869
|
case "ref":
|
|
2743
2870
|
case "mark":
|
|
2744
|
-
if (!
|
|
2871
|
+
if (!str5(t.imageHash)) errors.push(`${at}.imageHash must be a non-empty string`);
|
|
2745
2872
|
if (t.t === "ref" && t.label !== void 0 && typeof t.label !== "string")
|
|
2746
2873
|
errors.push(`${at}.label must be a string when present`);
|
|
2747
2874
|
break;
|
|
@@ -2771,7 +2898,7 @@ function compileBrief(brief, ctx) {
|
|
|
2771
2898
|
let hasPerson = false;
|
|
2772
2899
|
let people = 0;
|
|
2773
2900
|
let sentence = "";
|
|
2774
|
-
|
|
2901
|
+
const userWords = brief.tokens.map((t) => t.t === "text" ? ` ${t.v}` : "").join("");
|
|
2775
2902
|
const append = (s) => {
|
|
2776
2903
|
sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
|
|
2777
2904
|
};
|
|
@@ -2782,7 +2909,6 @@ function compileBrief(brief, ctx) {
|
|
|
2782
2909
|
switch (tok.t) {
|
|
2783
2910
|
case "text":
|
|
2784
2911
|
append(tok.v);
|
|
2785
|
-
userWords += ` ${tok.v}`;
|
|
2786
2912
|
break;
|
|
2787
2913
|
case "product": {
|
|
2788
2914
|
const p = products.find((x) => x.id === tok.id);
|
|
@@ -2833,7 +2959,11 @@ function compileBrief(brief, ctx) {
|
|
|
2833
2959
|
hasPerson = true;
|
|
2834
2960
|
people += 1;
|
|
2835
2961
|
append(c.promptName ?? c.name);
|
|
2836
|
-
const cshots =
|
|
2962
|
+
const cshots = [];
|
|
2963
|
+
for (const s of characterRefs(c.shots ?? [], userWords, CHARACTER_REF_MAX)) {
|
|
2964
|
+
const h = assetHash2(s?.file);
|
|
2965
|
+
if (h && ctx.images.has(h)) cshots.push({ h, ...s?.angle ? { angle: String(s.angle) } : {} });
|
|
2966
|
+
}
|
|
2837
2967
|
if (cshots.length) {
|
|
2838
2968
|
cshots.forEach(({ h, angle }, i) => {
|
|
2839
2969
|
attachments.push({
|
|
@@ -3571,6 +3701,50 @@ var brand_schema_default = {
|
|
|
3571
3701
|
avatar: {
|
|
3572
3702
|
$ref: "#/$defs/assetRef",
|
|
3573
3703
|
description: "Square head-and-shoulders portrait, for surfaces that render a person small or round. Also a crop of the first shot."
|
|
3704
|
+
},
|
|
3705
|
+
source: {
|
|
3706
|
+
enum: ["synthetic", "photos"],
|
|
3707
|
+
description: "Where this person came from: made in Scenri from a description (synthetic) or built from photographs of a real person (photos). Absent on a record written before the distinction existed, which is read as photos. There is no third value: a public figure is not a source Scenri offers."
|
|
3708
|
+
},
|
|
3709
|
+
likeness: {
|
|
3710
|
+
type: "object",
|
|
3711
|
+
additionalProperties: false,
|
|
3712
|
+
required: ["attestedAt", "version"],
|
|
3713
|
+
properties: {
|
|
3714
|
+
attestedAt: {
|
|
3715
|
+
type: "string",
|
|
3716
|
+
description: "When the person casting them confirmed they have permission to use this real person's likeness commercially (ISO 8601)."
|
|
3717
|
+
},
|
|
3718
|
+
version: { const: "v1", description: "Which wording of the confirmation was shown." }
|
|
3719
|
+
},
|
|
3720
|
+
description: "The likeness confirmation recorded for a person built from photographs of a real person. Never set on a synthetic person."
|
|
3721
|
+
},
|
|
3722
|
+
facial: {
|
|
3723
|
+
type: "string",
|
|
3724
|
+
description: "Bone structure in words, because full-length pictures cannot carry it: face shape, jaw, cheekbones, eyes, brows. Injected verbatim, name-prefixed."
|
|
3725
|
+
},
|
|
3726
|
+
skin: {
|
|
3727
|
+
type: "string",
|
|
3728
|
+
description: "Skin tone and texture exactly as the references show it. Injected verbatim, name-prefixed."
|
|
3729
|
+
},
|
|
3730
|
+
build: {
|
|
3731
|
+
type: "string",
|
|
3732
|
+
description: "Body type and proportions. Injected verbatim, name-prefixed."
|
|
3733
|
+
},
|
|
3734
|
+
revisionOf: {
|
|
3735
|
+
type: "string",
|
|
3736
|
+
description: "The record this one replaced: an edit that changes a picture or the identity prose is a new record, so shots made against the old one keep refining against what they were made from."
|
|
3737
|
+
},
|
|
3738
|
+
supersededBy: {
|
|
3739
|
+
type: "string",
|
|
3740
|
+
description: "The record that replaced this one. Absent on the current record; a tool lists only records without it and maps an old id to the current record through it."
|
|
3741
|
+
},
|
|
3742
|
+
identityEdits: {
|
|
3743
|
+
type: "array",
|
|
3744
|
+
items: {
|
|
3745
|
+
type: "string"
|
|
3746
|
+
},
|
|
3747
|
+
description: `The identity-wide instructions accepted when this record's views were drawn, e.g. "shorter hair". Read after identityNotes, so later views follow the change rather than the originals.`
|
|
3574
3748
|
}
|
|
3575
3749
|
}
|
|
3576
3750
|
},
|
|
@@ -4274,19 +4448,70 @@ function svgAsMark(markup, longEdge = 1024) {
|
|
|
4274
4448
|
|
|
4275
4449
|
// ../brand-spec/src/scrapeError.ts
|
|
4276
4450
|
var ScrapeError = class extends Error {
|
|
4277
|
-
constructor(code, message, statusCode = 502) {
|
|
4451
|
+
constructor(code, message, statusCode = 502, httpStatus) {
|
|
4278
4452
|
super(message);
|
|
4279
4453
|
this.code = code;
|
|
4280
4454
|
this.statusCode = statusCode;
|
|
4455
|
+
this.httpStatus = httpStatus;
|
|
4281
4456
|
}
|
|
4282
4457
|
code;
|
|
4283
4458
|
statusCode;
|
|
4459
|
+
httpStatus;
|
|
4284
4460
|
name = "ScrapeError";
|
|
4285
4461
|
};
|
|
4462
|
+
function isRefusal(err) {
|
|
4463
|
+
if (!(err instanceof ScrapeError)) return false;
|
|
4464
|
+
if (err.code === "timeout") return true;
|
|
4465
|
+
if (err.code !== "http_status" || err.httpStatus === void 0) return false;
|
|
4466
|
+
return err.httpStatus === 401 || err.httpStatus === 403 || err.httpStatus === 429 || err.httpStatus >= 500;
|
|
4467
|
+
}
|
|
4286
4468
|
function urlRefusal(reason, message) {
|
|
4287
4469
|
return new ScrapeError(`url_${reason}`, message, 400);
|
|
4288
4470
|
}
|
|
4289
4471
|
|
|
4472
|
+
// ../brand-spec/src/hostManners.ts
|
|
4473
|
+
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4474
|
+
var cooledUntil = /* @__PURE__ */ new Map();
|
|
4475
|
+
var hostOf = (url) => {
|
|
4476
|
+
try {
|
|
4477
|
+
return new URL(url).host;
|
|
4478
|
+
} catch {
|
|
4479
|
+
return "";
|
|
4480
|
+
}
|
|
4481
|
+
};
|
|
4482
|
+
async function waitForHost(host, signal) {
|
|
4483
|
+
if (!host) return;
|
|
4484
|
+
for (; ; ) {
|
|
4485
|
+
const left = (cooledUntil.get(host) ?? 0) - Date.now();
|
|
4486
|
+
if (left <= 0 || signal?.aborted) return;
|
|
4487
|
+
await sleep3(Math.min(left, 250));
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
function coolHost(host, ms) {
|
|
4491
|
+
if (!host || ms <= 0) return;
|
|
4492
|
+
const until = Date.now() + ms;
|
|
4493
|
+
if (until > (cooledUntil.get(host) ?? 0)) cooledUntil.set(host, until);
|
|
4494
|
+
}
|
|
4495
|
+
var RETRY_AFTER_CAP_MS = 6e4;
|
|
4496
|
+
function retryAfterMs(header, now = Date.now()) {
|
|
4497
|
+
if (!header) return null;
|
|
4498
|
+
const secs = Number(header.trim());
|
|
4499
|
+
if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
|
|
4500
|
+
const at = Date.parse(header);
|
|
4501
|
+
if (Number.isNaN(at)) return null;
|
|
4502
|
+
return Math.min(Math.max(0, at - now), RETRY_AFTER_CAP_MS);
|
|
4503
|
+
}
|
|
4504
|
+
function isChallenge(res) {
|
|
4505
|
+
if (res.status !== 429 && res.status !== 503) return false;
|
|
4506
|
+
if (res.headers.get("cf-mitigated")) return true;
|
|
4507
|
+
if (res.headers.get("retry-after")) return false;
|
|
4508
|
+
return /text\/html/i.test(res.headers.get("content-type") ?? "");
|
|
4509
|
+
}
|
|
4510
|
+
function throttleBackoff(attempt) {
|
|
4511
|
+
const base = Math.min(1e3 * 2 ** attempt, 16e3);
|
|
4512
|
+
return base + Math.floor(Math.random() * 400);
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4290
4515
|
// ../brand-spec/src/safeFetch.ts
|
|
4291
4516
|
var DEFAULTS = {
|
|
4292
4517
|
budgetMs: 2e4,
|
|
@@ -4380,6 +4605,7 @@ function createGuardedFetch(opts = {}) {
|
|
|
4380
4605
|
}
|
|
4381
4606
|
}
|
|
4382
4607
|
assertPublicHost(u.hostname, addresses, Boolean(o.allowPrivateHosts));
|
|
4608
|
+
await waitForHost(u.host);
|
|
4383
4609
|
const left = remaining();
|
|
4384
4610
|
if (left <= 0) throw new ScrapeError("timeout", "Reading that site took too long.");
|
|
4385
4611
|
const signal = AbortSignal.any([AbortSignal.timeout(Math.min(o.requestMs, left))]);
|
|
@@ -4409,14 +4635,24 @@ function createGuardedFetch(opts = {}) {
|
|
|
4409
4635
|
continue;
|
|
4410
4636
|
}
|
|
4411
4637
|
if (!res.ok) {
|
|
4412
|
-
|
|
4638
|
+
const refused = () => new ScrapeError("http_status", statusSentence(u.hostname, res.status, isChallenge(res)), 502, res.status);
|
|
4639
|
+
if (isChallenge(res)) {
|
|
4413
4640
|
await res.body?.cancel().catch(() => {
|
|
4414
4641
|
});
|
|
4415
|
-
|
|
4416
|
-
attempts++;
|
|
4417
|
-
continue;
|
|
4642
|
+
throw refused();
|
|
4418
4643
|
}
|
|
4419
|
-
|
|
4644
|
+
if (RETRYABLE.has(res.status)) {
|
|
4645
|
+
const wait = retryAfterMs(res.headers.get("retry-after")) ?? throttleBackoff(attempts);
|
|
4646
|
+
coolHost(u.host, wait);
|
|
4647
|
+
if (attempts < 1 && remaining() > wait + 2e3) {
|
|
4648
|
+
await res.body?.cancel().catch(() => {
|
|
4649
|
+
});
|
|
4650
|
+
await sleep4(wait);
|
|
4651
|
+
attempts++;
|
|
4652
|
+
continue;
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
throw refused();
|
|
4420
4656
|
}
|
|
4421
4657
|
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
4422
4658
|
const cap2 = o[LIMIT[as]];
|
|
@@ -4437,10 +4673,10 @@ function createGuardedFetch(opts = {}) {
|
|
|
4437
4673
|
return guarded;
|
|
4438
4674
|
}
|
|
4439
4675
|
var USER_AGENT = "scenri/0.1 (+https://scenri.co)";
|
|
4440
|
-
var
|
|
4676
|
+
var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4441
4677
|
var RETRYABLE = /* @__PURE__ */ new Set([403, 408, 425, 429, 500, 502, 503, 504]);
|
|
4442
|
-
function statusSentence(host, status) {
|
|
4443
|
-
if (status === 401 || status === 403)
|
|
4678
|
+
function statusSentence(host, status, challenged = false) {
|
|
4679
|
+
if (challenged || status === 401 || status === 403)
|
|
4444
4680
|
return `${host} would not let Scenri read it. Some sites block anything that is not a person in a browser; you can still add the logo and colours by hand.`;
|
|
4445
4681
|
if (status === 404) return `There is no page at that address on ${host}.`;
|
|
4446
4682
|
if (status === 429) return `${host} asked Scenri to slow down. Try again in a minute.`;
|
|
@@ -4538,7 +4774,10 @@ function unwrap(value) {
|
|
|
4538
4774
|
const pairs = [
|
|
4539
4775
|
["<", ">"],
|
|
4540
4776
|
['"', '"'],
|
|
4541
|
-
["'", "'"]
|
|
4777
|
+
["'", "'"],
|
|
4778
|
+
["\u201C", "\u201D"],
|
|
4779
|
+
["\u2018", "\u2019"],
|
|
4780
|
+
["\xAB", "\xBB"]
|
|
4542
4781
|
];
|
|
4543
4782
|
for (const [open, close] of pairs) {
|
|
4544
4783
|
if (value.length >= 2 && value.startsWith(open) && value.endsWith(close)) return value.slice(1, -1).trim();
|
|
@@ -4589,7 +4828,25 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4589
4828
|
...opts.guard,
|
|
4590
4829
|
...opts.fetchImpl ? { fetchImpl: opts.fetchImpl, allowPrivateHosts: true } : {}
|
|
4591
4830
|
});
|
|
4592
|
-
|
|
4831
|
+
let page = null;
|
|
4832
|
+
let refused = null;
|
|
4833
|
+
let missed = null;
|
|
4834
|
+
for (const candidate of readOrder(normalized.url)) {
|
|
4835
|
+
try {
|
|
4836
|
+
page = await get(candidate, "html");
|
|
4837
|
+
break;
|
|
4838
|
+
} catch (err) {
|
|
4839
|
+
if (isRefusal(err)) {
|
|
4840
|
+
refused = err;
|
|
4841
|
+
break;
|
|
4842
|
+
}
|
|
4843
|
+
missed = err;
|
|
4844
|
+
}
|
|
4845
|
+
}
|
|
4846
|
+
if (!page) {
|
|
4847
|
+
if (refused) return addressOnlyBrand(normalized.url, refused.message, opts);
|
|
4848
|
+
throw missed;
|
|
4849
|
+
}
|
|
4593
4850
|
const origin = new URL(page.finalUrl);
|
|
4594
4851
|
const $ = cheerio.load(page.text);
|
|
4595
4852
|
const base = baseOf($, origin);
|
|
@@ -4622,11 +4879,12 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4622
4879
|
const manifest = await readManifest($, base, get);
|
|
4623
4880
|
const candidates = logoCandidates($, base, manifest);
|
|
4624
4881
|
const picked = await downloadMark(candidates, get, opts, warnings);
|
|
4882
|
+
const iconRef = await downloadIcon(candidates, get, opts, picked);
|
|
4625
4883
|
const brand = {
|
|
4626
4884
|
specVersion: "0.1",
|
|
4627
4885
|
meta: {
|
|
4628
4886
|
name: named.value,
|
|
4629
|
-
slug: named.value
|
|
4887
|
+
slug: slugOf(named.value, origin.hostname),
|
|
4630
4888
|
...tagline ? { tagline } : {},
|
|
4631
4889
|
website: origin.origin,
|
|
4632
4890
|
createdWith: opts.createdWith ?? "scenri",
|
|
@@ -4640,13 +4898,16 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4640
4898
|
...palette.neutrals.length ? { neutrals: palette.neutrals.map((hex) => ({ hex })) } : {}
|
|
4641
4899
|
}
|
|
4642
4900
|
} : {},
|
|
4643
|
-
...picked.ref ? {
|
|
4901
|
+
...picked.ref || iconRef ? {
|
|
4644
4902
|
logos: [
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4903
|
+
...picked.ref ? [
|
|
4904
|
+
{
|
|
4905
|
+
role: picked.role,
|
|
4906
|
+
file: picked.ref,
|
|
4907
|
+
...picked.background ? { background: picked.background } : {}
|
|
4908
|
+
}
|
|
4909
|
+
] : [],
|
|
4910
|
+
...iconRef ? [{ role: "mark", file: iconRef }] : []
|
|
4650
4911
|
]
|
|
4651
4912
|
} : {}
|
|
4652
4913
|
};
|
|
@@ -4656,6 +4917,7 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4656
4917
|
report: {
|
|
4657
4918
|
url: page.finalUrl,
|
|
4658
4919
|
host: origin.hostname,
|
|
4920
|
+
read: true,
|
|
4659
4921
|
name: named,
|
|
4660
4922
|
tagline: tagline ?? null,
|
|
4661
4923
|
logo: {
|
|
@@ -4668,6 +4930,45 @@ async function buildFromUrl(url, opts = {}) {
|
|
|
4668
4930
|
}
|
|
4669
4931
|
};
|
|
4670
4932
|
}
|
|
4933
|
+
function readOrder(url) {
|
|
4934
|
+
try {
|
|
4935
|
+
const u = new URL(url);
|
|
4936
|
+
return u.pathname === "/" && !u.search ? [url] : [url, u.origin];
|
|
4937
|
+
} catch {
|
|
4938
|
+
return [url];
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
function slugOf(name, fallback) {
|
|
4942
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || fallback;
|
|
4943
|
+
}
|
|
4944
|
+
function addressOnlyBrand(url, reason, opts) {
|
|
4945
|
+
const origin = new URL(url);
|
|
4946
|
+
const name = origin.hostname.replace(/^www\./, "");
|
|
4947
|
+
return {
|
|
4948
|
+
brand: {
|
|
4949
|
+
specVersion: "0.1",
|
|
4950
|
+
meta: {
|
|
4951
|
+
name,
|
|
4952
|
+
slug: slugOf(name, origin.hostname),
|
|
4953
|
+
website: origin.origin,
|
|
4954
|
+
createdWith: opts.createdWith ?? "scenri",
|
|
4955
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4956
|
+
}
|
|
4957
|
+
},
|
|
4958
|
+
// The first sentence is the site's own refusal, already written for a
|
|
4959
|
+
// person by `statusSentence`; the second is what they can do about it.
|
|
4960
|
+
warnings: [reason, "The brand was made from the address. Add the logo and colours in Settings."],
|
|
4961
|
+
report: {
|
|
4962
|
+
url: origin.origin,
|
|
4963
|
+
host: origin.hostname,
|
|
4964
|
+
read: false,
|
|
4965
|
+
name: { value: name, source: "hostname" },
|
|
4966
|
+
tagline: null,
|
|
4967
|
+
logo: { status: "none", source: null },
|
|
4968
|
+
colors: { count: 0 }
|
|
4969
|
+
}
|
|
4970
|
+
};
|
|
4971
|
+
}
|
|
4671
4972
|
function baseOf($, origin) {
|
|
4672
4973
|
const declared = $("base[href]").attr("href");
|
|
4673
4974
|
if (!declared) return origin;
|
|
@@ -4765,14 +5066,9 @@ async function downloadMark(candidates, get, opts, warnings) {
|
|
|
4765
5066
|
for (const candidate of candidates) {
|
|
4766
5067
|
if (tried >= LOGO_TRIES || get.remaining() <= 0) break;
|
|
4767
5068
|
let buf = null;
|
|
4768
|
-
if (candidate
|
|
4769
|
-
|
|
4770
|
-
if (!
|
|
4771
|
-
buf = Buffer.from(sized, "utf8");
|
|
4772
|
-
} else if (candidate.url?.startsWith("data:")) {
|
|
4773
|
-
const comma = candidate.url.indexOf(",");
|
|
4774
|
-
const body = candidate.url.slice(comma + 1);
|
|
4775
|
-
buf = candidate.url.slice(0, comma).includes(";base64") ? Buffer.from(body, "base64") : Buffer.from(decodeURIComponent(body), "utf8");
|
|
5069
|
+
if (carriesBytes(candidate)) {
|
|
5070
|
+
buf = inlineBytes(candidate);
|
|
5071
|
+
if (!buf) continue;
|
|
4776
5072
|
} else if (candidate.url) {
|
|
4777
5073
|
tried++;
|
|
4778
5074
|
try {
|
|
@@ -4826,6 +5122,45 @@ async function downloadMark(candidates, get, opts, warnings) {
|
|
|
4826
5122
|
warnings.push("No logo captured. Add one manually.");
|
|
4827
5123
|
return { role: "primary", source: null };
|
|
4828
5124
|
}
|
|
5125
|
+
function carriesBytes(candidate) {
|
|
5126
|
+
return Boolean(candidate.svg) || Boolean(candidate.url?.startsWith("data:"));
|
|
5127
|
+
}
|
|
5128
|
+
function inlineBytes(candidate) {
|
|
5129
|
+
if (candidate.svg) {
|
|
5130
|
+
const sized = svgAsMark(candidate.svg);
|
|
5131
|
+
return sized ? Buffer.from(sized, "utf8") : null;
|
|
5132
|
+
}
|
|
5133
|
+
if (!candidate.url) return null;
|
|
5134
|
+
const comma = candidate.url.indexOf(",");
|
|
5135
|
+
const body = candidate.url.slice(comma + 1);
|
|
5136
|
+
return candidate.url.slice(0, comma).includes(";base64") ? Buffer.from(body, "base64") : Buffer.from(decodeURIComponent(body), "utf8");
|
|
5137
|
+
}
|
|
5138
|
+
async function downloadIcon(candidates, get, opts, picked) {
|
|
5139
|
+
if (!opts.saveAsset) return null;
|
|
5140
|
+
if (picked.source && ICON_SOURCES.has(picked.source)) return null;
|
|
5141
|
+
const best = candidates.find((c) => ICON_SOURCES.has(c.source) && (c.svg || c.url));
|
|
5142
|
+
if (!best) return null;
|
|
5143
|
+
let buf = null;
|
|
5144
|
+
if (carriesBytes(best)) {
|
|
5145
|
+
buf = inlineBytes(best);
|
|
5146
|
+
} else if (best.url) {
|
|
5147
|
+
if (get.remaining() <= 0) return null;
|
|
5148
|
+
try {
|
|
5149
|
+
buf = (await get(best.url, "asset")).bytes;
|
|
5150
|
+
} catch {
|
|
5151
|
+
return null;
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
5154
|
+
if (!buf || buf.length === 0) return null;
|
|
5155
|
+
try {
|
|
5156
|
+
const ref = await opts.saveAsset(buf, "logo");
|
|
5157
|
+
if (ref === picked.ref) return null;
|
|
5158
|
+
const shape = opts.inspectMark ? await opts.inspectMark(buf).catch(() => null) : null;
|
|
5159
|
+
return shape?.blank ? null : ref;
|
|
5160
|
+
} catch {
|
|
5161
|
+
return null;
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
4829
5164
|
|
|
4830
5165
|
// ../brand-spec/src/mergeScrape.ts
|
|
4831
5166
|
var str2 = (v) => typeof v === "string" ? v.trim() : "";
|
|
@@ -4888,7 +5223,8 @@ async function inspectMark(buf, toPng2) {
|
|
|
4888
5223
|
}
|
|
4889
5224
|
|
|
4890
5225
|
// ../catalog/src/failures.ts
|
|
4891
|
-
function pageFailure(status) {
|
|
5226
|
+
function pageFailure(status, challenged = false) {
|
|
5227
|
+
if (challenged) return "CHALLENGED";
|
|
4892
5228
|
if (status === 429 || status === 503) return "RATE_LIMITED";
|
|
4893
5229
|
if (status === 401 || status === 403 || status === 405) return "PAGE_BLOCKED";
|
|
4894
5230
|
if (status === 404 || status === 410) return "PAGE_NOT_FOUND";
|
|
@@ -4910,25 +5246,34 @@ function tally(into, reason, n = 1) {
|
|
|
4910
5246
|
into[reason] = (into[reason] ?? 0) + n;
|
|
4911
5247
|
}
|
|
4912
5248
|
function leadingReason(t) {
|
|
4913
|
-
const rows = Object.entries(t);
|
|
5249
|
+
const rows = Object.entries(t).filter(([, n]) => n > 0);
|
|
4914
5250
|
if (!rows.length) return null;
|
|
4915
5251
|
return rows.sort((a, b) => b[1] - a[1])[0][0];
|
|
4916
5252
|
}
|
|
4917
|
-
function
|
|
5253
|
+
function shortfall(asked, worked) {
|
|
5254
|
+
return Math.max(0, asked - Math.max(0, worked));
|
|
5255
|
+
}
|
|
5256
|
+
function summarise(t, saved, asked, failedCount) {
|
|
4918
5257
|
const reason = leadingReason(t);
|
|
4919
5258
|
if (!reason) return null;
|
|
4920
5259
|
const none = saved === 0;
|
|
5260
|
+
const failed = Math.min(asked, failedCount ?? Math.max(0, asked - saved));
|
|
4921
5261
|
switch (reason) {
|
|
4922
5262
|
case "RATE_LIMITED":
|
|
4923
|
-
return none ? "The store asked us to slow down, so nothing could be read. Waiting a few minutes and trying again usually works." : `The store asked us to slow down partway, so ${
|
|
5263
|
+
return none ? "The store asked us to slow down, so nothing could be read. Waiting a few minutes and trying again usually works." : `The store asked us to slow down partway, so ${failed} of ${asked} products were skipped. Trying again picks up the rest.`;
|
|
5264
|
+
// Never "try again in a few minutes": this one does not clear with time,
|
|
5265
|
+
// and saying so would be advice that cannot work.
|
|
5266
|
+
case "CHALLENGED":
|
|
5267
|
+
return none ? "This store checks that visitors are a web browser, so its catalogue could not be read automatically. You can still add products by hand." : `This store started checking that visitors are a web browser, so ${failed} of ${asked} products were skipped.`;
|
|
4924
5268
|
case "PAGE_BLOCKED":
|
|
4925
|
-
return none ? "This store would not let us read its product pages." : `${
|
|
5269
|
+
return none ? "This store would not let us read its product pages." : `${failed} of ${asked} product pages would not open.`;
|
|
4926
5270
|
case "PAGE_NOT_FOUND":
|
|
4927
|
-
return `${
|
|
5271
|
+
return `${failed} of ${asked} products are no longer on the site.`;
|
|
4928
5272
|
case "IMAGE_FORBIDDEN":
|
|
4929
5273
|
case "IMAGE_NOT_FOUND":
|
|
4930
5274
|
case "IMAGE_TIMEOUT":
|
|
4931
5275
|
case "IMAGE_EMPTY":
|
|
5276
|
+
case "UNSUPPORTED_MEDIA":
|
|
4932
5277
|
case "DOWNLOAD_FAILED":
|
|
4933
5278
|
return none ? "We found the products, but none of their pictures could be downloaded." : "Some pictures could not be downloaded. The products they belong to are still here.";
|
|
4934
5279
|
case "ABORTED":
|
|
@@ -4970,6 +5315,19 @@ function absolutize(base, href) {
|
|
|
4970
5315
|
return null;
|
|
4971
5316
|
}
|
|
4972
5317
|
}
|
|
5318
|
+
var CARD_WIDTH = 400;
|
|
5319
|
+
function cardImageUrl(url, width = CARD_WIDTH) {
|
|
5320
|
+
try {
|
|
5321
|
+
const u = new URL(url);
|
|
5322
|
+
for (const key of u.searchParams.keys()) {
|
|
5323
|
+
if (/^(x-amz-|signature|sig|token|expires|policy)/i.test(key)) return url;
|
|
5324
|
+
}
|
|
5325
|
+
u.searchParams.set("width", String(width));
|
|
5326
|
+
return u.toString();
|
|
5327
|
+
} catch {
|
|
5328
|
+
return url;
|
|
5329
|
+
}
|
|
5330
|
+
}
|
|
4973
5331
|
function upgradeImageUrl(url) {
|
|
4974
5332
|
try {
|
|
4975
5333
|
const u = new URL(url);
|
|
@@ -5153,43 +5511,9 @@ async function readBounded2(res, maxBytes) {
|
|
|
5153
5511
|
}
|
|
5154
5512
|
return out + decoder.decode();
|
|
5155
5513
|
}
|
|
5156
|
-
function
|
|
5514
|
+
function sleep5(ms) {
|
|
5157
5515
|
return new Promise((r) => setTimeout(r, ms));
|
|
5158
5516
|
}
|
|
5159
|
-
var cooledUntil = /* @__PURE__ */ new Map();
|
|
5160
|
-
var hostOf = (url) => {
|
|
5161
|
-
try {
|
|
5162
|
-
return new URL(url).host;
|
|
5163
|
-
} catch {
|
|
5164
|
-
return "";
|
|
5165
|
-
}
|
|
5166
|
-
};
|
|
5167
|
-
async function waitForHost(host, signal) {
|
|
5168
|
-
if (!host) return;
|
|
5169
|
-
for (; ; ) {
|
|
5170
|
-
const left = (cooledUntil.get(host) ?? 0) - Date.now();
|
|
5171
|
-
if (left <= 0 || signal?.aborted) return;
|
|
5172
|
-
await sleep4(Math.min(left, 250));
|
|
5173
|
-
}
|
|
5174
|
-
}
|
|
5175
|
-
function coolHost(host, ms) {
|
|
5176
|
-
if (!host || ms <= 0) return;
|
|
5177
|
-
const until = Date.now() + ms;
|
|
5178
|
-
if (until > (cooledUntil.get(host) ?? 0)) cooledUntil.set(host, until);
|
|
5179
|
-
}
|
|
5180
|
-
var RETRY_AFTER_CAP_MS = 6e4;
|
|
5181
|
-
function retryAfterMs(header, now = Date.now()) {
|
|
5182
|
-
if (!header) return null;
|
|
5183
|
-
const secs = Number(header.trim());
|
|
5184
|
-
if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
|
|
5185
|
-
const at = Date.parse(header);
|
|
5186
|
-
if (Number.isNaN(at)) return null;
|
|
5187
|
-
return Math.min(Math.max(0, at - now), RETRY_AFTER_CAP_MS);
|
|
5188
|
-
}
|
|
5189
|
-
function throttleBackoff(attempt) {
|
|
5190
|
-
const base = Math.min(1e3 * 2 ** attempt, 16e3);
|
|
5191
|
-
return base + Math.floor(Math.random() * 400);
|
|
5192
|
-
}
|
|
5193
5517
|
async function httpGet(url, opts = {}) {
|
|
5194
5518
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
5195
5519
|
const retries = opts.retries ?? 3;
|
|
@@ -5230,6 +5554,7 @@ async function httpGet(url, opts = {}) {
|
|
|
5230
5554
|
} catch {
|
|
5231
5555
|
}
|
|
5232
5556
|
}
|
|
5557
|
+
if (isChallenge(res)) return res;
|
|
5233
5558
|
if (res.status === 429 || res.status === 503) {
|
|
5234
5559
|
const asked = retryAfterMs(res.headers.get("retry-after"));
|
|
5235
5560
|
const wait = asked ?? throttleBackoff(attempt);
|
|
@@ -5242,7 +5567,7 @@ async function httpGet(url, opts = {}) {
|
|
|
5242
5567
|
return res;
|
|
5243
5568
|
}
|
|
5244
5569
|
if (res.status >= 500 && attempt < retries) {
|
|
5245
|
-
await
|
|
5570
|
+
await sleep5(throttleBackoff(attempt));
|
|
5246
5571
|
continue;
|
|
5247
5572
|
}
|
|
5248
5573
|
return res;
|
|
@@ -5250,7 +5575,7 @@ async function httpGet(url, opts = {}) {
|
|
|
5250
5575
|
lastErr = err;
|
|
5251
5576
|
if (opts.signal?.aborted) throw err;
|
|
5252
5577
|
if (attempt < retries) {
|
|
5253
|
-
await
|
|
5578
|
+
await sleep5(400 * 2 ** attempt);
|
|
5254
5579
|
continue;
|
|
5255
5580
|
}
|
|
5256
5581
|
throw err;
|
|
@@ -5263,12 +5588,14 @@ async function httpGet(url, opts = {}) {
|
|
|
5263
5588
|
}
|
|
5264
5589
|
async function httpText(url, opts = {}) {
|
|
5265
5590
|
const res = await httpGet(url, opts);
|
|
5591
|
+
const challenged = isChallenge(res);
|
|
5266
5592
|
const text = await readBounded2(res, opts.maxBytes);
|
|
5267
|
-
return { ok: res.ok, status: res.status, text, url: res.url || url };
|
|
5593
|
+
return { ok: res.ok, status: res.status, text, url: res.url || url, challenged };
|
|
5268
5594
|
}
|
|
5269
5595
|
async function httpJson(url, opts = {}) {
|
|
5270
5596
|
const res = await httpGet(url, { ...opts, accept: opts.accept ?? "application/json" });
|
|
5271
|
-
const
|
|
5597
|
+
const challenged = isChallenge(res);
|
|
5598
|
+
const text = await readBounded2(res, opts.maxBytes ?? 16e6);
|
|
5272
5599
|
let json = null;
|
|
5273
5600
|
if (res.ok) {
|
|
5274
5601
|
try {
|
|
@@ -5277,7 +5604,10 @@ async function httpJson(url, opts = {}) {
|
|
|
5277
5604
|
json = null;
|
|
5278
5605
|
}
|
|
5279
5606
|
}
|
|
5280
|
-
return { ok: res.ok && json !== null, status: res.status, json, url: res.url || url, text };
|
|
5607
|
+
return { ok: res.ok && json !== null, status: res.status, json, url: res.url || url, text, challenged };
|
|
5608
|
+
}
|
|
5609
|
+
function outOfTime(deadline) {
|
|
5610
|
+
return deadline != null && Date.now() > deadline;
|
|
5281
5611
|
}
|
|
5282
5612
|
async function mapPool(items, concurrency, fn, signal) {
|
|
5283
5613
|
const results = new Array(items.length);
|
|
@@ -5434,6 +5764,16 @@ var PRODUCT_MARKERS = [
|
|
|
5434
5764
|
var BUY_WORDS = /add to (cart|bag|basket)|buy now|add to my bag/i;
|
|
5435
5765
|
var BUILD_ASSET = /\/_next\/static\/|\/static\/media\/|\/assets\/(icons|flags|ui)\//i;
|
|
5436
5766
|
var NOT_A_PACKSHOT = /logo|icon|sprite|pixel|avatar|\bflags?\b|\/flags?\/|locale|country|currency|badge|payment|social/i;
|
|
5767
|
+
var SITE_CHROME = /login|sign-?in|account|\bcart\b|\bsearch\b|\bmenu\b|burger|newsletter|placeholder|spacer|spinner/i;
|
|
5768
|
+
var VECTOR = /\.svgz?(\?|#|$)/i;
|
|
5769
|
+
var MIN_PACKSHOT_PX = 100;
|
|
5770
|
+
function imageKey(url) {
|
|
5771
|
+
return url.replace(/^https?:/i, "").toLowerCase();
|
|
5772
|
+
}
|
|
5773
|
+
function declaredPx(value) {
|
|
5774
|
+
const n = Number(String(value ?? "").trim());
|
|
5775
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
5776
|
+
}
|
|
5437
5777
|
function looksLikeProduct(html, doc) {
|
|
5438
5778
|
const $ = doc ?? loadHtml(html);
|
|
5439
5779
|
for (const sel of PRODUCT_MARKERS) {
|
|
@@ -5450,17 +5790,27 @@ function looksLikeProduct(html, doc) {
|
|
|
5450
5790
|
}
|
|
5451
5791
|
function galleryImages($, pageUrl, cap2 = 12) {
|
|
5452
5792
|
const images = [];
|
|
5793
|
+
const taken = /* @__PURE__ */ new Set();
|
|
5453
5794
|
const og = attr($.querySelector('meta[property="og:image"]'), "content");
|
|
5454
5795
|
if (og) {
|
|
5455
5796
|
const abs = absolutize(pageUrl, og);
|
|
5456
|
-
if (abs
|
|
5797
|
+
if (abs && !VECTOR.test(abs)) {
|
|
5798
|
+
images.push({ url: abs, position: 0, width: null, height: null, alt: null });
|
|
5799
|
+
taken.add(imageKey(abs));
|
|
5800
|
+
}
|
|
5457
5801
|
}
|
|
5458
5802
|
for (const el of $.querySelectorAll("img[src]")) {
|
|
5459
5803
|
if (images.length >= cap2) break;
|
|
5460
5804
|
const src = attr(el, "src") || attr(el, "data-src");
|
|
5461
5805
|
const abs = src ? absolutize(pageUrl, src) : null;
|
|
5462
5806
|
if (!abs || NOT_A_PACKSHOT.test(abs) || BUILD_ASSET.test(abs)) continue;
|
|
5463
|
-
if (
|
|
5807
|
+
if (SITE_CHROME.test(abs) || VECTOR.test(abs)) continue;
|
|
5808
|
+
const w = declaredPx(attr(el, "width"));
|
|
5809
|
+
const h = declaredPx(attr(el, "height"));
|
|
5810
|
+
if (w !== null && w < MIN_PACKSHOT_PX || h !== null && h < MIN_PACKSHOT_PX) continue;
|
|
5811
|
+
const key = imageKey(abs);
|
|
5812
|
+
if (taken.has(key)) continue;
|
|
5813
|
+
taken.add(key);
|
|
5464
5814
|
images.push({ url: abs, position: images.length, width: null, height: null, alt: attr(el, "alt") ?? null });
|
|
5465
5815
|
}
|
|
5466
5816
|
return images;
|
|
@@ -5511,7 +5861,7 @@ function productsFromPage(html, url) {
|
|
|
5511
5861
|
const one = parseProductHtml(html, url, doc);
|
|
5512
5862
|
return one ? [one] : [];
|
|
5513
5863
|
}
|
|
5514
|
-
var
|
|
5864
|
+
var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5515
5865
|
async function fetchProductPages(ctx, urls, opts = {}) {
|
|
5516
5866
|
const out = [];
|
|
5517
5867
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -5531,8 +5881,8 @@ async function fetchProductPages(ctx, urls, opts = {}) {
|
|
|
5531
5881
|
if (opts.deadline != null && Date.now() > opts.deadline) return;
|
|
5532
5882
|
if (opts.maxTotalBytes != null && bytes >= opts.maxTotalBytes) return;
|
|
5533
5883
|
try {
|
|
5534
|
-
if (opts.delayMs) await
|
|
5535
|
-
const { ok, status, text, url } = await httpText(u, {
|
|
5884
|
+
if (opts.delayMs) await sleep6(opts.delayMs);
|
|
5885
|
+
const { ok, status, text, url, challenged } = await httpText(u, {
|
|
5536
5886
|
fetchImpl: ctx.fetchImpl,
|
|
5537
5887
|
signal: ctx.signal,
|
|
5538
5888
|
accept: "text/html",
|
|
@@ -5546,7 +5896,7 @@ async function fetchProductPages(ctx, urls, opts = {}) {
|
|
|
5546
5896
|
if (!ok) {
|
|
5547
5897
|
if (opts.stats) {
|
|
5548
5898
|
opts.stats.refused = (opts.stats.refused ?? 0) + 1;
|
|
5549
|
-
if (opts.stats.reasons) tally(opts.stats.reasons, pageFailure(status));
|
|
5899
|
+
if (opts.stats.reasons) tally(opts.stats.reasons, pageFailure(status, challenged));
|
|
5550
5900
|
}
|
|
5551
5901
|
if (++refusedRun >= giveUpAt) givenUp = true;
|
|
5552
5902
|
return;
|
|
@@ -5565,6 +5915,7 @@ async function fetchProductPages(ctx, urls, opts = {}) {
|
|
|
5565
5915
|
opts.stats.refused = (opts.stats.refused ?? 0) + 1;
|
|
5566
5916
|
if (opts.stats.reasons) tally(opts.stats.reasons, thrownFailure(err, ctx.signal?.aborted));
|
|
5567
5917
|
}
|
|
5918
|
+
if (!ctx.signal?.aborted && ++refusedRun >= giveUpAt) givenUp = true;
|
|
5568
5919
|
}
|
|
5569
5920
|
},
|
|
5570
5921
|
ctx.signal
|
|
@@ -5631,14 +5982,28 @@ async function fetchProductsJsonPage(ctx, page, limit = 250) {
|
|
|
5631
5982
|
if (ok && json?.products) return { products: json.products, blocked: false };
|
|
5632
5983
|
return { products: [], blocked: status === 401 || status === 403 || status === 404 || status >= 500 };
|
|
5633
5984
|
}
|
|
5985
|
+
function cardOf(origin, p) {
|
|
5986
|
+
const handle = String(p.handle ?? "");
|
|
5987
|
+
const first = (p.images ?? [])[0] ?? p.image ?? null;
|
|
5988
|
+
const src = first ? String(first.src ?? first) : null;
|
|
5989
|
+
return {
|
|
5990
|
+
externalKey: String(p.id),
|
|
5991
|
+
title: String(p.title ?? handle),
|
|
5992
|
+
url: absolutize(origin, `/products/${handle}`) ?? `${originOf(origin)}/products/${handle}`,
|
|
5993
|
+
handle: handle || null,
|
|
5994
|
+
image: src ? cardImageUrl(upgradeImageUrl(src)) : null
|
|
5995
|
+
};
|
|
5996
|
+
}
|
|
5634
5997
|
var JSON_BLOCKED = "json-blocked";
|
|
5998
|
+
var JSON_GIVE_UP = 3;
|
|
5999
|
+
var MAX_SITEMAPS = 40;
|
|
5635
6000
|
async function collectSitemapProductUrls(ctx) {
|
|
5636
6001
|
const origin = originOf(ctx.baseUrl);
|
|
5637
6002
|
const urls = /* @__PURE__ */ new Set();
|
|
5638
6003
|
const candidates = [`${origin}/sitemap.xml`, `${origin}/sitemap_products_1.xml`];
|
|
5639
6004
|
const queue = [...candidates];
|
|
5640
6005
|
const seen = /* @__PURE__ */ new Set();
|
|
5641
|
-
while (queue.length) {
|
|
6006
|
+
while (queue.length && seen.size < MAX_SITEMAPS && !outOfTime(ctx.deadline)) {
|
|
5642
6007
|
const next = queue.shift();
|
|
5643
6008
|
if (seen.has(next)) continue;
|
|
5644
6009
|
seen.add(next);
|
|
@@ -5692,11 +6057,16 @@ var shopifyAdapter = {
|
|
|
5692
6057
|
const hints = [];
|
|
5693
6058
|
const keys = /* @__PURE__ */ new Set();
|
|
5694
6059
|
const productUrls = /* @__PURE__ */ new Set();
|
|
6060
|
+
const cards = [];
|
|
5695
6061
|
const origin = originOf(ctx.baseUrl);
|
|
5696
6062
|
let page = 1;
|
|
5697
6063
|
let emptyStreak = 0;
|
|
5698
6064
|
while (emptyStreak < 1) {
|
|
5699
6065
|
if (ctx.signal?.aborted) throw new Error("aborted");
|
|
6066
|
+
if (outOfTime(ctx.deadline)) {
|
|
6067
|
+
warnings.push("This store was slow to list its catalogue, so only part of it was read");
|
|
6068
|
+
break;
|
|
6069
|
+
}
|
|
5700
6070
|
const { products, blocked: blocked2 } = await fetchProductsJsonPage(ctx, page);
|
|
5701
6071
|
ctx.onProgress?.({ stage: "discovering", discovered: keys.size, message: `Shopify page ${page}` });
|
|
5702
6072
|
if (blocked2 && page === 1) {
|
|
@@ -5710,6 +6080,7 @@ var shopifyAdapter = {
|
|
|
5710
6080
|
for (const p of products) {
|
|
5711
6081
|
keys.add(String(p.id));
|
|
5712
6082
|
if (p.handle) productUrls.add(`${origin}/products/${p.handle}`);
|
|
6083
|
+
cards.push(cardOf(origin, p));
|
|
5713
6084
|
}
|
|
5714
6085
|
if (products.length < 250) break;
|
|
5715
6086
|
page++;
|
|
@@ -5730,6 +6101,7 @@ var shopifyAdapter = {
|
|
|
5730
6101
|
return {
|
|
5731
6102
|
productKeys: [...keys],
|
|
5732
6103
|
productUrls: [...productUrls],
|
|
6104
|
+
cards,
|
|
5733
6105
|
estimatedTotal: keys.size || productUrls.size || null,
|
|
5734
6106
|
// A store whose own product API answered but refused us leaves nothing
|
|
5735
6107
|
// to read but the pages themselves, one request each. That is what
|
|
@@ -5739,6 +6111,43 @@ var shopifyAdapter = {
|
|
|
5739
6111
|
hints
|
|
5740
6112
|
};
|
|
5741
6113
|
},
|
|
6114
|
+
/**
|
|
6115
|
+
* The chosen products, read from the listing instead of a page each.
|
|
6116
|
+
*
|
|
6117
|
+
* Walks `/products.json` only until every handle asked for has been found,
|
|
6118
|
+
* so a small pick is usually one request and the whole catalogue is a
|
|
6119
|
+
* handful. Returns null the moment the listing refuses us, because then the
|
|
6120
|
+
* pages are the only way in and the caller already knows how to read them.
|
|
6121
|
+
*/
|
|
6122
|
+
async fetchSome(ctx, urls) {
|
|
6123
|
+
const origin = originOf(ctx.baseUrl);
|
|
6124
|
+
const handleOf = (u) => {
|
|
6125
|
+
const raw = /\/products\/([^/?#]+)/i.exec(u)?.[1];
|
|
6126
|
+
return raw ? decodeURIComponent(raw) : null;
|
|
6127
|
+
};
|
|
6128
|
+
const want = new Set(urls.map(handleOf).filter((h) => Boolean(h)));
|
|
6129
|
+
if (!want.size) return null;
|
|
6130
|
+
const out = [];
|
|
6131
|
+
let page = 1;
|
|
6132
|
+
while (want.size) {
|
|
6133
|
+
if (ctx.signal?.aborted) throw new Error("aborted");
|
|
6134
|
+
if (outOfTime(ctx.deadline)) break;
|
|
6135
|
+
const { products, blocked: blocked2 } = await fetchProductsJsonPage(ctx, page);
|
|
6136
|
+
if (blocked2 && page === 1) return null;
|
|
6137
|
+
if (!products.length) break;
|
|
6138
|
+
for (const p of products) {
|
|
6139
|
+
const h = String(p.handle ?? "");
|
|
6140
|
+
if (want.delete(h)) {
|
|
6141
|
+
out.push(mapShopifyProduct(origin, p));
|
|
6142
|
+
ctx.onProgress?.({ stage: "fetching_products", fetched: out.length });
|
|
6143
|
+
}
|
|
6144
|
+
}
|
|
6145
|
+
if (products.length < 250) break;
|
|
6146
|
+
page++;
|
|
6147
|
+
if (page > 1e4) break;
|
|
6148
|
+
}
|
|
6149
|
+
return out.length ? out : null;
|
|
6150
|
+
},
|
|
5742
6151
|
async fetchAll(ctx, discovered) {
|
|
5743
6152
|
const origin = originOf(ctx.baseUrl);
|
|
5744
6153
|
const out = [];
|
|
@@ -5772,20 +6181,29 @@ var shopifyAdapter = {
|
|
|
5772
6181
|
const handle = handleOf(u);
|
|
5773
6182
|
return handle && !out.some((p) => p.handle === handle);
|
|
5774
6183
|
});
|
|
5775
|
-
|
|
6184
|
+
const bulkCoveredAll = out.length > 0 && out.length >= discovered.productKeys.length;
|
|
6185
|
+
if (!jsonBlocked && !bulkCoveredAll) {
|
|
5776
6186
|
const missingUrls = stillMissing();
|
|
5777
6187
|
if (missingUrls.length) {
|
|
6188
|
+
let refusedRun = 0;
|
|
6189
|
+
let abandoned = false;
|
|
5778
6190
|
await mapPool(
|
|
5779
6191
|
missingUrls,
|
|
5780
|
-
|
|
6192
|
+
4,
|
|
5781
6193
|
async (u) => {
|
|
6194
|
+
if (abandoned) return;
|
|
5782
6195
|
const handle = handleOf(u);
|
|
5783
6196
|
if (!handle) return;
|
|
5784
|
-
const { ok, json } = await httpJson(`${origin}/products/${handle}.json`, {
|
|
6197
|
+
const { ok, json, challenged } = await httpJson(`${origin}/products/${handle}.json`, {
|
|
5785
6198
|
fetchImpl: ctx.fetchImpl,
|
|
5786
6199
|
signal: ctx.signal
|
|
5787
6200
|
});
|
|
5788
|
-
if (ok
|
|
6201
|
+
if (!ok) {
|
|
6202
|
+
if (challenged || ++refusedRun >= JSON_GIVE_UP) abandoned = true;
|
|
6203
|
+
return;
|
|
6204
|
+
}
|
|
6205
|
+
refusedRun = 0;
|
|
6206
|
+
if (json?.product) {
|
|
5789
6207
|
const mapped = mapShopifyProduct(origin, json.product);
|
|
5790
6208
|
if (!seen.has(mapped.externalKey)) {
|
|
5791
6209
|
seen.add(mapped.externalKey);
|
|
@@ -5843,7 +6261,7 @@ async function extractSitemapUrls(ctx, filter = () => true) {
|
|
|
5843
6261
|
}
|
|
5844
6262
|
if (filter(loc)) out.add(loc.split("?")[0]);
|
|
5845
6263
|
}
|
|
5846
|
-
if (seen.size > 200) break;
|
|
6264
|
+
if (seen.size > 200 || outOfTime(ctx.deadline)) break;
|
|
5847
6265
|
}
|
|
5848
6266
|
return preferCanonicalLocale([...out]);
|
|
5849
6267
|
}
|
|
@@ -5882,7 +6300,7 @@ async function crawlListingPages(ctx) {
|
|
|
5882
6300
|
const found = /* @__PURE__ */ new Set();
|
|
5883
6301
|
const visited = /* @__PURE__ */ new Set();
|
|
5884
6302
|
const queue = [origin, `${origin}/collections/all`, `${origin}/shop`, `${origin}/products`, `${origin}/catalog`];
|
|
5885
|
-
while (queue.length && visited.size < 40) {
|
|
6303
|
+
while (queue.length && visited.size < 40 && !outOfTime(ctx.deadline)) {
|
|
5886
6304
|
const page = queue.shift();
|
|
5887
6305
|
if (visited.has(page)) continue;
|
|
5888
6306
|
visited.add(page);
|
|
@@ -6038,6 +6456,11 @@ var woocommerceAdapter = {
|
|
|
6038
6456
|
let page = 1;
|
|
6039
6457
|
let usedApi = false;
|
|
6040
6458
|
while (true) {
|
|
6459
|
+
if (ctx.signal?.aborted) throw new Error("aborted");
|
|
6460
|
+
if (outOfTime(ctx.deadline)) {
|
|
6461
|
+
warnings.push("This store was slow to list its catalogue, so only part of it was read");
|
|
6462
|
+
break;
|
|
6463
|
+
}
|
|
6041
6464
|
const batch = await tryStoreApi(ctx, page, 100);
|
|
6042
6465
|
if (batch === null) break;
|
|
6043
6466
|
usedApi = true;
|
|
@@ -6397,6 +6820,7 @@ var DEFAULT_SCAN_BUDGET = {
|
|
|
6397
6820
|
*/
|
|
6398
6821
|
previewFloorMs: 12e3
|
|
6399
6822
|
};
|
|
6823
|
+
var DISCOVERY_FLOOR_MS = 8e3;
|
|
6400
6824
|
var COMMERCE_SIGNALS = /* @__PURE__ */ new Set([
|
|
6401
6825
|
"products.json",
|
|
6402
6826
|
"shopify-html",
|
|
@@ -6427,12 +6851,14 @@ async function scanOnce(opts, baseUrl) {
|
|
|
6427
6851
|
const started = Date.now();
|
|
6428
6852
|
const budget = { ...DEFAULT_SCAN_BUDGET, ...opts.budget };
|
|
6429
6853
|
const deadline = started + budget.budgetMs;
|
|
6854
|
+
const discoveryDeadline = Math.max(deadline, started + DISCOVERY_FLOOR_MS);
|
|
6430
6855
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
6431
6856
|
const ctx = {
|
|
6432
6857
|
fetchImpl,
|
|
6433
6858
|
baseUrl,
|
|
6434
6859
|
signal: opts.signal,
|
|
6435
|
-
onProgress: opts.onProgress
|
|
6860
|
+
onProgress: opts.onProgress,
|
|
6861
|
+
deadline: discoveryDeadline
|
|
6436
6862
|
};
|
|
6437
6863
|
const warnings = [];
|
|
6438
6864
|
const stats = { pages: 0, bytes: 0 };
|
|
@@ -6453,10 +6879,18 @@ async function scanOnce(opts, baseUrl) {
|
|
|
6453
6879
|
if (refused > 0) warnings.push(`${refused} pages are disallowed by this site's robots.txt`);
|
|
6454
6880
|
const fromApi = detection.platform !== "generic" && !discovered.hints?.includes("json-blocked");
|
|
6455
6881
|
const countSource = urls.length === 0 ? "none" : fromApi ? "api" : "sitemap";
|
|
6882
|
+
const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
|
|
6456
6883
|
if (urls.length === 0) {
|
|
6457
|
-
const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
|
|
6458
6884
|
return done(commerce ? "likely" : "none", [], [], 0, countSource, warnings);
|
|
6459
6885
|
}
|
|
6886
|
+
const listed = discovered.cards ?? [];
|
|
6887
|
+
if (listed.length) {
|
|
6888
|
+
const byUrl = new Set(urls);
|
|
6889
|
+
const usable2 = listed.filter((c) => byUrl.has(c.url));
|
|
6890
|
+
const cards = usable2.length ? usable2 : listed;
|
|
6891
|
+
opts.onProgress?.({ stage: "discovering", discovered: cards.length, message: `Found ${cards.length} products` });
|
|
6892
|
+
return done("found", [], urls, urls.length || cards.length, countSource, warnings, cards);
|
|
6893
|
+
}
|
|
6460
6894
|
opts.onProgress?.({ stage: "discovering", discovered: urls.length, message: "Reading a few products" });
|
|
6461
6895
|
const previewDeadline = Math.max(deadline, Date.now() + budget.previewFloorMs);
|
|
6462
6896
|
const read = dedupeProducts(
|
|
@@ -6472,15 +6906,16 @@ async function scanOnce(opts, baseUrl) {
|
|
|
6472
6906
|
})
|
|
6473
6907
|
);
|
|
6474
6908
|
const preview = read.slice(0, budget.maxPreviewPages);
|
|
6475
|
-
const verdict = preview.length ? "found" : "blocked";
|
|
6909
|
+
const verdict = preview.length ? "found" : commerce ? "blocked" : "none";
|
|
6476
6910
|
return done(verdict, preview, urls, urls.length, countSource, warnings);
|
|
6477
|
-
function done(verdict2, candidates, candidateUrls, count, source, notes) {
|
|
6911
|
+
function done(verdict2, candidates, candidateUrls, count, source, notes, cards = []) {
|
|
6478
6912
|
return {
|
|
6479
6913
|
baseUrl,
|
|
6480
6914
|
platform: detection?.platform ?? "unknown",
|
|
6481
6915
|
signals: detection?.signals ?? [],
|
|
6482
6916
|
verdict: verdict2,
|
|
6483
6917
|
count,
|
|
6918
|
+
cards,
|
|
6484
6919
|
countSource: source,
|
|
6485
6920
|
candidates,
|
|
6486
6921
|
candidateUrls,
|
|
@@ -6548,11 +6983,14 @@ function progressWriter(patch2) {
|
|
|
6548
6983
|
});
|
|
6549
6984
|
};
|
|
6550
6985
|
}
|
|
6986
|
+
function stoppedMessage(saved) {
|
|
6987
|
+
return saved ? `Stopped after saving ${saved.toLocaleString()} products` : "Stopped before anything was saved";
|
|
6988
|
+
}
|
|
6551
6989
|
async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
6552
6990
|
const { core, fetchImpl } = deps;
|
|
6553
6991
|
const patch2 = (p) => core.catalog.updateJob(jobId, p);
|
|
6992
|
+
const tally2 = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
|
|
6554
6993
|
try {
|
|
6555
|
-
const tally2 = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
|
|
6556
6994
|
const ctx = { fetchImpl: fetchImpl ?? fetch, baseUrl: url, signal };
|
|
6557
6995
|
let urls = [];
|
|
6558
6996
|
let bulk = null;
|
|
@@ -6568,6 +7006,16 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6568
7006
|
baseUrl = url;
|
|
6569
7007
|
platform = detection.platform;
|
|
6570
7008
|
discovered = only.length;
|
|
7009
|
+
const adapter = adapterFor(detection.platform);
|
|
7010
|
+
const listed = adapter.fetchSome ? await adapter.fetchSome({ ...ctx, baseUrl }, only) : null;
|
|
7011
|
+
if (listed?.length) {
|
|
7012
|
+
bulk = listed;
|
|
7013
|
+
const got = new Set(listed.map((p) => p.handle ?? "").filter(Boolean));
|
|
7014
|
+
urls = only.filter((u) => {
|
|
7015
|
+
const h = /\/products\/([^/?#]+)/i.exec(u)?.[1];
|
|
7016
|
+
return !h || !got.has(decodeURIComponent(h));
|
|
7017
|
+
});
|
|
7018
|
+
}
|
|
6571
7019
|
} else {
|
|
6572
7020
|
patch2({ stage: "discovering", message: "Detecting store platform" });
|
|
6573
7021
|
const found = await discoverCatalog({ url, fetchImpl, signal, onProgress: progressWriter(patch2) });
|
|
@@ -6591,7 +7039,7 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6591
7039
|
if (!found.byPage) {
|
|
6592
7040
|
const products = await found.fetchAll();
|
|
6593
7041
|
if (signal.aborted) {
|
|
6594
|
-
patch2({ stage: "cancelled", errors: [], message:
|
|
7042
|
+
patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
|
|
6595
7043
|
return;
|
|
6596
7044
|
}
|
|
6597
7045
|
if (!products.length) {
|
|
@@ -6617,18 +7065,23 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6617
7065
|
}
|
|
6618
7066
|
}
|
|
6619
7067
|
const run2 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered);
|
|
6620
|
-
const pictures = drainPictures(deps, jobId, brandId, tally2, signal);
|
|
7068
|
+
const pictures = drainPictures(deps, jobId, brandId, tally2, signal, hostOf(baseUrl));
|
|
6621
7069
|
let pictureErrors;
|
|
6622
7070
|
let pictureReasons = {};
|
|
6623
7071
|
const stats = { pages: 0, bytes: 0, refused: 0, reasons: {} };
|
|
6624
7072
|
try {
|
|
6625
7073
|
if (bulk) for (const p of bulk) run2.write(p);
|
|
6626
|
-
|
|
7074
|
+
if (urls.length)
|
|
6627
7075
|
await fetchProductPages(ctx, urls, {
|
|
6628
7076
|
concurrency: IMPORT_CONCURRENCY,
|
|
6629
7077
|
maxBytes: 15e5,
|
|
6630
7078
|
onEach: run2.write,
|
|
6631
7079
|
stats,
|
|
7080
|
+
// A run that cannot finish still has to end. Generous enough for a
|
|
7081
|
+
// 2,200 page catalogue, which measures around sixteen minutes, and
|
|
7082
|
+
// short of the forever this had before. Stopping here leaves the run
|
|
7083
|
+
// `partial` with what it saved, never `completed`.
|
|
7084
|
+
deadline: Date.now() + IMPORT_DEADLINE_MS,
|
|
6632
7085
|
// Past this many refusals in a row the store is not going to change
|
|
6633
7086
|
// its mind inside this import, and waiting out its cooldown for the
|
|
6634
7087
|
// rest of the catalogue is time nobody gets a product for.
|
|
@@ -6652,7 +7105,7 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6652
7105
|
patch2({
|
|
6653
7106
|
stage: "cancelled",
|
|
6654
7107
|
errors: [],
|
|
6655
|
-
message: tally2.upserted
|
|
7108
|
+
message: stoppedMessage(tally2.upserted),
|
|
6656
7109
|
finished: true
|
|
6657
7110
|
});
|
|
6658
7111
|
core.catalog.setSourceStatus(run2.sourceId, "partial", true);
|
|
@@ -6664,14 +7117,21 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6664
7117
|
errors: pictureErrors,
|
|
6665
7118
|
refused: stats.refused,
|
|
6666
7119
|
reasons: { ...stats.reasons, ...pictureReasons },
|
|
6667
|
-
|
|
6668
|
-
//
|
|
6669
|
-
|
|
6670
|
-
|
|
7120
|
+
// A chosen set can now come partly from the listing and partly from the
|
|
7121
|
+
// pages it did not carry, so both halves count.
|
|
7122
|
+
asked: (bulk?.length ?? 0) + urls.length,
|
|
7123
|
+
// Addresses that actually yielded a page, which is the unit the person's
|
|
7124
|
+
// question was asked in. Products are the wrong unit: one address can
|
|
7125
|
+
// carry several, and counting them made a run that lost three addresses
|
|
7126
|
+
// report that it had lost one. A listing answers for itself.
|
|
7127
|
+
worked: (bulk?.length ?? 0) + Math.max(0, stats.pages - stats.refused),
|
|
7128
|
+
// Every address was read. A bulk API hands its share over whole, so only
|
|
7129
|
+
// the pages left over have to be covered.
|
|
7130
|
+
covered: stats.pages >= urls.length
|
|
6671
7131
|
});
|
|
6672
7132
|
} catch (err) {
|
|
6673
7133
|
if (signal.aborted) {
|
|
6674
|
-
patch2({ stage: "cancelled", errors: [], message:
|
|
7134
|
+
patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
|
|
6675
7135
|
return;
|
|
6676
7136
|
}
|
|
6677
7137
|
patch2({
|
|
@@ -6685,7 +7145,15 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6685
7145
|
var IMAGES_PER_PRODUCT = 3;
|
|
6686
7146
|
var TERMINAL = /* @__PURE__ */ new Set(["completed", "partial", "no_catalog", "cancelled", "failed"]);
|
|
6687
7147
|
var IMAGE_CONCURRENCY = 4;
|
|
6688
|
-
var
|
|
7148
|
+
var IMAGE_CONCURRENCY_OFF_HOST = 12;
|
|
7149
|
+
function allOffHost(round, storeHost) {
|
|
7150
|
+
if (!storeHost || !round.length) return false;
|
|
7151
|
+
return round.every((img) => {
|
|
7152
|
+
const h = hostOf(img.sourceUrl);
|
|
7153
|
+
return h !== "" && h !== storeHost;
|
|
7154
|
+
});
|
|
7155
|
+
}
|
|
7156
|
+
var sleep7 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6689
7157
|
function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered) {
|
|
6690
7158
|
const { core } = deps;
|
|
6691
7159
|
const patch2 = (p) => core.catalog.updateJob(jobId, p);
|
|
@@ -6736,14 +7204,22 @@ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered)
|
|
|
6736
7204
|
refused = 0,
|
|
6737
7205
|
covered = true,
|
|
6738
7206
|
reasons = {},
|
|
6739
|
-
asked = 0
|
|
7207
|
+
asked = 0,
|
|
7208
|
+
worked
|
|
6740
7209
|
}) {
|
|
6741
7210
|
const mayRetire = sweep && covered && refused === 0 && tally2.upserted > 0;
|
|
6742
7211
|
if (mayRetire) core.catalog.markMissingUnavailable(source.id, tally2.seenKeys);
|
|
7212
|
+
if (tally2.upserted > 0) core.catalog.excludeSharedImages(source.id);
|
|
6743
7213
|
tally2.errors = errors;
|
|
6744
7214
|
const partial = !covered || refused > 0;
|
|
6745
7215
|
const savedNothing = tally2.upserted === 0;
|
|
6746
|
-
const
|
|
7216
|
+
const total = asked || discovered || tally2.fetched;
|
|
7217
|
+
const said = summarise(
|
|
7218
|
+
reasons,
|
|
7219
|
+
tally2.upserted,
|
|
7220
|
+
total,
|
|
7221
|
+
worked === void 0 ? void 0 : shortfall(total, worked)
|
|
7222
|
+
);
|
|
6747
7223
|
const stage = savedNothing ? "failed" : partial ? "partial" : "completed";
|
|
6748
7224
|
const message = savedNothing ? said ?? "We found the products on this site, but none of them could be imported." : said ?? (partial ? `Imported ${tally2.upserted.toLocaleString()} products with ${(errors.length + refused).toLocaleString()} issue${errors.length + refused === 1 ? "" : "s"}` : `Imported ${tally2.upserted.toLocaleString()} products`);
|
|
6749
7225
|
patch2({
|
|
@@ -6762,8 +7238,9 @@ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered)
|
|
|
6762
7238
|
};
|
|
6763
7239
|
}
|
|
6764
7240
|
var IMPORT_CONCURRENCY = 4;
|
|
7241
|
+
var IMPORT_DEADLINE_MS = 40 * 6e4;
|
|
6765
7242
|
var PICTURE_ROUND = 60;
|
|
6766
|
-
function drainPictures(deps, jobId, brandId, tally2, signal, imagesPerProduct = IMAGES_PER_PRODUCT) {
|
|
7243
|
+
function drainPictures(deps, jobId, brandId, tally2, signal, storeHost = "", imagesPerProduct = IMAGES_PER_PRODUCT) {
|
|
6767
7244
|
const { core, fetchImpl } = deps;
|
|
6768
7245
|
const patch2 = (p) => core.catalog.updateJob(jobId, p);
|
|
6769
7246
|
const errors = [...core.catalog.getJob(jobId)?.errors ?? []];
|
|
@@ -6774,13 +7251,13 @@ function drainPictures(deps, jobId, brandId, tally2, signal, imagesPerProduct =
|
|
|
6774
7251
|
const round = core.catalog.listImagesNeedingAssets(brandId, PICTURE_ROUND).filter((img) => img.position < imagesPerProduct);
|
|
6775
7252
|
if (!round.length) {
|
|
6776
7253
|
if (stopped) break;
|
|
6777
|
-
await
|
|
7254
|
+
await sleep7(150);
|
|
6778
7255
|
continue;
|
|
6779
7256
|
}
|
|
6780
|
-
tally2.imagesTotal
|
|
7257
|
+
tally2.imagesTotal = tally2.imagesDone + core.catalog.countImagesNeedingAssets(brandId, imagesPerProduct);
|
|
6781
7258
|
await mapPool(
|
|
6782
7259
|
round,
|
|
6783
|
-
IMAGE_CONCURRENCY,
|
|
7260
|
+
allOffHost(round, storeHost) ? IMAGE_CONCURRENCY_OFF_HOST : IMAGE_CONCURRENCY,
|
|
6784
7261
|
async (img) => {
|
|
6785
7262
|
if (signal.aborted) return;
|
|
6786
7263
|
try {
|
|
@@ -6797,6 +7274,11 @@ function drainPictures(deps, jobId, brandId, tally2, signal, imagesPerProduct =
|
|
|
6797
7274
|
return;
|
|
6798
7275
|
}
|
|
6799
7276
|
const probe = await sharp21(buf).metadata();
|
|
7277
|
+
if (probe.format === "svg") {
|
|
7278
|
+
errors.push({ code: "image_unsupported", message: "Not a photograph", url: img.sourceUrl });
|
|
7279
|
+
tally(reasons, "UNSUPPORTED_MEDIA");
|
|
7280
|
+
return;
|
|
7281
|
+
}
|
|
6800
7282
|
const turned = (probe.orientation ?? 1) > 1;
|
|
6801
7283
|
const keep = turned ? await sharp21(buf).rotate().toBuffer() : buf;
|
|
6802
7284
|
const meta = turned ? await sharp21(keep).metadata() : probe;
|
|
@@ -6896,12 +7378,26 @@ function brandJsonWithCatalogProducts(core, brandId) {
|
|
|
6896
7378
|
function runningImportCount() {
|
|
6897
7379
|
return running.size;
|
|
6898
7380
|
}
|
|
7381
|
+
var IDENTITY_EDITS_MAX = 8;
|
|
7382
|
+
var IDENTITY_EDIT_CHARS = 120;
|
|
7383
|
+
var PRESENTER_SOURCES = ["synthetic", "photos"];
|
|
7384
|
+
var LIKENESS_VERSION = "v1";
|
|
6899
7385
|
var PRESENTER_ID_PREFIX = "up";
|
|
6900
7386
|
var SCENE_ID_PREFIX = "us";
|
|
6901
7387
|
var ASSET_WIDTH = 1024;
|
|
6902
7388
|
var ASSET_HEIGHT = 1280;
|
|
6903
7389
|
var mintId = (prefix) => `${prefix}-${randomUUID().slice(0, 8)}`;
|
|
6904
7390
|
var str3 = (v, max) => String(v ?? "").trim().slice(0, max);
|
|
7391
|
+
var phrase = (v, max) => {
|
|
7392
|
+
const whole = String(v ?? "").trim();
|
|
7393
|
+
if (whole.length <= max) return whole;
|
|
7394
|
+
const cut = whole.slice(0, max);
|
|
7395
|
+
const stop = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
|
|
7396
|
+
if (stop >= max * 0.6) return cut.slice(0, stop + 1);
|
|
7397
|
+
const space = cut.lastIndexOf(" ");
|
|
7398
|
+
if (space < max * 0.6) return cut;
|
|
7399
|
+
return cut.slice(0, space).replace(/[\s,;:]+$/, "");
|
|
7400
|
+
};
|
|
6905
7401
|
var strList = (v, max, each) => Array.isArray(v) ? v.map((x) => str3(x, each)).filter(Boolean).slice(0, max) : [];
|
|
6906
7402
|
var assetRef = (hash) => {
|
|
6907
7403
|
const h = String(hash ?? "");
|
|
@@ -6919,6 +7415,30 @@ function brandSceneById(brandJson, id) {
|
|
|
6919
7415
|
function isCustomPresenter(c) {
|
|
6920
7416
|
return c?.origin === "custom";
|
|
6921
7417
|
}
|
|
7418
|
+
function headOf(brandJson, id) {
|
|
7419
|
+
const rows = brandCharacters(brandJson);
|
|
7420
|
+
const seen = /* @__PURE__ */ new Set([id]);
|
|
7421
|
+
let cur = id;
|
|
7422
|
+
for (; ; ) {
|
|
7423
|
+
const next = rows.find((c) => c?.id === cur)?.supersededBy;
|
|
7424
|
+
if (typeof next !== "string" || !next || seen.has(next) || !rows.some((c) => c?.id === next)) return cur;
|
|
7425
|
+
seen.add(next);
|
|
7426
|
+
cur = next;
|
|
7427
|
+
}
|
|
7428
|
+
}
|
|
7429
|
+
function identityEditsOf(v) {
|
|
7430
|
+
const out = [];
|
|
7431
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7432
|
+
for (const raw of Array.isArray(v) ? v : []) {
|
|
7433
|
+
const s = str3(raw, IDENTITY_EDIT_CHARS).replace(/\s+/g, " ");
|
|
7434
|
+
const key = s.toLowerCase();
|
|
7435
|
+
if (!s || seen.has(key)) continue;
|
|
7436
|
+
seen.add(key);
|
|
7437
|
+
out.push(s);
|
|
7438
|
+
if (out.length >= IDENTITY_EDITS_MAX) break;
|
|
7439
|
+
}
|
|
7440
|
+
return out;
|
|
7441
|
+
}
|
|
6922
7442
|
var SUBJECTS2 = /* @__PURE__ */ new Set(["product", "person", "either"]);
|
|
6923
7443
|
function sceneRecordFrom(input, base) {
|
|
6924
7444
|
const has = (k) => input[k] !== void 0;
|
|
@@ -6969,7 +7489,11 @@ function presenterRecordFrom(input, base) {
|
|
|
6969
7489
|
const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
|
|
6970
7490
|
if (!name) return { ok: false, error: "a presenter needs a name" };
|
|
6971
7491
|
const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
|
|
6972
|
-
const
|
|
7492
|
+
const knownAngle = (file) => base?.shots?.find((s) => s.file === file)?.angle;
|
|
7493
|
+
const shots = has("shotHashes") ? strList(input.shotHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file, i) => {
|
|
7494
|
+
const angle = angles[i] ?? knownAngle(file);
|
|
7495
|
+
return angle ? { file, angle, locked: true } : { file, locked: true };
|
|
7496
|
+
}) : base?.shots ?? [];
|
|
6973
7497
|
if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
|
|
6974
7498
|
const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
|
|
6975
7499
|
const presenter = {
|
|
@@ -6982,13 +7506,13 @@ function presenterRecordFrom(input, base) {
|
|
|
6982
7506
|
if (promptName) presenter.promptName = promptName;
|
|
6983
7507
|
const presentation = has("presentation") ? str3(input.presentation, 10).toLowerCase() : base?.presentation;
|
|
6984
7508
|
if (presentation === "woman" || presentation === "man") presenter.presentation = presentation;
|
|
6985
|
-
const descriptor = has("descriptor") ?
|
|
7509
|
+
const descriptor = has("descriptor") ? phrase(input.descriptor, 120) : base?.descriptor;
|
|
6986
7510
|
if (descriptor) presenter.descriptor = descriptor;
|
|
6987
7511
|
const ageRange = has("ageRange") ? str3(input.ageRange, 40) : base?.ageRange;
|
|
6988
7512
|
if (ageRange) presenter.ageRange = ageRange;
|
|
6989
|
-
const hair = has("hair") ?
|
|
7513
|
+
const hair = has("hair") ? phrase(input.hair, 120) : base?.hair;
|
|
6990
7514
|
if (hair) presenter.hair = hair;
|
|
6991
|
-
const identityNotes = has("identityNotes") ?
|
|
7515
|
+
const identityNotes = has("identityNotes") ? phrase(input.identityNotes, 900) : base?.identityNotes;
|
|
6992
7516
|
if (identityNotes) presenter.identityNotes = identityNotes;
|
|
6993
7517
|
const negatives = has("negativeConstraints") ? strList(input.negativeConstraints, 8, 160) : base?.negativeConstraints;
|
|
6994
7518
|
if (negatives?.length) presenter.negativeConstraints = negatives;
|
|
@@ -6999,8 +7523,36 @@ function presenterRecordFrom(input, base) {
|
|
|
6999
7523
|
if (previewRef) presenter.preview = previewRef;
|
|
7000
7524
|
const avatarRef = has("avatarHash") ? assetRef(input.avatarHash) : base?.avatar ?? null;
|
|
7001
7525
|
if (avatarRef) presenter.avatar = avatarRef;
|
|
7526
|
+
const source = has("source") ? str3(input.source, 16) : base?.source;
|
|
7527
|
+
if (source && PRESENTER_SOURCES.includes(source)) presenter.source = source;
|
|
7528
|
+
const likeness = has("likeness") ? likenessOf(input.likeness) : base?.likeness;
|
|
7529
|
+
if (likeness) presenter.likeness = likeness;
|
|
7530
|
+
const facial = has("facial") ? str3(input.facial, 300) : base?.facial;
|
|
7531
|
+
if (facial) presenter.facial = facial;
|
|
7532
|
+
const skin = has("skin") ? str3(input.skin, 200) : base?.skin;
|
|
7533
|
+
if (skin) presenter.skin = skin;
|
|
7534
|
+
const build = has("build") ? str3(input.build, 200) : base?.build;
|
|
7535
|
+
if (build) presenter.build = build;
|
|
7536
|
+
const edits = has("identityEdits") ? identityEditsOf(input.identityEdits) : base?.identityEdits;
|
|
7537
|
+
if (edits?.length) presenter.identityEdits = edits;
|
|
7538
|
+
const revisionOf = has("revisionOf") ? str3(input.revisionOf, 64) : base?.revisionOf;
|
|
7539
|
+
if (revisionOf) presenter.revisionOf = revisionOf;
|
|
7540
|
+
if (base?.supersededBy) presenter.supersededBy = base.supersededBy;
|
|
7002
7541
|
return { ok: true, presenter };
|
|
7003
7542
|
}
|
|
7543
|
+
function mintRevision(base, input) {
|
|
7544
|
+
const built = presenterRecordFrom(input, base);
|
|
7545
|
+
if (!built.ok) return built;
|
|
7546
|
+
const { supersededBy: _head, ...rest } = built.presenter;
|
|
7547
|
+
return { ok: true, presenter: { ...rest, id: mintId(PRESENTER_ID_PREFIX), revisionOf: base.id } };
|
|
7548
|
+
}
|
|
7549
|
+
function likenessOf(raw) {
|
|
7550
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
7551
|
+
const at = str3(raw.attestedAt, 40);
|
|
7552
|
+
const version = str3(raw.version, 8);
|
|
7553
|
+
if (!at || version !== LIKENESS_VERSION) return void 0;
|
|
7554
|
+
return { attestedAt: at, version: LIKENESS_VERSION };
|
|
7555
|
+
}
|
|
7004
7556
|
function lintSceneProse(brandJson, scene) {
|
|
7005
7557
|
const haystack = `${scene.prompt} ${scene.description} ${scene.figure ?? ""} ${scene.figureTreatment ?? ""}`.toLowerCase();
|
|
7006
7558
|
const named = /* @__PURE__ */ new Set();
|
|
@@ -7016,14 +7568,14 @@ function lintSceneProse(brandJson, scene) {
|
|
|
7016
7568
|
`This scene names ${[...named].join(", ")}. A scene is a place, so whatever you stage in it later has to argue with that.`
|
|
7017
7569
|
] : [];
|
|
7018
7570
|
}
|
|
7019
|
-
function commit(core, brandId,
|
|
7571
|
+
function commit(core, brandId, mutate2, dropDraftId) {
|
|
7020
7572
|
const brand = core.store.getBrand(brandId);
|
|
7021
7573
|
if (!brand) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
|
|
7022
7574
|
const json = { ...brand.json };
|
|
7023
|
-
|
|
7575
|
+
mutate2(json);
|
|
7024
7576
|
const v = validateBrand(json);
|
|
7025
7577
|
if (!v.valid) throw Object.assign(new Error(`brand became invalid: ${v.errors.join("; ")}`), { statusCode: 400 });
|
|
7026
|
-
return core.store.updateBrand(brand.id, json);
|
|
7578
|
+
return dropDraftId ? core.store.updateBrandAndDropPresenterDraft(brand.id, json, dropDraftId) : core.store.updateBrand(brand.id, json);
|
|
7027
7579
|
}
|
|
7028
7580
|
|
|
7029
7581
|
// src/customAssets.ts
|
|
@@ -7055,20 +7607,17 @@ function startAssetBuild(deps, input) {
|
|
|
7055
7607
|
if (input.sceneId && !prior) throw Object.assign(new Error("scene not found"), { statusCode: 404 });
|
|
7056
7608
|
const supplied = input.imageHashes.length ? input.imageHashes : (prior?.refs ?? []).map((r) => String(r?.file ?? "").replace(/^asset:/, ""));
|
|
7057
7609
|
const hashes = supplied.filter((h) => /^[a-f0-9]{32}$/.test(h) && core.images.has(h));
|
|
7058
|
-
if (
|
|
7059
|
-
throw Object.assign(new Error("add at least one photo of this person"), { statusCode: 400 });
|
|
7060
|
-
}
|
|
7061
|
-
if (input.kind === "scene" && !hashes.length && !input.instruction?.trim()) {
|
|
7610
|
+
if (!hashes.length && !input.instruction?.trim()) {
|
|
7062
7611
|
throw Object.assign(new Error("add a reference image, or describe the place in a sentence"), { statusCode: 400 });
|
|
7063
7612
|
}
|
|
7064
7613
|
const job = {
|
|
7065
7614
|
id: `ab-${randomUUID().slice(0, 8)}`,
|
|
7066
7615
|
brandId: brand.id,
|
|
7067
7616
|
kind: input.kind,
|
|
7068
|
-
name: str3(input.name, 60) ||
|
|
7617
|
+
name: str3(input.name, 60) || "New scene",
|
|
7069
7618
|
stage: "queued",
|
|
7070
7619
|
step: 0,
|
|
7071
|
-
steps:
|
|
7620
|
+
steps: 1,
|
|
7072
7621
|
message: null,
|
|
7073
7622
|
assetId: null,
|
|
7074
7623
|
previewHash: null,
|
|
@@ -7096,8 +7645,7 @@ function patch(job, next) {
|
|
|
7096
7645
|
}
|
|
7097
7646
|
async function runBuild(deps, job, hashes, instruction, signal) {
|
|
7098
7647
|
try {
|
|
7099
|
-
|
|
7100
|
-
else await runSceneBuild(deps, job, hashes, instruction, signal);
|
|
7648
|
+
await runSceneBuild(deps, job, hashes, instruction, signal);
|
|
7101
7649
|
} catch (err) {
|
|
7102
7650
|
if (signal.aborted) {
|
|
7103
7651
|
patch(job, { stage: "cancelled", message: null, finished: true });
|
|
@@ -7106,162 +7654,6 @@ async function runBuild(deps, job, hashes, instruction, signal) {
|
|
|
7106
7654
|
patch(job, { stage: "failed", error: err?.message ?? "build failed", message: null, finished: true });
|
|
7107
7655
|
}
|
|
7108
7656
|
}
|
|
7109
|
-
var STUDIO_FRAMES = [
|
|
7110
|
-
/*
|
|
7111
|
-
* The identity frame, and it comes first because that is the order a brief
|
|
7112
|
-
* attaches: `shots[0]` is the essential character reference.
|
|
7113
|
-
*
|
|
7114
|
-
* Every other frame here is full-length head-to-toe, which is right for
|
|
7115
|
-
* build, proportion and wardrobe and useless for a face — in a 1024x1280
|
|
7116
|
-
* full-length frame the face is about 105px brow to chin, while a portrait
|
|
7117
|
-
* output renders it at four times that. Measured 2026-08-30 against the
|
|
7118
|
-
* reported failure: four outputs of one brief, four different jaws, and
|
|
7119
|
-
* drift that tracked nothing but how big the face was in the output.
|
|
7120
|
-
*
|
|
7121
|
-
* Drawn `from: 'sources'` rather than chained off the front view, because
|
|
7122
|
-
* the user's own photographs are the only real face evidence in the system
|
|
7123
|
-
* and a chain would just enlarge the same 105px.
|
|
7124
|
-
*/
|
|
7125
|
-
{
|
|
7126
|
-
angle: "portrait",
|
|
7127
|
-
from: "sources",
|
|
7128
|
-
subject: (who) => `${who}, head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens, their own hair exactly as the references show it, the same plain studio backdrop and even frontal light`
|
|
7129
|
-
},
|
|
7130
|
-
{
|
|
7131
|
-
angle: "front",
|
|
7132
|
-
from: "sources",
|
|
7133
|
-
subject: (who) => `${who}, wearing a fitted off-white ribbed tank top and matching fitted off-white leggings, barefoot, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, facing the camera straight-on`
|
|
7134
|
-
},
|
|
7135
|
-
{
|
|
7136
|
-
angle: "left-profile",
|
|
7137
|
-
from: "front",
|
|
7138
|
-
subject: () => "the same person in the identical standing pose, full-length head-to-toe framing, rotated a full 90 degrees to show their left side in full profile, facing screen-left, same wardrobe"
|
|
7139
|
-
},
|
|
7140
|
-
{
|
|
7141
|
-
angle: "right-profile",
|
|
7142
|
-
from: "left-profile",
|
|
7143
|
-
subject: () => "the attached image shows this exact same person in full left profile, standing: generate the precise mirror-flipped view of that same pose, the same person now in full right profile, facing the exact opposite horizontal direction, same standing pose, same wardrobe, same lighting and background"
|
|
7144
|
-
},
|
|
7145
|
-
{
|
|
7146
|
-
angle: "back",
|
|
7147
|
-
from: "front",
|
|
7148
|
-
subject: () => "the same person in the identical standing pose, full-length head-to-toe framing, rotated to face fully away from the camera, back view, same wardrobe"
|
|
7149
|
-
}
|
|
7150
|
-
];
|
|
7151
|
-
var STUDIO_SET = "against a solid seamless white studio background, eye-level camera with gentle 85mm-equivalent portrait compression and a soft shallow depth of field, one large soft key light with gentle fill producing even, flattering, true-to-life beauty light, while keeping fine natural skin texture at pore scale and true-to-life proportions, the complexion even and uniform in tone across face, neck and shoulders, never airbrushed, plastic, or synthetic-looking, a calm quietly confident expression, true-to-life color grade with minimal retouch";
|
|
7152
|
-
function studioPrompt(subject) {
|
|
7153
|
-
return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind, the seamless studio backdrop runs past all four edges and is the only thing behind the subject at every edge of the frame. ${subject}, ${STUDIO_SET}. No text, no logos, no watermarks anywhere in the frame.`;
|
|
7154
|
-
}
|
|
7155
|
-
function whoIs(name, draft) {
|
|
7156
|
-
if (!draft) return `the exact person in the attached photographs`;
|
|
7157
|
-
const bits = [draft.promptName];
|
|
7158
|
-
if (draft.hair && !draft.promptName.toLowerCase().includes(draft.hair.toLowerCase())) bits.push(draft.hair);
|
|
7159
|
-
if (draft.identityNotes) bits.push(draft.identityNotes);
|
|
7160
|
-
return bits.filter(Boolean).join(", ") || name;
|
|
7161
|
-
}
|
|
7162
|
-
async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
7163
|
-
const { core } = deps;
|
|
7164
|
-
const sourcePaths = hashes.map((h) => core.images.pathFor(h));
|
|
7165
|
-
let draft = null;
|
|
7166
|
-
if (deps.analyzer) {
|
|
7167
|
-
patch(job, { stage: "analyzing", message: "Reading the photos" });
|
|
7168
|
-
draft = await deps.analyzer.analyze(
|
|
7169
|
-
{
|
|
7170
|
-
kind: "presenter",
|
|
7171
|
-
imagePaths: sourcePaths,
|
|
7172
|
-
name: job.name,
|
|
7173
|
-
instruction: instruction || void 0,
|
|
7174
|
-
vocabulary: deps.vocabulary
|
|
7175
|
-
},
|
|
7176
|
-
signal
|
|
7177
|
-
);
|
|
7178
|
-
patch(job, { coverage: draft.coverage ?? [] });
|
|
7179
|
-
}
|
|
7180
|
-
if (signal.aborted) throw new Error("cancelled");
|
|
7181
|
-
let shotHashes = hashes;
|
|
7182
|
-
let shotAngles = [];
|
|
7183
|
-
const warnings = [];
|
|
7184
|
-
if (deps.engine) {
|
|
7185
|
-
patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
|
|
7186
|
-
const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
|
|
7187
|
-
if (built2.hashes.length) {
|
|
7188
|
-
shotHashes = built2.hashes;
|
|
7189
|
-
shotAngles = built2.angles;
|
|
7190
|
-
} else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
|
|
7191
|
-
} else {
|
|
7192
|
-
warnings.push("No engine could draw the studio views, so the photos are being used directly.");
|
|
7193
|
-
}
|
|
7194
|
-
if (signal.aborted) throw new Error("cancelled");
|
|
7195
|
-
patch(job, { stage: "saving", message: null });
|
|
7196
|
-
const generated = shotHashes !== hashes;
|
|
7197
|
-
const frontIndex = shotAngles.indexOf("front");
|
|
7198
|
-
const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
|
|
7199
|
-
const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
|
|
7200
|
-
const built = presenterRecordFrom({
|
|
7201
|
-
name: job.name,
|
|
7202
|
-
shotHashes,
|
|
7203
|
-
shotAngles,
|
|
7204
|
-
sourceHashes: hashes,
|
|
7205
|
-
previewHash,
|
|
7206
|
-
avatarHash,
|
|
7207
|
-
promptName: draft?.promptName,
|
|
7208
|
-
presentation: draft?.presentation,
|
|
7209
|
-
descriptor: draft?.descriptor,
|
|
7210
|
-
ageRange: draft?.ageRange,
|
|
7211
|
-
hair: draft?.hair,
|
|
7212
|
-
identityNotes: draft?.identityNotes,
|
|
7213
|
-
negativeConstraints: draft?.negativeConstraints,
|
|
7214
|
-
// What the caller asked for wins over what the analyzer guessed: the
|
|
7215
|
-
// person choosing where this belongs knows their own library.
|
|
7216
|
-
suitableCategories: job.facets.length ? job.facets : draft?.suitableCategories
|
|
7217
|
-
});
|
|
7218
|
-
if (!built.ok) throw new Error(built.error);
|
|
7219
|
-
commit(core, job.brandId, (json) => {
|
|
7220
|
-
json.characters = [...brandCharacters(json), built.presenter];
|
|
7221
|
-
});
|
|
7222
|
-
patch(job, {
|
|
7223
|
-
stage: "done",
|
|
7224
|
-
step: job.steps,
|
|
7225
|
-
assetId: built.presenter.id,
|
|
7226
|
-
previewHash: previewHash ?? cardSource ?? null,
|
|
7227
|
-
warnings: [...job.warnings, ...warnings],
|
|
7228
|
-
finished: true
|
|
7229
|
-
});
|
|
7230
|
-
}
|
|
7231
|
-
async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
7232
|
-
const engine = deps.engine;
|
|
7233
|
-
if (!engine) return { hashes: [], angles: [] };
|
|
7234
|
-
const caps = engine.capabilities();
|
|
7235
|
-
if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
|
|
7236
|
-
const byAngle = /* @__PURE__ */ new Map();
|
|
7237
|
-
for (const frame of STUDIO_FRAMES) {
|
|
7238
|
-
if (signal.aborted) throw new Error("cancelled");
|
|
7239
|
-
const refs = frame.from === "sources" ? sourcePaths.slice(0, caps.maxReferenceImages) : [byAngle.get(frame.from)].filter((h) => !!h).map((h) => deps.core.images.pathFor(h));
|
|
7240
|
-
if (!refs.length) continue;
|
|
7241
|
-
try {
|
|
7242
|
-
const drawn = await draw(deps, {
|
|
7243
|
-
prompt: studioPrompt(frame.subject(who)),
|
|
7244
|
-
brandId: job.brandId,
|
|
7245
|
-
referenceImages: refs,
|
|
7246
|
-
referenceRoles: refs.map(() => "character"),
|
|
7247
|
-
signal
|
|
7248
|
-
});
|
|
7249
|
-
const hash = await trimEdgeBars(deps.core, drawn);
|
|
7250
|
-
byAngle.set(frame.angle, hash);
|
|
7251
|
-
patch(job, {
|
|
7252
|
-
step: byAngle.size,
|
|
7253
|
-
previewHash: job.previewHash ?? hash,
|
|
7254
|
-
message: `Building the studio views (${byAngle.size} of ${STUDIO_FRAMES.length})`
|
|
7255
|
-
});
|
|
7256
|
-
} catch (err) {
|
|
7257
|
-
if (signal.aborted) throw err;
|
|
7258
|
-
if (frame.angle === "front") throw err;
|
|
7259
|
-
patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
|
|
7260
|
-
}
|
|
7261
|
-
}
|
|
7262
|
-
const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
|
|
7263
|
-
return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
|
|
7264
|
-
}
|
|
7265
7657
|
async function edgeBarGeometry(buf) {
|
|
7266
7658
|
const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
7267
7659
|
const W = info.width;
|
|
@@ -7332,7 +7724,7 @@ async function cardCrop(core, hash) {
|
|
|
7332
7724
|
}
|
|
7333
7725
|
var AVATAR_FIGURE_FRACTION = 0.22;
|
|
7334
7726
|
var AVATAR_HEADROOM = 0.1;
|
|
7335
|
-
var AVATAR_MAX_PX =
|
|
7727
|
+
var AVATAR_MAX_PX = 1024;
|
|
7336
7728
|
var FIGURE_TRIM_THRESHOLDS = [12, 25];
|
|
7337
7729
|
async function avatarCrop(core, hash) {
|
|
7338
7730
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
@@ -7435,10 +7827,25 @@ async function figureBox(buf) {
|
|
|
7435
7827
|
return null;
|
|
7436
7828
|
}
|
|
7437
7829
|
async function presenterCrops(core, hash, mode) {
|
|
7830
|
+
if (mode === "portrait") {
|
|
7831
|
+
const exists = !!hash && core.images.has(hash);
|
|
7832
|
+
return { previewHash: exists ? hash : void 0, avatarHash: await portraitAvatarCrop(core, hash) };
|
|
7833
|
+
}
|
|
7438
7834
|
const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
|
|
7439
7835
|
const avatarHash = mode === "generated" ? await avatarCrop(core, hash) ?? await avatarCropSmart(core, hash) : await avatarCropSmart(core, hash) ?? await avatarCrop(core, hash);
|
|
7440
7836
|
return { previewHash, avatarHash };
|
|
7441
7837
|
}
|
|
7838
|
+
async function portraitAvatarCrop(core, hash) {
|
|
7839
|
+
return crop(
|
|
7840
|
+
core,
|
|
7841
|
+
hash,
|
|
7842
|
+
(w, h) => {
|
|
7843
|
+
const size = Math.min(w, h);
|
|
7844
|
+
return { left: Math.max(0, Math.round((w - size) / 2)), top: 0, width: size, height: size };
|
|
7845
|
+
},
|
|
7846
|
+
AVATAR_MAX_PX
|
|
7847
|
+
);
|
|
7848
|
+
}
|
|
7442
7849
|
async function cardCropSmart(core, hash) {
|
|
7443
7850
|
return smartCover(core, hash, (w, h) => {
|
|
7444
7851
|
if (w / h > 0.8) return { width: Math.round(h * 0.8), height: h };
|
|
@@ -7577,8 +7984,8 @@ async function draw(deps, req) {
|
|
|
7577
7984
|
brand: deps.brandContext(req.brandId),
|
|
7578
7985
|
referenceImages: req.referenceImages,
|
|
7579
7986
|
referenceRoles: req.referenceRoles,
|
|
7580
|
-
width: ASSET_WIDTH,
|
|
7581
|
-
height: ASSET_HEIGHT,
|
|
7987
|
+
width: req.width ?? ASSET_WIDTH,
|
|
7988
|
+
height: req.height ?? ASSET_HEIGHT,
|
|
7582
7989
|
count: 1
|
|
7583
7990
|
};
|
|
7584
7991
|
const estimate = await engine.costEstimate(generateReq).catch(() => 0);
|
|
@@ -8733,6 +9140,8 @@ function registerLogoRoutes(app, deps) {
|
|
|
8733
9140
|
var scans = /* @__PURE__ */ new Map();
|
|
8734
9141
|
var controllers = /* @__PURE__ */ new Map();
|
|
8735
9142
|
var KEEP = 32;
|
|
9143
|
+
var HARD_STOP_MS = 42e3;
|
|
9144
|
+
var KEEP_MS = 10 * 6e4;
|
|
8736
9145
|
function remember(state) {
|
|
8737
9146
|
scans.set(state.id, state);
|
|
8738
9147
|
while (scans.size > KEEP) {
|
|
@@ -8743,7 +9152,20 @@ function remember(state) {
|
|
|
8743
9152
|
}
|
|
8744
9153
|
}
|
|
8745
9154
|
function getScan(scanId) {
|
|
8746
|
-
|
|
9155
|
+
const state = scans.get(scanId);
|
|
9156
|
+
if (!state) return void 0;
|
|
9157
|
+
if (state.status !== "running" && Date.now() - state.startedAt > KEEP_MS) {
|
|
9158
|
+
scans.delete(scanId);
|
|
9159
|
+
controllers.delete(scanId);
|
|
9160
|
+
return void 0;
|
|
9161
|
+
}
|
|
9162
|
+
return state;
|
|
9163
|
+
}
|
|
9164
|
+
function cancelScan(scanId) {
|
|
9165
|
+
const ctrl = controllers.get(scanId);
|
|
9166
|
+
if (!ctrl) return false;
|
|
9167
|
+
ctrl.abort();
|
|
9168
|
+
return true;
|
|
8747
9169
|
}
|
|
8748
9170
|
function startCatalogScan(deps, brandId, url) {
|
|
8749
9171
|
const { core, fetchImpl } = deps;
|
|
@@ -8753,13 +9175,22 @@ function startCatalogScan(deps, brandId, url) {
|
|
|
8753
9175
|
const state = { id, brandId, url, status: "running", startedAt: Date.now() };
|
|
8754
9176
|
remember(state);
|
|
8755
9177
|
controllers.set(id, ctrl);
|
|
9178
|
+
let hardStopped = false;
|
|
9179
|
+
const stopper = setTimeout(() => {
|
|
9180
|
+
hardStopped = true;
|
|
9181
|
+
ctrl.abort();
|
|
9182
|
+
}, HARD_STOP_MS);
|
|
9183
|
+
stopper.unref?.();
|
|
8756
9184
|
void scanForCandidates({ url, fetchImpl, signal: ctrl.signal }).then((result) => {
|
|
8757
9185
|
state.result = result;
|
|
8758
9186
|
state.status = "done";
|
|
8759
9187
|
}).catch((err) => {
|
|
8760
9188
|
state.status = "error";
|
|
8761
|
-
state.error = err instanceof Error ? err.message : String(err);
|
|
8762
|
-
}).finally(() =>
|
|
9189
|
+
state.error = hardStopped ? "This site took too long to look through." : err instanceof Error ? err.message : String(err);
|
|
9190
|
+
}).finally(() => {
|
|
9191
|
+
clearTimeout(stopper);
|
|
9192
|
+
controllers.delete(id);
|
|
9193
|
+
});
|
|
8763
9194
|
return { scanId: id };
|
|
8764
9195
|
}
|
|
8765
9196
|
|
|
@@ -8803,6 +9234,13 @@ function registerCatalogImportRoutes(app, deps) {
|
|
|
8803
9234
|
if (!scan || scan.brandId !== brandId) return reply.status(404).send({ error: "scan not found" });
|
|
8804
9235
|
return scan;
|
|
8805
9236
|
});
|
|
9237
|
+
app.post("/api/brands/:id/catalog/scans/:scanId/cancel", async (req, reply) => {
|
|
9238
|
+
const brandId = req.params.id;
|
|
9239
|
+
const scanId = req.params.scanId;
|
|
9240
|
+
const scan = getScan(scanId);
|
|
9241
|
+
if (!scan || scan.brandId !== brandId) return reply.status(404).send({ error: "scan not found" });
|
|
9242
|
+
return { ok: cancelScan(scanId) };
|
|
9243
|
+
});
|
|
8806
9244
|
app.post("/api/brands/:id/catalog/details", async (req, reply) => {
|
|
8807
9245
|
const brand = core.store.getBrand(req.params.id);
|
|
8808
9246
|
if (!brand) return reply.status(404).send({ error: "brand not found" });
|
|
@@ -9052,8 +9490,11 @@ function registerPresenterRoutes(app, deps) {
|
|
|
9052
9490
|
}
|
|
9053
9491
|
|
|
9054
9492
|
// src/presenterRepair.ts
|
|
9055
|
-
function presenterCropMode(firstShotFile,
|
|
9056
|
-
|
|
9493
|
+
function presenterCropMode(firstShotFile, sourceFiles, firstShotAngle) {
|
|
9494
|
+
const sources = Array.isArray(sourceFiles) ? sourceFiles : [sourceFiles];
|
|
9495
|
+
if (firstShotFile && sources.includes(firstShotFile)) return "upload";
|
|
9496
|
+
if (firstShotAngle === "portrait") return "portrait";
|
|
9497
|
+
return "generated";
|
|
9057
9498
|
}
|
|
9058
9499
|
async function repairPresenterCrops(core, log = () => {
|
|
9059
9500
|
}) {
|
|
@@ -9065,7 +9506,11 @@ async function repairPresenterCrops(core, log = () => {
|
|
|
9065
9506
|
const firstShot = c.shots?.[0]?.file;
|
|
9066
9507
|
const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
|
|
9067
9508
|
if (!hash) continue;
|
|
9068
|
-
const mode = presenterCropMode(
|
|
9509
|
+
const mode = presenterCropMode(
|
|
9510
|
+
firstShot,
|
|
9511
|
+
(c.sourceRefs ?? []).map((s) => s?.file),
|
|
9512
|
+
c.shots?.[0]?.angle
|
|
9513
|
+
);
|
|
9069
9514
|
const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
|
|
9070
9515
|
const preview = previewHash ? `asset:${previewHash}` : void 0;
|
|
9071
9516
|
const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
|
|
@@ -9084,22 +9529,1164 @@ async function repairPresenterCrops(core, log = () => {
|
|
|
9084
9529
|
return { repaired };
|
|
9085
9530
|
}
|
|
9086
9531
|
|
|
9532
|
+
// src/presenterPrompts.ts
|
|
9533
|
+
var CAPTURE_UNIFORM = "a fitted off-white ribbed tank top and matching fitted off-white leggings, barefoot";
|
|
9534
|
+
var STUDIO_SET = "against a solid seamless white studio background, eye-level camera with gentle 85mm-equivalent portrait compression and a soft shallow depth of field, one large soft key light with gentle fill producing even, flattering, true-to-life beauty light, while keeping fine natural skin texture at pore scale and true-to-life proportions, the complexion even and uniform in tone across face, neck and shoulders, never airbrushed, plastic, or synthetic-looking, a calm quietly confident expression, true-to-life color grade with minimal retouch";
|
|
9535
|
+
function studioPrompt(subject) {
|
|
9536
|
+
return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind, the seamless studio backdrop runs past all four edges and is the only thing behind the subject at every edge of the frame. ${subject}, ${STUDIO_SET}. No text, no logos, no watermarks anywhere in the frame.`;
|
|
9537
|
+
}
|
|
9538
|
+
var VIEW_ROLES = [
|
|
9539
|
+
{ id: "portrait", tier: "core", gate: true, from: [], label: "face" },
|
|
9540
|
+
{ id: "front", tier: "core", gate: true, from: ["portrait"], label: "front view" },
|
|
9541
|
+
{ id: "three-quarter", tier: "core", from: ["portrait", "front"], label: "three-quarter view" },
|
|
9542
|
+
{ id: "back", tier: "supplementary", from: ["portrait", "front"], label: "back view" },
|
|
9543
|
+
{ id: "left", tier: "supplementary", from: ["portrait", "front"], label: "left view" },
|
|
9544
|
+
// Never from the left: drawing one profile off the other is the surest way
|
|
9545
|
+
// to put a trait on the wrong side of a face. See refDeps.
|
|
9546
|
+
{ id: "right", tier: "supplementary", from: ["portrait", "front", "left"], label: "right view" }
|
|
9547
|
+
];
|
|
9548
|
+
var idsWhere = (want) => VIEW_ROLES.filter(want).map((r) => r.id);
|
|
9549
|
+
var PRESENTER_VIEWS = idsWhere(() => true);
|
|
9550
|
+
var CORE_VIEWS = idsWhere((r) => r.tier === "core");
|
|
9551
|
+
var EXTRA_VIEWS = idsWhere((r) => r.tier === "supplementary");
|
|
9552
|
+
var DEPENDS = Object.fromEntries(VIEW_ROLES.map((r) => [r.id, r.from]));
|
|
9553
|
+
var VIEW_LABEL = Object.fromEntries(VIEW_ROLES.map((r) => [r.id, r.label]));
|
|
9554
|
+
var HAND_APPROVED = new Set(idsWhere((r) => r.gate === true));
|
|
9555
|
+
function viewSubject(view, who) {
|
|
9556
|
+
switch (view) {
|
|
9557
|
+
case "portrait":
|
|
9558
|
+
return `${who}, head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens, wearing ${CAPTURE_UNIFORM}, their own hair exactly as the references show it, the same plain studio backdrop and even frontal light`;
|
|
9559
|
+
case "front":
|
|
9560
|
+
return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, facing the camera straight-on, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
|
|
9561
|
+
case "three-quarter":
|
|
9562
|
+
return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned about forty-five degrees from the camera so that both eyes stay in frame (a three-quarter view), the head turned with the body, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
|
|
9563
|
+
case "left":
|
|
9564
|
+
return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned so that their left side faces the camera in a full profile, the head in profile too, this being a turn of the same body and never a mirror image of it so anything on one side of them stays on that side, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
|
|
9565
|
+
case "back":
|
|
9566
|
+
return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned to face directly away from the camera so the back of the head, the shoulders and the legs are to the lens, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
|
|
9567
|
+
case "right":
|
|
9568
|
+
return `${who}: the same person as the attached images, wearing ${CAPTURE_UNIFORM}, standing naturally in a relaxed straight standing pose, full-length head-to-toe framing, turned so that their right side faces the camera in a full profile, the head in profile too, this being a turn of the same body and never a mirror image of it so anything on one side of them stays on that side, their own hair exactly as the attached images show it, the same plain studio backdrop and even light`;
|
|
9569
|
+
}
|
|
9570
|
+
}
|
|
9571
|
+
function syntheticIdentitySubject(direction, opts = {}) {
|
|
9572
|
+
const person = `an adult, ${direction.trim()}: an original person who does not resemble any real, famous or public figure, mature adult facial structure`;
|
|
9573
|
+
const framing = "head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens";
|
|
9574
|
+
const skin = "visible natural skin texture with pores and fine lines, no beauty filter, no digital smoothing, the plain studio backdrop and even frontal light";
|
|
9575
|
+
const body = `${person}, ${framing}, wearing ${CAPTURE_UNIFORM}, ${skin}`;
|
|
9576
|
+
const adjustment = opts.adjustment?.trim();
|
|
9577
|
+
if (!adjustment) return body;
|
|
9578
|
+
return `the same person as the attached image, changed only in this: ${adjustment}. That change is the point of this picture and overrides anything below that describes it otherwise. ${keptAspects(adjustment)}; ${body}`;
|
|
9579
|
+
}
|
|
9580
|
+
var ASPECTS = [
|
|
9581
|
+
{
|
|
9582
|
+
name: "face",
|
|
9583
|
+
except: "the rest of the face",
|
|
9584
|
+
words: /\b(face|facial|jaw|chin|cheeks?|cheekbones?|nose|mouth|lips?|teeth|smile|eyes?|eyelids?|eyebrows?|brows?|lashes|freckles?|beard|moustache|mustache|stubble|glasses|expression|skin|complexion|wrinkles?|scars?|birthmarks?|moles?|piercings?|studs?|makeup|make-up|eyeliner|lipstick)\b/i
|
|
9585
|
+
},
|
|
9586
|
+
{
|
|
9587
|
+
name: "hair",
|
|
9588
|
+
words: /\b(hair|haircut|hairline|bob|fringe|bangs|ponytail|bun|braids?|curls?|curly|straighter|bald|shaved|beard)\b/i
|
|
9589
|
+
},
|
|
9590
|
+
{ name: "age", words: /\b(age|aged|older|younger|youthful|years old|teenage|[2-7]0s)\b/i },
|
|
9591
|
+
{
|
|
9592
|
+
name: "build",
|
|
9593
|
+
words: /\b(build|body|frame|slim|slimmer|slender|thin|athletic|muscular|heavier|leaner|fuller|broader|broad|shoulders|weight|taller|shorter)\b/i
|
|
9594
|
+
},
|
|
9595
|
+
{
|
|
9596
|
+
// What a body carries rather than what shape it is. Without this row an ask
|
|
9597
|
+
// for a tattoo or a prosthetic arm shipped "otherwise identical in face,
|
|
9598
|
+
// hair, age and build" beside it, which is a contradiction: the picture
|
|
9599
|
+
// being asked for is not identical, that is the point of asking.
|
|
9600
|
+
name: "marks and limbs",
|
|
9601
|
+
except: "their other marks and limbs",
|
|
9602
|
+
words: /\b(tattoos?|tattooed|scars?|birthmarks?|moles?|piercings?|prosthetics?|prosthesis|bionic|limbs?|arms?|hands?|legs?)\b/i
|
|
9603
|
+
}
|
|
9604
|
+
];
|
|
9605
|
+
function keptAspects(adjustment) {
|
|
9606
|
+
const kept = ASPECTS.map((a) => a.words.test(adjustment) ? a.except : a.name).filter((x) => !!x);
|
|
9607
|
+
if (!kept.length) return "Otherwise the same person as the attached image";
|
|
9608
|
+
const list2 = kept.length === 1 ? kept[0] : `${kept.slice(0, -1).join(", ")} and ${kept.at(-1)}`;
|
|
9609
|
+
return `Otherwise identical to the attached image in ${list2}`;
|
|
9610
|
+
}
|
|
9611
|
+
var keepSentenceOf = (items) => items.map((i) => i.words).join(", ");
|
|
9612
|
+
function itemsFromKeep(keep) {
|
|
9613
|
+
return (keep ?? "").split(",").map((w) => w.trim()).filter(Boolean).map((words, i) => ({ id: `said-${i}`, words }));
|
|
9614
|
+
}
|
|
9615
|
+
function sideNote(keep) {
|
|
9616
|
+
return /\b(left|right)\b/i.test(keep) ? " (their own left and right, not the viewer's)" : "";
|
|
9617
|
+
}
|
|
9618
|
+
var KEEP_FACE = /\b(face|facial|jaw|chin|cheeks?|cheekbones?|nose|nostrils?|septum|mouth|lips?|teeth|smile|eyes?|eyelids?|eyebrows?|brows?|lashes|freckles?|beard|moustache|mustache|stubble|glasses|spectacles|ears?|lobes?|temples?|forehead|skin|complexion|wrinkles?|makeup|make-up|eyeliner|lipstick)\b/i;
|
|
9619
|
+
var KEEP_BODY = /\b(arms?|forearms?|wrists?|hands?|knuckles?|fingers?|shoulders?|back|chest|collarbones?|torso|stomach|waist|hips?|legs?|thighs?|calf|calves|ankles?|feet|foot|toes?|knees?|prosthetics?|prosthesis|bionic|limbs?|sleeve)\b/i;
|
|
9620
|
+
var ROW_LIVES = {
|
|
9621
|
+
glasses: "face",
|
|
9622
|
+
freckles: "face",
|
|
9623
|
+
makeup: "face",
|
|
9624
|
+
scar: "face",
|
|
9625
|
+
piercing: "face"
|
|
9626
|
+
};
|
|
9627
|
+
function shows(view, item) {
|
|
9628
|
+
const lives = ROW_LIVES[item.id];
|
|
9629
|
+
const face = lives ? lives === "face" : KEEP_FACE.test(item.words);
|
|
9630
|
+
const body = lives ? lives === "body" : KEEP_BODY.test(item.words);
|
|
9631
|
+
if (face && !body) return view !== "back";
|
|
9632
|
+
if (body && !face) return view !== "portrait";
|
|
9633
|
+
return true;
|
|
9634
|
+
}
|
|
9635
|
+
function itemsFor(view, items) {
|
|
9636
|
+
return items.filter((i) => shows(view, i));
|
|
9637
|
+
}
|
|
9638
|
+
function keepFor(view, items) {
|
|
9639
|
+
return keepSentenceOf(itemsFor(view, items));
|
|
9640
|
+
}
|
|
9641
|
+
var ATTACHED_PERSON = "the exact person in the attached photographs";
|
|
9642
|
+
function identityOf2(rec) {
|
|
9643
|
+
return {
|
|
9644
|
+
said: (rec.direction ?? "").trim(),
|
|
9645
|
+
read: rec.analysis ?? null,
|
|
9646
|
+
items: rec.keepItems ?? itemsFromKeep(rec.keep),
|
|
9647
|
+
edits: (rec.identityEdits ?? []).filter(Boolean)
|
|
9648
|
+
};
|
|
9649
|
+
}
|
|
9650
|
+
function whoIs(id, view) {
|
|
9651
|
+
if (!id) return ATTACHED_PERSON;
|
|
9652
|
+
const bits = [];
|
|
9653
|
+
const read = id.read;
|
|
9654
|
+
const named = read?.promptName ?? "";
|
|
9655
|
+
if (named && id.said.toLowerCase().includes(named.toLowerCase())) bits.push(id.said);
|
|
9656
|
+
else if (named) bits.push(named);
|
|
9657
|
+
else if (id.said) bits.push(id.said);
|
|
9658
|
+
const already = (v) => bits.some((b) => b.toLowerCase().includes(v.toLowerCase()));
|
|
9659
|
+
if (read?.hair && !already(read.hair)) bits.push(read.hair);
|
|
9660
|
+
if (read?.identityNotes) bits.push(read.identityNotes);
|
|
9661
|
+
if (id.said && !already(id.said)) bits.push(id.said);
|
|
9662
|
+
const said = bits.filter(Boolean).join(", ") || ATTACHED_PERSON;
|
|
9663
|
+
const keep = keepFor(view, id.items);
|
|
9664
|
+
const who = keep ? `${said}, who also has ${keep}${sideNote(keep)}, which is part of who they are and is drawn in this view whether or not the attached images show it` : said;
|
|
9665
|
+
if (!id.edits.length) return who;
|
|
9666
|
+
return `${who}, except as changed here: ${id.edits.join("; ")}; the attached drawn views show the change`;
|
|
9667
|
+
}
|
|
9668
|
+
var RESULTS_MAX = 60;
|
|
9669
|
+
var isView = (v) => PRESENTER_VIEWS.includes(String(v));
|
|
9670
|
+
var isExtra = (v) => EXTRA_VIEWS.includes(v);
|
|
9671
|
+
var HASH = /^[a-f0-9]{32}$/;
|
|
9672
|
+
var fail = (message, statusCode) => Object.assign(new Error(message), { statusCode });
|
|
9673
|
+
var str4 = (v, max) => String(v ?? "").trim().slice(0, max);
|
|
9674
|
+
var emptySlot = () => ({ status: "empty", attempts: 0, rejected: [] });
|
|
9675
|
+
function fromRow(row) {
|
|
9676
|
+
const j = row.json ?? {};
|
|
9677
|
+
const views = {};
|
|
9678
|
+
for (const v of PRESENTER_VIEWS) views[v] = { ...emptySlot(), ...j.views?.[v] ?? {} };
|
|
9679
|
+
const rec = {
|
|
9680
|
+
id: row.id,
|
|
9681
|
+
brandId: row.brandId,
|
|
9682
|
+
source: j.source === "synthetic" ? "synthetic" : "photos",
|
|
9683
|
+
name: String(j.name ?? ""),
|
|
9684
|
+
facets: Array.isArray(j.facets) ? j.facets.map(String) : [],
|
|
9685
|
+
sources: Array.isArray(j.sources) ? j.sources.map(String) : [],
|
|
9686
|
+
views,
|
|
9687
|
+
extras: j.extras === true,
|
|
9688
|
+
generations: Number(j.generations ?? 0),
|
|
9689
|
+
activeView: isView(j.activeView) ? j.activeView : null,
|
|
9690
|
+
stage: j.stage === "analyzing" || j.stage === "drawing" ? j.stage : "idle",
|
|
9691
|
+
identityEdits: identityEditsOf(j.identityEdits),
|
|
9692
|
+
asks: asksOf(j.asks),
|
|
9693
|
+
results: resultsOf(j.results),
|
|
9694
|
+
decisions: decisionsOf(j.decisions),
|
|
9695
|
+
createdAt: row.createdAt,
|
|
9696
|
+
updatedAt: row.updatedAt
|
|
9697
|
+
};
|
|
9698
|
+
if (j.direction) rec.direction = String(j.direction);
|
|
9699
|
+
if (j.keep) rec.keep = String(j.keep);
|
|
9700
|
+
if (j.setup) rec.setup = String(j.setup);
|
|
9701
|
+
const keepItems = keepItemsOf(j.keepItems);
|
|
9702
|
+
if (keepItems) rec.keepItems = keepItems;
|
|
9703
|
+
const detailRefs = detailRefsOf(j.detailRefs);
|
|
9704
|
+
if (detailRefs) rec.detailRefs = detailRefs;
|
|
9705
|
+
if (j.attestation) rec.attestation = j.attestation;
|
|
9706
|
+
if (j.analysis) rec.analysis = j.analysis;
|
|
9707
|
+
if (j.readError) rec.readError = String(j.readError);
|
|
9708
|
+
if (j.presenterId) rec.presenterId = String(j.presenterId);
|
|
9709
|
+
if (j.baseId) rec.baseId = String(j.baseId);
|
|
9710
|
+
if (Array.isArray(j.keptShots) && j.keptShots.length) rec.keptShots = j.keptShots.map(shotOf);
|
|
9711
|
+
backfillRecord(rec);
|
|
9712
|
+
return rec;
|
|
9713
|
+
}
|
|
9714
|
+
function backfillRecord(rec) {
|
|
9715
|
+
if (rec.results.length) return;
|
|
9716
|
+
const at = rec.updatedAt || rec.createdAt;
|
|
9717
|
+
for (const v of PRESENTER_VIEWS) {
|
|
9718
|
+
const s = rec.views[v];
|
|
9719
|
+
if (!s.hash || s.origin !== "generated") continue;
|
|
9720
|
+
if (s.adjustment && !rec.asks.some((a) => a.view === v && a.text === s.adjustment)) {
|
|
9721
|
+
rec.asks = [...rec.asks, { view: v, text: s.adjustment, at }];
|
|
9722
|
+
}
|
|
9723
|
+
rec.results = [
|
|
9724
|
+
...rec.results,
|
|
9725
|
+
{ view: v, hash: s.hash, at, ...s.adjustment ? { ask: s.adjustment } : {}, how: "drawn" }
|
|
9726
|
+
];
|
|
9727
|
+
}
|
|
9728
|
+
}
|
|
9729
|
+
var asksOf = (v) => (Array.isArray(v) ? v : []).filter((a) => isView(a?.view) && typeof a?.text === "string" && a.text.trim()).map((a) => ({ view: a.view, text: String(a.text), at: String(a.at ?? "") })).slice(-40);
|
|
9730
|
+
var resultsOf = (v) => (Array.isArray(v) ? v : []).filter((r) => isView(r?.view) && HASH.test(String(r?.hash ?? ""))).map((r) => ({
|
|
9731
|
+
view: r.view,
|
|
9732
|
+
hash: String(r.hash),
|
|
9733
|
+
at: String(r.at ?? ""),
|
|
9734
|
+
...typeof r.ask === "string" && r.ask ? { ask: String(r.ask) } : {},
|
|
9735
|
+
how: r.how === "restored" ? "restored" : "drawn"
|
|
9736
|
+
})).slice(-RESULTS_MAX);
|
|
9737
|
+
var decisionsOf = (v) => (Array.isArray(v) ? v : []).filter((d) => isView(d?.view) && ["use", "again", "keep"].includes(d?.what)).map((d) => ({ view: d.view, what: d.what, at: String(d.at ?? "") })).slice(-RESULTS_MAX);
|
|
9738
|
+
var shotOf = (s) => ({
|
|
9739
|
+
file: String(s?.file ?? ""),
|
|
9740
|
+
...s?.angle ? { angle: String(s.angle) } : {},
|
|
9741
|
+
...s?.locked ? { locked: true } : {}
|
|
9742
|
+
});
|
|
9743
|
+
function put(core, rec) {
|
|
9744
|
+
const { id, brandId, createdAt: _c, updatedAt: _u, ...json } = rec;
|
|
9745
|
+
return fromRow(core.store.putPresenterDraft({ id, brandId, json }));
|
|
9746
|
+
}
|
|
9747
|
+
function mutate(core, id, fn) {
|
|
9748
|
+
const rec = getPresenterDraft(core, id);
|
|
9749
|
+
if (!rec) throw fail("draft not found", 404);
|
|
9750
|
+
fn(rec);
|
|
9751
|
+
return put(core, rec);
|
|
9752
|
+
}
|
|
9753
|
+
function getPresenterDraft(core, id) {
|
|
9754
|
+
const row = core.store.getPresenterDraft(id);
|
|
9755
|
+
return row ? fromRow(row) : null;
|
|
9756
|
+
}
|
|
9757
|
+
function bestPicture(rec) {
|
|
9758
|
+
const drawn = PRESENTER_VIEWS.map((v) => rec.views[v]).find((s) => s.hash);
|
|
9759
|
+
return rec.views.portrait.hash ?? drawn?.hash ?? rec.sources[0];
|
|
9760
|
+
}
|
|
9761
|
+
function summarisePresenterDraft(rec) {
|
|
9762
|
+
const wanted = rec.extras ? PRESENTER_VIEWS : CORE_VIEWS;
|
|
9763
|
+
const summary = {
|
|
9764
|
+
id: rec.id,
|
|
9765
|
+
name: rec.name,
|
|
9766
|
+
source: rec.source,
|
|
9767
|
+
updatedAt: rec.updatedAt,
|
|
9768
|
+
createdAt: rec.createdAt,
|
|
9769
|
+
stage: rec.stage,
|
|
9770
|
+
approved: wanted.filter((v) => rec.views[v].status === "approved").length,
|
|
9771
|
+
of: wanted.length,
|
|
9772
|
+
drawing: rec.stage !== "idle" || !!rec.activeView,
|
|
9773
|
+
// Drawn work a discard would throw away, decided or not. A photograph is
|
|
9774
|
+
// still on disk as itself, so it does not count; a candidate face does,
|
|
9775
|
+
// which counting approved views alone would have missed.
|
|
9776
|
+
drawn: PRESENTER_VIEWS.some((v) => {
|
|
9777
|
+
const s = rec.views[v];
|
|
9778
|
+
return !!s.hash && s.origin !== "photo" && s.status !== "empty";
|
|
9779
|
+
})
|
|
9780
|
+
};
|
|
9781
|
+
const hash = bestPicture(rec);
|
|
9782
|
+
if (hash) summary.hash = hash;
|
|
9783
|
+
if (rec.presenterId) summary.presenterId = rec.presenterId;
|
|
9784
|
+
return summary;
|
|
9785
|
+
}
|
|
9786
|
+
function listPresenterDraftSummaries(core, brandId) {
|
|
9787
|
+
return listPresenterDrafts(core, brandId).map(summarisePresenterDraft).sort((a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : 1);
|
|
9788
|
+
}
|
|
9789
|
+
function listPresenterDrafts(core, brandId) {
|
|
9790
|
+
return core.store.listPresenterDrafts(brandId).map(fromRow);
|
|
9791
|
+
}
|
|
9792
|
+
var running3 = /* @__PURE__ */ new Map();
|
|
9793
|
+
function runningDraftJobCount() {
|
|
9794
|
+
return running3.size;
|
|
9795
|
+
}
|
|
9796
|
+
function sweepPresenterDrafts(core) {
|
|
9797
|
+
let swept = 0;
|
|
9798
|
+
for (const brand of core.store.listBrands()) {
|
|
9799
|
+
for (const rec of listPresenterDrafts(core, brand.id)) {
|
|
9800
|
+
const stuck = PRESENTER_VIEWS.filter((v) => rec.views[v].status === "generating");
|
|
9801
|
+
if (!stuck.length && !rec.activeView && rec.stage === "idle") continue;
|
|
9802
|
+
for (const v of stuck) {
|
|
9803
|
+
const slot = rec.views[v];
|
|
9804
|
+
slot.status = slot.hash ? "candidate" : "empty";
|
|
9805
|
+
slot.error = "interrupted: server restarted mid-generation";
|
|
9806
|
+
slot.startedAt = void 0;
|
|
9807
|
+
}
|
|
9808
|
+
rec.activeView = null;
|
|
9809
|
+
rec.stage = "idle";
|
|
9810
|
+
put(core, rec);
|
|
9811
|
+
swept += 1;
|
|
9812
|
+
}
|
|
9813
|
+
}
|
|
9814
|
+
return swept;
|
|
9815
|
+
}
|
|
9816
|
+
var KEEP_ITEM_CHARS = 200;
|
|
9817
|
+
var SETUP_CHARS = 4e4;
|
|
9818
|
+
var KEEP_ITEMS_MAX = 12;
|
|
9819
|
+
function keepItemsOf(raw, has) {
|
|
9820
|
+
if (!Array.isArray(raw)) return void 0;
|
|
9821
|
+
const out = [];
|
|
9822
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9823
|
+
for (const r of raw.slice(0, KEEP_ITEMS_MAX)) {
|
|
9824
|
+
if (!r || typeof r !== "object") continue;
|
|
9825
|
+
const words = str4(r.words, KEEP_ITEM_CHARS);
|
|
9826
|
+
if (!words) continue;
|
|
9827
|
+
let id = str4(r.id, 40) || "said";
|
|
9828
|
+
while (seen.has(id)) id = `${id}+`;
|
|
9829
|
+
seen.add(id);
|
|
9830
|
+
const refs = (Array.isArray(r.refs) ? r.refs : []).map(String).filter((h) => HASH.test(h) && (!has || has(h))).slice(0, 4);
|
|
9831
|
+
out.push(refs.length ? { id, words, refs } : { id, words });
|
|
9832
|
+
}
|
|
9833
|
+
return out;
|
|
9834
|
+
}
|
|
9835
|
+
function writeItems(r, items) {
|
|
9836
|
+
r.keepItems = items.length ? items : void 0;
|
|
9837
|
+
r.keep = keepSentenceOf(items) || void 0;
|
|
9838
|
+
const refs = {};
|
|
9839
|
+
for (const i of items) if (i.refs?.length) refs[i.id] = i.refs;
|
|
9840
|
+
r.detailRefs = Object.keys(refs).length ? refs : void 0;
|
|
9841
|
+
}
|
|
9842
|
+
function detailRefsOf(raw, has) {
|
|
9843
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
9844
|
+
const out = {};
|
|
9845
|
+
for (const [k, v] of Object.entries(raw).slice(0, 8)) {
|
|
9846
|
+
const key = str4(k, 40);
|
|
9847
|
+
if (!key || !Array.isArray(v)) continue;
|
|
9848
|
+
const hashes = v.map(String).filter((h) => HASH.test(h) && (!has || has(h))).slice(0, 4);
|
|
9849
|
+
if (hashes.length) out[key] = hashes;
|
|
9850
|
+
}
|
|
9851
|
+
return Object.keys(out).length ? out : void 0;
|
|
9852
|
+
}
|
|
9853
|
+
async function createPresenterDraft(deps, input) {
|
|
9854
|
+
const { core } = deps;
|
|
9855
|
+
const brand = core.store.getBrand(input.brandId);
|
|
9856
|
+
if (!brand) throw fail("brand not found", 404);
|
|
9857
|
+
const source = input.source === "synthetic" ? "synthetic" : "photos";
|
|
9858
|
+
const direction = str4(input.direction, 400);
|
|
9859
|
+
const keep = str4(input.keep, 240);
|
|
9860
|
+
const keepItems = keepItemsOf(input.keepItems, (h) => core.images.has(h));
|
|
9861
|
+
const sources = (input.imageHashes ?? []).map(String).filter((h) => HASH.test(h) && core.images.has(h));
|
|
9862
|
+
if (source === "synthetic" && !direction) throw fail("describe who they are in a sentence", 400);
|
|
9863
|
+
if (source === "synthetic" && !deps.engine) throw fail("no engine here can draw a person", 400);
|
|
9864
|
+
if (source === "photos" && !sources.length) throw fail("add at least one photo of this person", 400);
|
|
9865
|
+
if (source === "photos" && !input.attestation) {
|
|
9866
|
+
throw fail("confirm you have permission to use this person's likeness", 400);
|
|
9867
|
+
}
|
|
9868
|
+
const views = {};
|
|
9869
|
+
for (const v of PRESENTER_VIEWS) views[v] = emptySlot();
|
|
9870
|
+
const rec = {
|
|
9871
|
+
id: `pd-${randomUUID().slice(0, 8)}`,
|
|
9872
|
+
brandId: brand.id,
|
|
9873
|
+
source,
|
|
9874
|
+
name: str4(input.name, 60),
|
|
9875
|
+
facets: (input.facets ?? []).map((f) => str4(f, 40)).filter(Boolean).slice(0, 8),
|
|
9876
|
+
sources: source === "photos" ? sources : [],
|
|
9877
|
+
views,
|
|
9878
|
+
extras: input.extras === true,
|
|
9879
|
+
generations: 0,
|
|
9880
|
+
activeView: null,
|
|
9881
|
+
stage: "idle",
|
|
9882
|
+
identityEdits: [],
|
|
9883
|
+
asks: [],
|
|
9884
|
+
results: [],
|
|
9885
|
+
decisions: [],
|
|
9886
|
+
createdAt: "",
|
|
9887
|
+
updatedAt: ""
|
|
9888
|
+
};
|
|
9889
|
+
if (keepItems) writeItems(rec, keepItems);
|
|
9890
|
+
else {
|
|
9891
|
+
if (keep) rec.keep = keep;
|
|
9892
|
+
const detailRefs = detailRefsOf(input.detailRefs, (h) => core.images.has(h));
|
|
9893
|
+
if (detailRefs) rec.detailRefs = detailRefs;
|
|
9894
|
+
}
|
|
9895
|
+
if (source === "synthetic") rec.direction = direction;
|
|
9896
|
+
else {
|
|
9897
|
+
rec.attestation = { attestedAt: (/* @__PURE__ */ new Date()).toISOString(), version: LIKENESS_VERSION };
|
|
9898
|
+
if (direction) rec.direction = direction;
|
|
9899
|
+
}
|
|
9900
|
+
const saved = put(core, rec);
|
|
9901
|
+
if (source === "photos") startJob(deps, saved.id, null, (signal) => filePhotos(deps, saved.id, signal));
|
|
9902
|
+
return saved;
|
|
9903
|
+
}
|
|
9904
|
+
var hashOfFile = (file) => {
|
|
9905
|
+
const s = String(file ?? "");
|
|
9906
|
+
return s.startsWith("asset:") && HASH.test(s.slice(6)) ? s.slice(6) : null;
|
|
9907
|
+
};
|
|
9908
|
+
var seedDirection = (p) => {
|
|
9909
|
+
const said = str4(p.identityNotes ?? p.descriptor ?? p.promptName, 400);
|
|
9910
|
+
if (said) return said;
|
|
9911
|
+
return str4([p.presentation, p.ageRange, p.hair && `${p.hair} hair`].filter(Boolean).join(", "), 400);
|
|
9912
|
+
};
|
|
9913
|
+
function slotsFromRecord(presenter) {
|
|
9914
|
+
const sources = (presenter.sourceRefs ?? []).map((s) => hashOfFile(s.file)).filter((h) => !!h);
|
|
9915
|
+
const views = {};
|
|
9916
|
+
for (const v of PRESENTER_VIEWS) views[v] = emptySlot();
|
|
9917
|
+
const kept = [];
|
|
9918
|
+
const shots = presenter.shots ?? [];
|
|
9919
|
+
const hasPortrait = shots.some((s) => s.angle === "portrait" && hashOfFile(s.file));
|
|
9920
|
+
shots.forEach((shot, i) => {
|
|
9921
|
+
const hash = hashOfFile(shot.file);
|
|
9922
|
+
const angle = !hasPortrait && i === 0 ? "portrait" : shot.angle;
|
|
9923
|
+
if (hash && isView(angle) && views[angle].status === "empty") {
|
|
9924
|
+
const origin = sources.includes(hash) ? "photo" : "generated";
|
|
9925
|
+
views[angle] = { ...emptySlot(), status: "approved", hash, origin };
|
|
9926
|
+
} else kept.push({ ...shot });
|
|
9927
|
+
});
|
|
9928
|
+
return { views, kept, sources };
|
|
9929
|
+
}
|
|
9930
|
+
function seedDraftFromPresenter(core, brandId, presenter) {
|
|
9931
|
+
const { views, kept, sources } = slotsFromRecord(presenter);
|
|
9932
|
+
const source = presenter.source ?? (presenter.sourceRefs?.length ? "photos" : "synthetic");
|
|
9933
|
+
const direction = seedDirection(presenter);
|
|
9934
|
+
const rec = {
|
|
9935
|
+
id: `pd-${randomUUID().slice(0, 8)}`,
|
|
9936
|
+
brandId,
|
|
9937
|
+
source,
|
|
9938
|
+
name: str4(presenter.name, 60),
|
|
9939
|
+
facets: (presenter.suitableCategories ?? []).map((f) => str4(f, 40)).filter(Boolean).slice(0, 8),
|
|
9940
|
+
sources,
|
|
9941
|
+
views,
|
|
9942
|
+
// Every one of them, not any: a record carrying a single supplementary
|
|
9943
|
+
// view was never a record whose owner asked for the whole set, and
|
|
9944
|
+
// reading it that way made the save demand two more nobody wanted.
|
|
9945
|
+
extras: EXTRA_VIEWS.every((v) => views[v].status !== "empty"),
|
|
9946
|
+
generations: 0,
|
|
9947
|
+
activeView: null,
|
|
9948
|
+
stage: "idle",
|
|
9949
|
+
presenterId: presenter.id,
|
|
9950
|
+
baseId: presenter.id,
|
|
9951
|
+
identityEdits: identityEditsOf(presenter.identityEdits),
|
|
9952
|
+
asks: [],
|
|
9953
|
+
results: [],
|
|
9954
|
+
decisions: [],
|
|
9955
|
+
createdAt: "",
|
|
9956
|
+
updatedAt: ""
|
|
9957
|
+
};
|
|
9958
|
+
if (direction) rec.direction = direction;
|
|
9959
|
+
if (presenter.likeness) rec.attestation = presenter.likeness;
|
|
9960
|
+
if (kept.length) rec.keptShots = kept;
|
|
9961
|
+
return put(core, rec);
|
|
9962
|
+
}
|
|
9963
|
+
function openPresenterEdit(core, brandId, presenterId) {
|
|
9964
|
+
const brand = core.store.getBrand(brandId);
|
|
9965
|
+
if (!brand) throw fail("brand not found", 404);
|
|
9966
|
+
const rows = brandCharacters(brand.json);
|
|
9967
|
+
const asked = rows.find((c) => c?.id === presenterId);
|
|
9968
|
+
if (!asked) throw fail("presenter not found", 404);
|
|
9969
|
+
if (!isCustomPresenter(asked)) throw fail("this presenter is not editable", 400);
|
|
9970
|
+
const headId = headOf(brand.json, presenterId);
|
|
9971
|
+
const head = rows.find((c) => c?.id === headId);
|
|
9972
|
+
return listPresenterDrafts(core, brandId).find((d) => d.presenterId === headId) ?? seedDraftFromPresenter(core, brandId, head);
|
|
9973
|
+
}
|
|
9974
|
+
var IDENTITY_WORDS = [
|
|
9975
|
+
"hair",
|
|
9976
|
+
"fringe",
|
|
9977
|
+
"bangs",
|
|
9978
|
+
"curls",
|
|
9979
|
+
"beard",
|
|
9980
|
+
"moustache",
|
|
9981
|
+
"mustache",
|
|
9982
|
+
"stubble",
|
|
9983
|
+
"eyebrows",
|
|
9984
|
+
"eyebrow",
|
|
9985
|
+
"brows",
|
|
9986
|
+
"brow",
|
|
9987
|
+
"eyes",
|
|
9988
|
+
"eye",
|
|
9989
|
+
"lashes",
|
|
9990
|
+
"nose",
|
|
9991
|
+
"lips",
|
|
9992
|
+
"mouth",
|
|
9993
|
+
"teeth",
|
|
9994
|
+
"smile",
|
|
9995
|
+
"chin",
|
|
9996
|
+
"jawline",
|
|
9997
|
+
"jaw",
|
|
9998
|
+
"cheekbones",
|
|
9999
|
+
"cheeks",
|
|
10000
|
+
"ears",
|
|
10001
|
+
"neck",
|
|
10002
|
+
"skin",
|
|
10003
|
+
"freckles",
|
|
10004
|
+
"scar",
|
|
10005
|
+
"mole",
|
|
10006
|
+
"tattoo",
|
|
10007
|
+
"wrinkles",
|
|
10008
|
+
"glasses",
|
|
10009
|
+
"earrings",
|
|
10010
|
+
"earring",
|
|
10011
|
+
"piercing",
|
|
10012
|
+
"makeup",
|
|
10013
|
+
"age",
|
|
10014
|
+
"older",
|
|
10015
|
+
"younger",
|
|
10016
|
+
"build",
|
|
10017
|
+
"weight",
|
|
10018
|
+
"height",
|
|
10019
|
+
"shoulders"
|
|
10020
|
+
];
|
|
10021
|
+
var TRAIT_ALIAS = {
|
|
10022
|
+
fringe: "hair",
|
|
10023
|
+
bangs: "hair",
|
|
10024
|
+
curls: "hair",
|
|
10025
|
+
mustache: "moustache",
|
|
10026
|
+
stubble: "beard",
|
|
10027
|
+
eyebrow: "brow",
|
|
10028
|
+
eyebrows: "brow",
|
|
10029
|
+
brows: "brow",
|
|
10030
|
+
eye: "eyes",
|
|
10031
|
+
lashes: "eyes",
|
|
10032
|
+
jawline: "jaw",
|
|
10033
|
+
cheeks: "cheekbones",
|
|
10034
|
+
earring: "earrings",
|
|
10035
|
+
older: "age",
|
|
10036
|
+
younger: "age",
|
|
10037
|
+
weight: "build",
|
|
10038
|
+
height: "build"
|
|
10039
|
+
};
|
|
10040
|
+
var TRAIT = new RegExp(`\\b(${IDENTITY_WORDS.join("|")})\\b`, "i");
|
|
10041
|
+
function traitOf(sentence) {
|
|
10042
|
+
const m = TRAIT.exec(sentence);
|
|
10043
|
+
if (!m) return null;
|
|
10044
|
+
const word = m[1].toLowerCase();
|
|
10045
|
+
return TRAIT_ALIAS[word] ?? word;
|
|
10046
|
+
}
|
|
10047
|
+
function mergeIdentityEdits(existing, adjustment) {
|
|
10048
|
+
const next = str4(adjustment, IDENTITY_EDIT_CHARS).replace(/\s+/g, " ");
|
|
10049
|
+
if (!next) return existing.slice(-IDENTITY_EDITS_MAX);
|
|
10050
|
+
const trait = traitOf(next);
|
|
10051
|
+
const kept = existing.filter((e) => e.toLowerCase() !== next.toLowerCase() && (!trait || traitOf(e) !== trait));
|
|
10052
|
+
return [...kept, next].slice(-IDENTITY_EDITS_MAX);
|
|
10053
|
+
}
|
|
10054
|
+
async function filePhotos(deps, id, signal) {
|
|
10055
|
+
const { core, analyzer } = deps;
|
|
10056
|
+
const rec = getPresenterDraft(core, id);
|
|
10057
|
+
if (!rec) return;
|
|
10058
|
+
let analysis;
|
|
10059
|
+
let readError;
|
|
10060
|
+
if (analyzer) {
|
|
10061
|
+
mutate(core, id, (r) => {
|
|
10062
|
+
r.stage = "analyzing";
|
|
10063
|
+
});
|
|
10064
|
+
try {
|
|
10065
|
+
analysis = await analyzer.analyze(
|
|
10066
|
+
{
|
|
10067
|
+
kind: "presenter",
|
|
10068
|
+
imagePaths: rec.sources.map((h) => core.images.pathFor(h)),
|
|
10069
|
+
name: rec.name || "New presenter",
|
|
10070
|
+
instruction: rec.direction || void 0,
|
|
10071
|
+
vocabulary: deps.vocabulary,
|
|
10072
|
+
classifyPhotos: true
|
|
10073
|
+
},
|
|
10074
|
+
signal
|
|
10075
|
+
);
|
|
10076
|
+
} catch (err) {
|
|
10077
|
+
if (signal.aborted) return;
|
|
10078
|
+
readError = String(err?.message ?? "the photos could not be read");
|
|
10079
|
+
}
|
|
10080
|
+
}
|
|
10081
|
+
if (signal.aborted) return;
|
|
10082
|
+
mutate(core, id, (r) => {
|
|
10083
|
+
if (analysis) r.analysis = analysis;
|
|
10084
|
+
r.readError = readError;
|
|
10085
|
+
});
|
|
10086
|
+
}
|
|
10087
|
+
function startJob(deps, id, view, work) {
|
|
10088
|
+
const ctrl = new AbortController();
|
|
10089
|
+
running3.set(id, { view, ctrl });
|
|
10090
|
+
void work(ctrl.signal).catch(() => {
|
|
10091
|
+
}).finally(() => {
|
|
10092
|
+
running3.delete(id);
|
|
10093
|
+
try {
|
|
10094
|
+
mutate(deps.core, id, (r) => {
|
|
10095
|
+
r.activeView = null;
|
|
10096
|
+
r.stage = "idle";
|
|
10097
|
+
});
|
|
10098
|
+
} catch {
|
|
10099
|
+
}
|
|
10100
|
+
});
|
|
10101
|
+
}
|
|
10102
|
+
async function generateView(deps, id, view, opts = {}) {
|
|
10103
|
+
const { core, engine } = deps;
|
|
10104
|
+
if (!isView(view)) throw fail("no such view", 400);
|
|
10105
|
+
let rec = getPresenterDraft(core, id);
|
|
10106
|
+
if (!rec) throw fail("draft not found", 404);
|
|
10107
|
+
if (running3.has(id)) throw fail("a view is still being drawn", 409);
|
|
10108
|
+
if (isExtra(view) && !rec.extras) throw fail("extra views are built on request", 400);
|
|
10109
|
+
if (rec.source === "synthetic" && !rec.direction?.trim()) {
|
|
10110
|
+
const said = str4(opts.adjustment, 400);
|
|
10111
|
+
if (!said) throw fail("describe who they are in a sentence", 400);
|
|
10112
|
+
rec = mutate(core, id, (r) => {
|
|
10113
|
+
r.direction = said;
|
|
10114
|
+
});
|
|
10115
|
+
}
|
|
10116
|
+
if (opts.decide === "auto" && HAND_APPROVED.has(view))
|
|
10117
|
+
throw fail(`the ${VIEW_LABEL[view]} is always decided by hand`, 400);
|
|
10118
|
+
for (const dep of DEPENDS[view]) {
|
|
10119
|
+
if (rec.views[dep].status !== "approved") throw fail(`approve the ${VIEW_LABEL[dep]} first`, 400);
|
|
10120
|
+
}
|
|
10121
|
+
if (!engine || !engine.capabilities().maxReferenceImages) throw fail("no engine here can draw a person", 400);
|
|
10122
|
+
const adjustment = str4(opts.adjustment, 240) || void 0;
|
|
10123
|
+
const decide = opts.decide === "auto" ? "auto" : void 0;
|
|
10124
|
+
const before = rec.views[view].status;
|
|
10125
|
+
const saved = mutate(core, id, (r) => {
|
|
10126
|
+
r.views[view].status = "generating";
|
|
10127
|
+
r.views[view].error = void 0;
|
|
10128
|
+
r.views[view].startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
10129
|
+
r.activeView = view;
|
|
10130
|
+
r.stage = "drawing";
|
|
10131
|
+
const last = r.asks[r.asks.length - 1];
|
|
10132
|
+
if (adjustment && !(last && last.view === view && last.text === adjustment)) {
|
|
10133
|
+
r.asks = [...r.asks, { view, text: adjustment, at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-40);
|
|
10134
|
+
}
|
|
10135
|
+
});
|
|
10136
|
+
startJob(deps, id, view, (signal) => drawView(deps, id, view, before, adjustment, decide, signal));
|
|
10137
|
+
return { draft: saved };
|
|
10138
|
+
}
|
|
10139
|
+
async function drawView(deps, id, view, before, adjustment, decide, signal) {
|
|
10140
|
+
const { core, engine, analyzer } = deps;
|
|
10141
|
+
const caps = engine.capabilities();
|
|
10142
|
+
let rec = getPresenterDraft(core, id);
|
|
10143
|
+
try {
|
|
10144
|
+
if (!rec.analysis && analyzer && view !== "portrait" && rec.views.portrait.hash) {
|
|
10145
|
+
mutate(core, id, (r) => {
|
|
10146
|
+
r.stage = "analyzing";
|
|
10147
|
+
});
|
|
10148
|
+
const analysis = await analyzer.analyze(
|
|
10149
|
+
{
|
|
10150
|
+
kind: "presenter",
|
|
10151
|
+
imagePaths: [core.images.pathFor(rec.views.portrait.hash)],
|
|
10152
|
+
name: rec.name || "New presenter",
|
|
10153
|
+
instruction: rec.direction || void 0,
|
|
10154
|
+
vocabulary: deps.vocabulary
|
|
10155
|
+
},
|
|
10156
|
+
signal
|
|
10157
|
+
);
|
|
10158
|
+
rec = mutate(core, id, (r) => {
|
|
10159
|
+
r.analysis = analysis;
|
|
10160
|
+
r.stage = "drawing";
|
|
10161
|
+
});
|
|
10162
|
+
}
|
|
10163
|
+
if (signal.aborted) throw new Error("cancelled");
|
|
10164
|
+
const { prompt, refs, roles } = planStep(rec, view, adjustment, caps.maxReferenceImages);
|
|
10165
|
+
const builtOn = new Map(refDeps(rec, view).map((v) => [v, rec.views[v].hash]));
|
|
10166
|
+
const paths = [];
|
|
10167
|
+
for (const h of refs) {
|
|
10168
|
+
const p = core.images.pathFor(h);
|
|
10169
|
+
paths.push(caps.maxReferenceEdge ? await capReferenceEdge(core, p, caps.maxReferenceEdge) : p);
|
|
10170
|
+
}
|
|
10171
|
+
const drawn = await draw(deps, {
|
|
10172
|
+
prompt,
|
|
10173
|
+
brandId: rec.brandId,
|
|
10174
|
+
...paths.length ? { referenceImages: paths, referenceRoles: roles } : {},
|
|
10175
|
+
signal
|
|
10176
|
+
});
|
|
10177
|
+
const hash = await trimEdgeBars(core, drawn);
|
|
10178
|
+
mutate(core, id, (r) => {
|
|
10179
|
+
const slot = r.views[view];
|
|
10180
|
+
if (slot.hash && slot.hash !== hash) {
|
|
10181
|
+
if (before === "approved") {
|
|
10182
|
+
if (slot.prior && slot.prior !== slot.hash && !r.sources.includes(slot.prior)) {
|
|
10183
|
+
slot.rejected = [...slot.rejected, slot.prior];
|
|
10184
|
+
}
|
|
10185
|
+
slot.prior = slot.hash;
|
|
10186
|
+
} else if (slot.origin === "generated") slot.rejected = [...slot.rejected, slot.hash];
|
|
10187
|
+
}
|
|
10188
|
+
slot.status = "candidate";
|
|
10189
|
+
slot.hash = hash;
|
|
10190
|
+
slot.origin = "generated";
|
|
10191
|
+
slot.attempts += 1;
|
|
10192
|
+
slot.conditionedOn = refs;
|
|
10193
|
+
slot.adjustment = adjustment;
|
|
10194
|
+
slot.error = void 0;
|
|
10195
|
+
slot.startedAt = void 0;
|
|
10196
|
+
r.generations += 1;
|
|
10197
|
+
r.results = [
|
|
10198
|
+
...r.results,
|
|
10199
|
+
{ view, hash, at: (/* @__PURE__ */ new Date()).toISOString(), ...adjustment ? { ask: adjustment } : {}, how: "drawn" }
|
|
10200
|
+
].slice(-RESULTS_MAX);
|
|
10201
|
+
if (decide === "auto") {
|
|
10202
|
+
slot.status = "approved";
|
|
10203
|
+
if (slot.prior) staleDependents(r, view);
|
|
10204
|
+
}
|
|
10205
|
+
for (const [v, was] of builtOn) {
|
|
10206
|
+
if (r.views[v].hash !== was) {
|
|
10207
|
+
slot.status = "stale";
|
|
10208
|
+
break;
|
|
10209
|
+
}
|
|
10210
|
+
}
|
|
10211
|
+
});
|
|
10212
|
+
} catch (err) {
|
|
10213
|
+
mutate(core, id, (r) => {
|
|
10214
|
+
const slot = r.views[view];
|
|
10215
|
+
if (!slot.hash && slot.prior) {
|
|
10216
|
+
slot.hash = slot.prior;
|
|
10217
|
+
slot.prior = void 0;
|
|
10218
|
+
slot.origin = "generated";
|
|
10219
|
+
slot.status = dependents(view).some((v) => r.views[v].hash) ? "approved" : "candidate";
|
|
10220
|
+
} else slot.status = before === "generating" ? slot.hash ? "candidate" : "empty" : before;
|
|
10221
|
+
slot.attempts += 1;
|
|
10222
|
+
if (adjustment) slot.adjustment = adjustment;
|
|
10223
|
+
slot.error = signal.aborted ? "cancelled" : String(err?.message ?? "the view could not be drawn");
|
|
10224
|
+
slot.startedAt = void 0;
|
|
10225
|
+
r.generations += 1;
|
|
10226
|
+
});
|
|
10227
|
+
}
|
|
10228
|
+
}
|
|
10229
|
+
function rolledFrom(rec) {
|
|
10230
|
+
const id = identityOf2(rec);
|
|
10231
|
+
const said = id.said.replace(/[.\s]+$/, "");
|
|
10232
|
+
const keep = keepFor("portrait", id.items);
|
|
10233
|
+
if (!keep) return said;
|
|
10234
|
+
return said ? `${said}, ${keep}` : keep;
|
|
10235
|
+
}
|
|
10236
|
+
var namesASide = (text) => /\b(left|right)\b/i.test(text ?? "");
|
|
10237
|
+
function refDeps(rec, view) {
|
|
10238
|
+
const deps = DEPENDS[view];
|
|
10239
|
+
if (view !== "right" || !namesASide(rec.keep)) return deps;
|
|
10240
|
+
return deps.filter((d) => d !== "left");
|
|
10241
|
+
}
|
|
10242
|
+
function usableSource(rec, hash) {
|
|
10243
|
+
const filings = rec.analysis?.photos;
|
|
10244
|
+
if (!filings?.length) return true;
|
|
10245
|
+
const i = rec.sources.indexOf(hash);
|
|
10246
|
+
const said = filings.find((p) => p.index === i);
|
|
10247
|
+
return said ? said.usable !== false : true;
|
|
10248
|
+
}
|
|
10249
|
+
function planStep(rec, view, adjustment, cap2) {
|
|
10250
|
+
const slot = rec.views[view];
|
|
10251
|
+
const id = identityOf2(rec);
|
|
10252
|
+
const room = Math.max(1, cap2);
|
|
10253
|
+
const shown = itemsFor(view, id.items);
|
|
10254
|
+
const wanted = rec.keepItems ? [...new Set(shown.flatMap((i) => i.refs ?? []))] : [...new Set(Object.values(rec.detailRefs ?? {}).flat())];
|
|
10255
|
+
const roll = rec.source === "synthetic" && view === "portrait";
|
|
10256
|
+
const identity = [];
|
|
10257
|
+
if (roll) {
|
|
10258
|
+
if (adjustment && slot.hash) identity.push(slot.hash);
|
|
10259
|
+
} else {
|
|
10260
|
+
for (const dep of refDeps(rec, view)) {
|
|
10261
|
+
const h = rec.views[dep].hash;
|
|
10262
|
+
if (h && rec.views[dep].status === "approved" && !identity.includes(h)) identity.push(h);
|
|
10263
|
+
}
|
|
10264
|
+
const stood = rec.sources.filter((h) => usableSource(rec, h));
|
|
10265
|
+
for (const h of stood.length ? stood : rec.sources) if (!identity.includes(h)) identity.push(h);
|
|
10266
|
+
}
|
|
10267
|
+
const hold = wanted.length && identity.length >= room ? 1 : 0;
|
|
10268
|
+
const kept = identity.slice(0, Math.max(1, room - hold));
|
|
10269
|
+
const extra = wanted.filter((h) => !kept.includes(h)).slice(0, Math.max(0, room - kept.length));
|
|
10270
|
+
const dropped = [
|
|
10271
|
+
...identity.slice(kept.length).map((h) => `identity ${h}: no room`),
|
|
10272
|
+
...wanted.filter((h) => !kept.includes(h) && !extra.includes(h)).map((h) => `detail ${h}: no room`),
|
|
10273
|
+
...id.items.filter((i) => i.refs?.length && !shown.includes(i)).map((i) => `detail ${i.id}: not shown in this view`)
|
|
10274
|
+
];
|
|
10275
|
+
const refs = [...kept, ...extra];
|
|
10276
|
+
const roles = [...kept.map(() => "character"), ...extra.map(() => "detail")];
|
|
10277
|
+
const named = shown.filter((i) => (i.refs ?? []).some((h) => extra.includes(h)));
|
|
10278
|
+
const legend = named.length ? ` The attached detail pictures show, in this order: ${named.map((i) => i.words).join("; ")}.` : "";
|
|
10279
|
+
const who = whoIs(id, view);
|
|
10280
|
+
const subject = roll ? syntheticIdentitySubject(rolledFrom(rec), { adjustment: kept.length ? adjustment : void 0 }) : adjustment ? (
|
|
10281
|
+
// The ask is scoped to this view and has to win where it meets the
|
|
10282
|
+
// view's own fixed clauses, which is why the override sentence rides
|
|
10283
|
+
// with it: every non-face subject already pins "the same person as the
|
|
10284
|
+
// attached images" and "their own hair exactly as the attached images
|
|
10285
|
+
// show it", so a repair that names hair or a feature was asking for one
|
|
10286
|
+
// thing and being told the opposite in the same breath. A second
|
|
10287
|
+
// "otherwise identical" would not have settled that; saying which one
|
|
10288
|
+
// wins does. What the ask does not name is then held still, the same
|
|
10289
|
+
// way it is on a described person's face.
|
|
10290
|
+
`${viewSubject(view, who)}. For this view only: ${adjustment}. That change is the point of this picture and overrides anything above that describes it otherwise. ${keptAspects(adjustment)}`
|
|
10291
|
+
) : viewSubject(view, who);
|
|
10292
|
+
return { prompt: studioPrompt(subject) + legend, refs, roles, dropped };
|
|
10293
|
+
}
|
|
10294
|
+
async function approveView(deps, id, view) {
|
|
10295
|
+
if (!isView(view)) throw fail("no such view", 400);
|
|
10296
|
+
const rec = getPresenterDraft(deps.core, id);
|
|
10297
|
+
if (!rec) throw fail("draft not found", 404);
|
|
10298
|
+
const slot = rec.views[view];
|
|
10299
|
+
if (slot.status === "approved") {
|
|
10300
|
+
if (!slot.prior) return rec;
|
|
10301
|
+
return mutate(deps.core, id, (r) => {
|
|
10302
|
+
const s = r.views[view];
|
|
10303
|
+
if (s.prior && !r.sources.includes(s.prior)) s.rejected = [...s.rejected, s.prior];
|
|
10304
|
+
s.prior = void 0;
|
|
10305
|
+
});
|
|
10306
|
+
}
|
|
10307
|
+
if (slot.status === "stale") throw fail(`redo the ${VIEW_LABEL[view]}: it was built on a view you changed`, 400);
|
|
10308
|
+
if (slot.status !== "candidate" || !slot.hash) throw fail(`there is no ${VIEW_LABEL[view]} to approve yet`, 400);
|
|
10309
|
+
return mutate(deps.core, id, (r) => {
|
|
10310
|
+
const s = r.views[view];
|
|
10311
|
+
s.status = "approved";
|
|
10312
|
+
r.decisions = [...r.decisions, { view, what: "use", at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-RESULTS_MAX);
|
|
10313
|
+
if (s.prior) {
|
|
10314
|
+
if (!r.sources.includes(s.prior)) s.rejected = [...s.rejected, s.prior];
|
|
10315
|
+
s.prior = void 0;
|
|
10316
|
+
}
|
|
10317
|
+
reconcileDependents(r, view);
|
|
10318
|
+
if (view === "portrait" && r.presenterId && s.adjustment) {
|
|
10319
|
+
r.identityEdits = mergeIdentityEdits(r.identityEdits, s.adjustment);
|
|
10320
|
+
}
|
|
10321
|
+
});
|
|
10322
|
+
}
|
|
10323
|
+
async function revertView(deps, id, view) {
|
|
10324
|
+
if (!isView(view)) throw fail("no such view", 400);
|
|
10325
|
+
if (running3.has(id)) throw fail("a view is still being drawn", 409);
|
|
10326
|
+
const rec = getPresenterDraft(deps.core, id);
|
|
10327
|
+
if (!rec) throw fail("draft not found", 404);
|
|
10328
|
+
if (!rec.views[view].prior) throw fail(`there is no previous ${VIEW_LABEL[view]} to keep`, 400);
|
|
10329
|
+
return mutate(deps.core, id, (r) => {
|
|
10330
|
+
const s = r.views[view];
|
|
10331
|
+
const prior = s.prior;
|
|
10332
|
+
if (s.hash && s.hash !== prior && !r.sources.includes(s.hash)) s.rejected = [...s.rejected, s.hash];
|
|
10333
|
+
s.hash = prior;
|
|
10334
|
+
s.origin = r.sources.includes(prior) ? "photo" : "generated";
|
|
10335
|
+
s.status = "approved";
|
|
10336
|
+
s.prior = void 0;
|
|
10337
|
+
s.adjustment = void 0;
|
|
10338
|
+
s.conditionedOn = void 0;
|
|
10339
|
+
s.error = void 0;
|
|
10340
|
+
r.decisions = [...r.decisions, { view, what: "keep", at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-RESULTS_MAX);
|
|
10341
|
+
});
|
|
10342
|
+
}
|
|
10343
|
+
async function restoreView(deps, id, view, hash) {
|
|
10344
|
+
if (!isView(view)) throw fail("no such view", 400);
|
|
10345
|
+
if (!HASH.test(String(hash))) throw fail("no such picture", 400);
|
|
10346
|
+
if (running3.has(id)) throw fail("a view is still being drawn", 409);
|
|
10347
|
+
const rec = getPresenterDraft(deps.core, id);
|
|
10348
|
+
if (!rec) throw fail("draft not found", 404);
|
|
10349
|
+
const slot = rec.views[view];
|
|
10350
|
+
const known = rec.results.some((r) => r.view === view && r.hash === hash) || slot.prior === hash || slot.rejected.includes(hash);
|
|
10351
|
+
if (!known) throw fail(`that was never the ${VIEW_LABEL[view]}`, 400);
|
|
10352
|
+
if (!existsSync(deps.core.images.pathFor(hash))) throw fail("that picture is gone", 410);
|
|
10353
|
+
if (slot.hash === hash) return rec;
|
|
10354
|
+
const from = [...rec.results].reverse().find((r) => r.view === view && r.hash === hash);
|
|
10355
|
+
return mutate(deps.core, id, (r) => {
|
|
10356
|
+
const s = r.views[view];
|
|
10357
|
+
const was = s.hash;
|
|
10358
|
+
if (s.prior && s.prior !== was && s.prior !== hash && !r.sources.includes(s.prior)) {
|
|
10359
|
+
s.rejected = [...s.rejected, s.prior];
|
|
10360
|
+
}
|
|
10361
|
+
if (was) s.prior = was;
|
|
10362
|
+
s.rejected = s.rejected.filter((h) => h !== hash);
|
|
10363
|
+
s.hash = hash;
|
|
10364
|
+
s.origin = r.sources.includes(hash) ? "photo" : "generated";
|
|
10365
|
+
s.adjustment = from?.ask;
|
|
10366
|
+
s.conditionedOn = void 0;
|
|
10367
|
+
s.error = void 0;
|
|
10368
|
+
reconcileDependents(r, view);
|
|
10369
|
+
});
|
|
10370
|
+
}
|
|
10371
|
+
function dependents(view) {
|
|
10372
|
+
return PRESENTER_VIEWS.filter((v) => v !== view && reaches(v, view));
|
|
10373
|
+
}
|
|
10374
|
+
function reaches(from, to) {
|
|
10375
|
+
return DEPENDS[from].some((d) => d === to || reaches(d, to));
|
|
10376
|
+
}
|
|
10377
|
+
function stillStands(r, view) {
|
|
10378
|
+
const on = r.views[view].conditionedOn;
|
|
10379
|
+
if (!on?.length) return false;
|
|
10380
|
+
for (const h of on) {
|
|
10381
|
+
const owner = r.results.find((x) => x.hash === h)?.view;
|
|
10382
|
+
if (!owner || owner === view) continue;
|
|
10383
|
+
if (r.views[owner].hash !== h) return false;
|
|
10384
|
+
}
|
|
10385
|
+
return true;
|
|
10386
|
+
}
|
|
10387
|
+
function reconcileDependents(r, view) {
|
|
10388
|
+
for (const d of dependents(view)) {
|
|
10389
|
+
const s = r.views[d];
|
|
10390
|
+
if (!s.hash) continue;
|
|
10391
|
+
if (stillStands(r, d)) {
|
|
10392
|
+
if (s.status === "stale") s.status = "approved";
|
|
10393
|
+
} else if (s.status === "approved" || s.status === "candidate") s.status = "stale";
|
|
10394
|
+
}
|
|
10395
|
+
}
|
|
10396
|
+
function staleDependents(r, view) {
|
|
10397
|
+
for (const d of dependents(view)) {
|
|
10398
|
+
const s = r.views[d];
|
|
10399
|
+
if (s.origin === "photo") continue;
|
|
10400
|
+
if (s.status === "approved" || s.status === "candidate") s.status = "stale";
|
|
10401
|
+
}
|
|
10402
|
+
}
|
|
10403
|
+
async function redoView(deps, id, view) {
|
|
10404
|
+
if (!isView(view)) throw fail("no such view", 400);
|
|
10405
|
+
if (running3.has(id)) throw fail("a view is still being drawn", 409);
|
|
10406
|
+
return mutate(deps.core, id, (r) => {
|
|
10407
|
+
const slot = r.views[view];
|
|
10408
|
+
const gone = [slot.prior].filter((h) => !!h && h !== slot.hash && !r.sources.includes(h));
|
|
10409
|
+
r.views[view] = {
|
|
10410
|
+
...emptySlot(),
|
|
10411
|
+
attempts: slot.attempts,
|
|
10412
|
+
rejected: [...slot.rejected, ...gone],
|
|
10413
|
+
...slot.hash && !r.sources.includes(slot.hash) ? { prior: slot.hash } : {}
|
|
10414
|
+
};
|
|
10415
|
+
if (view === "portrait" && r.source === "synthetic") r.analysis = void 0;
|
|
10416
|
+
r.decisions = [...r.decisions, { view, what: "again", at: (/* @__PURE__ */ new Date()).toISOString() }].slice(-RESULTS_MAX);
|
|
10417
|
+
});
|
|
10418
|
+
}
|
|
10419
|
+
async function usePhotoForView(deps, id, view, hash) {
|
|
10420
|
+
if (!isView(view)) throw fail("no such view", 400);
|
|
10421
|
+
if (running3.has(id)) throw fail("a view is still being drawn", 409);
|
|
10422
|
+
const rec = getPresenterDraft(deps.core, id);
|
|
10423
|
+
if (!rec) throw fail("draft not found", 404);
|
|
10424
|
+
if (rec.source !== "photos" || !rec.sources.includes(hash)) throw fail("that is not one of their photos", 400);
|
|
10425
|
+
return mutate(deps.core, id, (r) => {
|
|
10426
|
+
const slot = r.views[view];
|
|
10427
|
+
if (slot.hash === hash && slot.origin === "photo") return;
|
|
10428
|
+
const gone = [slot.hash, slot.prior].filter((h) => !!h && !r.sources.includes(h));
|
|
10429
|
+
r.views[view] = {
|
|
10430
|
+
...emptySlot(),
|
|
10431
|
+
attempts: slot.attempts,
|
|
10432
|
+
rejected: [...slot.rejected, ...gone],
|
|
10433
|
+
status: "approved",
|
|
10434
|
+
hash,
|
|
10435
|
+
origin: "photo"
|
|
10436
|
+
};
|
|
10437
|
+
staleDependents(r, view);
|
|
10438
|
+
});
|
|
10439
|
+
}
|
|
10440
|
+
async function updatePresenterDraft(core, id, patch2) {
|
|
10441
|
+
return mutate(core, id, (r) => {
|
|
10442
|
+
if (patch2.extras !== void 0) r.extras = patch2.extras === true;
|
|
10443
|
+
if (patch2.name !== void 0) r.name = str4(patch2.name, 60);
|
|
10444
|
+
if (Array.isArray(patch2.facets))
|
|
10445
|
+
r.facets = patch2.facets.map((f) => str4(f, 40)).filter(Boolean).slice(0, 8);
|
|
10446
|
+
if (patch2.direction !== void 0) r.direction = str4(patch2.direction, 400) || void 0;
|
|
10447
|
+
if (patch2.setup !== void 0) r.setup = str4(patch2.setup, SETUP_CHARS) || void 0;
|
|
10448
|
+
const items = keepItemsOf(patch2.keepItems, (h) => core.images.has(h));
|
|
10449
|
+
if (items) writeItems(r, items);
|
|
10450
|
+
else {
|
|
10451
|
+
if (patch2.keep !== void 0) r.keep = str4(patch2.keep, 240) || void 0;
|
|
10452
|
+
if (patch2.detailRefs !== void 0) r.detailRefs = detailRefsOf(patch2.detailRefs, (h) => core.images.has(h));
|
|
10453
|
+
}
|
|
10454
|
+
});
|
|
10455
|
+
}
|
|
10456
|
+
async function savePresenterDraft(deps, id, hooks = {}) {
|
|
10457
|
+
const { core } = deps;
|
|
10458
|
+
const rec = getPresenterDraft(core, id);
|
|
10459
|
+
if (!rec) throw fail("draft not found", 404);
|
|
10460
|
+
if (running3.has(id)) throw fail("a view is still being drawn", 409);
|
|
10461
|
+
if (rec.presenterId) return saveEdit(deps, rec, hooks);
|
|
10462
|
+
if (!rec.name.trim()) throw fail("give them a name", 400);
|
|
10463
|
+
const blind = !deps.engine;
|
|
10464
|
+
const required = blind ? ["portrait"] : [...CORE_VIEWS, ...EXTRA_VIEWS.filter((v) => rec.views[v].status !== "empty")];
|
|
10465
|
+
for (const v of required) {
|
|
10466
|
+
const s = rec.views[v];
|
|
10467
|
+
if (s.status === "stale") throw fail(`redo the ${VIEW_LABEL[v]}: it was built on a view you changed`, 400);
|
|
10468
|
+
if (s.status !== "approved" || !s.hash) throw fail(`approve the ${VIEW_LABEL[v]} first`, 400);
|
|
10469
|
+
}
|
|
10470
|
+
const approved = required.map((v) => rec.views[v].hash);
|
|
10471
|
+
const shots = blind ? [...approved, ...rec.sources.filter((h) => !approved.includes(h))] : approved;
|
|
10472
|
+
const angles = blind ? ["portrait"] : [...required];
|
|
10473
|
+
const portraitFile = `asset:${shots[0]}`;
|
|
10474
|
+
const sourceFiles = rec.sources.map((h) => `asset:${h}`);
|
|
10475
|
+
const mode = presenterCropMode(portraitFile, sourceFiles, "portrait");
|
|
10476
|
+
const { previewHash, avatarHash } = await presenterCrops(core, shots[0], mode);
|
|
10477
|
+
const a = rec.analysis;
|
|
10478
|
+
const built = presenterRecordFrom({
|
|
10479
|
+
name: rec.name,
|
|
10480
|
+
shotHashes: shots,
|
|
10481
|
+
shotAngles: angles,
|
|
10482
|
+
...rec.sources.length ? { sourceHashes: rec.sources } : {},
|
|
10483
|
+
previewHash,
|
|
10484
|
+
avatarHash,
|
|
10485
|
+
promptName: a?.promptName ?? (rec.source === "synthetic" ? rec.direction : void 0),
|
|
10486
|
+
presentation: a?.presentation,
|
|
10487
|
+
descriptor: a?.descriptor,
|
|
10488
|
+
ageRange: a?.ageRange,
|
|
10489
|
+
hair: a?.hair,
|
|
10490
|
+
identityNotes: keptNotes(rec, a),
|
|
10491
|
+
negativeConstraints: a?.negativeConstraints,
|
|
10492
|
+
// What the person filing this chose wins over what the analyzer guessed.
|
|
10493
|
+
suitableCategories: rec.facets.length ? rec.facets : a?.suitableCategories,
|
|
10494
|
+
facial: a?.facial,
|
|
10495
|
+
skin: a?.skin,
|
|
10496
|
+
build: a?.build,
|
|
10497
|
+
source: rec.source,
|
|
10498
|
+
...rec.source === "photos" && rec.attestation ? { likeness: rec.attestation } : {}
|
|
10499
|
+
});
|
|
10500
|
+
if (!built.ok) throw fail(built.error, 400);
|
|
10501
|
+
commit(
|
|
10502
|
+
core,
|
|
10503
|
+
rec.brandId,
|
|
10504
|
+
(json) => {
|
|
10505
|
+
json.characters = [...brandCharacters(json), built.presenter];
|
|
10506
|
+
},
|
|
10507
|
+
// the row goes with the append, in one transaction: see `commit`
|
|
10508
|
+
id
|
|
10509
|
+
);
|
|
10510
|
+
removeUnreferenced(core, letGoOf(rec), hooks);
|
|
10511
|
+
return { presenter: built.presenter, brand: core.store.getBrand(rec.brandId) };
|
|
10512
|
+
}
|
|
10513
|
+
function keptNotes(rec, a) {
|
|
10514
|
+
const keep = rec.keep?.trim();
|
|
10515
|
+
const said = keep ? /[.!?]$/.test(keep) ? keep : `${keep}.` : "";
|
|
10516
|
+
return [said, a?.identityNotes ?? ""].filter(Boolean).join(" ") || void 0;
|
|
10517
|
+
}
|
|
10518
|
+
function letGoOf(rec) {
|
|
10519
|
+
return PRESENTER_VIEWS.flatMap((v) => {
|
|
10520
|
+
const s = rec.views[v];
|
|
10521
|
+
return [...s.rejected, ...s.prior && s.prior !== s.hash ? [s.prior] : []];
|
|
10522
|
+
});
|
|
10523
|
+
}
|
|
10524
|
+
async function saveEdit(deps, rec, hooks) {
|
|
10525
|
+
const { core } = deps;
|
|
10526
|
+
const brand = core.store.getBrand(rec.brandId);
|
|
10527
|
+
if (!brand) throw fail("brand not found", 404);
|
|
10528
|
+
const base = brandCharacters(brand.json).find((c) => c?.id === rec.presenterId);
|
|
10529
|
+
if (!base) throw fail("this presenter no longer exists", 404);
|
|
10530
|
+
const moved = () => fail("this presenter changed elsewhere; reload to continue", 409);
|
|
10531
|
+
const baseId = rec.baseId ?? "";
|
|
10532
|
+
if (!baseId || headOf(brand.json, baseId) !== baseId) throw moved();
|
|
10533
|
+
if (!rec.name.trim()) throw fail("give them a name", 400);
|
|
10534
|
+
const seeded = slotsFromRecord(base);
|
|
10535
|
+
const required = PRESENTER_VIEWS.filter((v) => rec.views[v].status !== "empty" || seeded.views[v].status !== "empty");
|
|
10536
|
+
for (const v of required) {
|
|
10537
|
+
const s = rec.views[v];
|
|
10538
|
+
if (s.status === "stale") throw fail(`redo the ${VIEW_LABEL[v]}: it was built on a view you changed`, 400);
|
|
10539
|
+
if (s.status !== "approved" || !s.hash) throw fail(`approve the ${VIEW_LABEL[v]} first`, 400);
|
|
10540
|
+
}
|
|
10541
|
+
const views = required.map((v) => ({ hash: rec.views[v].hash, angle: v }));
|
|
10542
|
+
const kept = rec.keptShots ?? [];
|
|
10543
|
+
const nextFiles = [...views.map((v) => `asset:${v.hash}`), ...kept.map((k) => k.file)];
|
|
10544
|
+
const baseFiles = (base.shots ?? []).map((s) => s.file);
|
|
10545
|
+
const samePictures = nextFiles.length === baseFiles.length && nextFiles.every((f) => baseFiles.includes(f));
|
|
10546
|
+
const sameEdits = JSON.stringify(rec.identityEdits) === JSON.stringify(base.identityEdits ?? []);
|
|
10547
|
+
const direction = rec.direction ?? "";
|
|
10548
|
+
const sameDirection = direction === seedDirection(base);
|
|
10549
|
+
let head;
|
|
10550
|
+
if (samePictures && sameEdits && sameDirection) {
|
|
10551
|
+
const built = presenterRecordFrom({ name: rec.name, suitableCategories: rec.facets }, base);
|
|
10552
|
+
if (!built.ok) throw fail(built.error, 400);
|
|
10553
|
+
const patched = built.presenter;
|
|
10554
|
+
commit(core, rec.brandId, (json) => {
|
|
10555
|
+
if (headOf(json, baseId) !== baseId) throw moved();
|
|
10556
|
+
json.characters = brandCharacters(json).map((c) => c.id === base.id ? patched : c);
|
|
10557
|
+
});
|
|
10558
|
+
head = patched;
|
|
10559
|
+
} else {
|
|
10560
|
+
if (!views.length) throw fail("approve the face first", 400);
|
|
10561
|
+
const first = views[0];
|
|
10562
|
+
const mode = presenterCropMode(
|
|
10563
|
+
`asset:${first.hash}`,
|
|
10564
|
+
rec.sources.map((h) => `asset:${h}`),
|
|
10565
|
+
first.angle
|
|
10566
|
+
);
|
|
10567
|
+
const { previewHash, avatarHash } = await presenterCrops(core, first.hash, mode);
|
|
10568
|
+
const built = mintRevision(base, {
|
|
10569
|
+
name: rec.name,
|
|
10570
|
+
shotHashes: views.map((v) => v.hash),
|
|
10571
|
+
shotAngles: views.map((v) => v.angle),
|
|
10572
|
+
...rec.sources.length ? { sourceHashes: rec.sources } : {},
|
|
10573
|
+
previewHash,
|
|
10574
|
+
avatarHash,
|
|
10575
|
+
suitableCategories: rec.facets,
|
|
10576
|
+
identityEdits: rec.identityEdits,
|
|
10577
|
+
// Recorded, never inferred: a record with no source stays without one.
|
|
10578
|
+
...base.source ? { source: rec.source } : {},
|
|
10579
|
+
...sameDirection ? {} : { identityNotes: direction },
|
|
10580
|
+
// A read taken in this session describes the face as it is now; with
|
|
10581
|
+
// none, the record's own words and casting prose carry.
|
|
10582
|
+
...readWords(rec.analysis)
|
|
10583
|
+
});
|
|
10584
|
+
if (!built.ok) throw fail(built.error, 400);
|
|
10585
|
+
const faceMoved = rec.views.portrait.hash !== seeded.views.portrait.hash;
|
|
10586
|
+
const carried = faceMoved ? [] : kept;
|
|
10587
|
+
const minted = carried.length ? { ...built.presenter, shots: [...built.presenter.shots ?? [], ...carried] } : built.presenter;
|
|
10588
|
+
commit(core, rec.brandId, (json) => {
|
|
10589
|
+
if (headOf(json, baseId) !== baseId) throw moved();
|
|
10590
|
+
json.characters = [
|
|
10591
|
+
...brandCharacters(json).map((c) => c.id === base.id ? { ...c, supersededBy: minted.id } : c),
|
|
10592
|
+
minted
|
|
10593
|
+
];
|
|
10594
|
+
});
|
|
10595
|
+
head = minted;
|
|
10596
|
+
}
|
|
10597
|
+
core.store.deletePresenterDraft(rec.id);
|
|
10598
|
+
removeUnreferenced(core, letGoOf(rec), hooks);
|
|
10599
|
+
return { presenter: head, brand: core.store.getBrand(rec.brandId) };
|
|
10600
|
+
}
|
|
10601
|
+
function readWords(a) {
|
|
10602
|
+
if (!a) return {};
|
|
10603
|
+
const out = {};
|
|
10604
|
+
const said = (v) => v != null && v !== "" && !(Array.isArray(v) && !v.length);
|
|
10605
|
+
if (said(a.promptName)) out.promptName = a.promptName;
|
|
10606
|
+
if (said(a.presentation)) out.presentation = a.presentation;
|
|
10607
|
+
if (said(a.descriptor)) out.descriptor = a.descriptor;
|
|
10608
|
+
if (said(a.ageRange)) out.ageRange = a.ageRange;
|
|
10609
|
+
if (said(a.hair)) out.hair = a.hair;
|
|
10610
|
+
if (said(a.negativeConstraints)) out.negativeConstraints = a.negativeConstraints;
|
|
10611
|
+
if (said(a.facial)) out.facial = a.facial;
|
|
10612
|
+
if (said(a.skin)) out.skin = a.skin;
|
|
10613
|
+
if (said(a.build)) out.build = a.build;
|
|
10614
|
+
return out;
|
|
10615
|
+
}
|
|
10616
|
+
async function stopPresenterDraft(deps, id) {
|
|
10617
|
+
const rec = getPresenterDraft(deps.core, id);
|
|
10618
|
+
if (!rec) throw fail("draft not found", 404);
|
|
10619
|
+
const job = running3.get(id);
|
|
10620
|
+
if (!job) return rec;
|
|
10621
|
+
job.ctrl.abort();
|
|
10622
|
+
for (let i = 0; i < 500 && running3.has(id); i++) await new Promise((r) => setTimeout(r, 10));
|
|
10623
|
+
return getPresenterDraft(deps.core, id) ?? rec;
|
|
10624
|
+
}
|
|
10625
|
+
async function discardPresenterDraft(deps, id, hooks = {}) {
|
|
10626
|
+
running3.get(id)?.ctrl.abort();
|
|
10627
|
+
running3.delete(id);
|
|
10628
|
+
const rec = getPresenterDraft(deps.core, id);
|
|
10629
|
+
if (!rec) return;
|
|
10630
|
+
dropDraft(deps.core, rec, hooks);
|
|
10631
|
+
}
|
|
10632
|
+
async function releasePresenter(deps, brandId, presenter, hooks = {}) {
|
|
10633
|
+
for (const d of listPresenterDrafts(deps.core, brandId)) {
|
|
10634
|
+
if (d.presenterId === presenter.id) await discardPresenterDraft(deps, d.id, hooks);
|
|
10635
|
+
}
|
|
10636
|
+
const held = [
|
|
10637
|
+
...(presenter.shots ?? []).map((s) => s?.file),
|
|
10638
|
+
...presenter.sourceRefs ?? [],
|
|
10639
|
+
presenter.preview,
|
|
10640
|
+
presenter.avatar
|
|
10641
|
+
];
|
|
10642
|
+
removeUnreferenced(
|
|
10643
|
+
deps.core,
|
|
10644
|
+
held.map(hashOfFile).filter((h) => !!h),
|
|
10645
|
+
hooks
|
|
10646
|
+
);
|
|
10647
|
+
}
|
|
10648
|
+
var ABANDONED_DRAFT_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
10649
|
+
function sweepAbandonedPresenterDrafts(core, hooks = {}, now = Date.now()) {
|
|
10650
|
+
let swept = 0;
|
|
10651
|
+
for (const brand of core.store.listBrands()) {
|
|
10652
|
+
for (const rec of listPresenterDrafts(core, brand.id)) {
|
|
10653
|
+
if (running3.has(rec.id)) continue;
|
|
10654
|
+
const touched = stampMs(rec.updatedAt);
|
|
10655
|
+
if (Number.isNaN(touched) || now - touched < ABANDONED_DRAFT_MS) continue;
|
|
10656
|
+
dropDraft(core, rec, hooks);
|
|
10657
|
+
swept += 1;
|
|
10658
|
+
}
|
|
10659
|
+
}
|
|
10660
|
+
return swept;
|
|
10661
|
+
}
|
|
10662
|
+
function stampMs(s) {
|
|
10663
|
+
const iso = s.includes("T") ? s : s.replace(" ", "T");
|
|
10664
|
+
return Date.parse(/Z$|[+-]\d\d:\d\d$/.test(iso) ? iso : `${iso}Z`);
|
|
10665
|
+
}
|
|
10666
|
+
function dropDraft(core, rec, hooks) {
|
|
10667
|
+
core.store.deletePresenterDraft(rec.id);
|
|
10668
|
+
const generated = PRESENTER_VIEWS.flatMap((v) => {
|
|
10669
|
+
const s = rec.views[v];
|
|
10670
|
+
return [...s.rejected, ...s.hash && s.origin === "generated" ? [s.hash] : [], ...s.prior ? [s.prior] : []];
|
|
10671
|
+
});
|
|
10672
|
+
removeUnreferenced(core, [...generated, ...rec.sources], hooks);
|
|
10673
|
+
}
|
|
10674
|
+
function removeUnreferenced(core, hashes, hooks) {
|
|
10675
|
+
const brands = core.store.listBrands().map((b) => JSON.stringify(b.json));
|
|
10676
|
+
for (const h of new Set(hashes)) {
|
|
10677
|
+
if (!HASH.test(h)) continue;
|
|
10678
|
+
if (core.store.imageReferenced(h)) continue;
|
|
10679
|
+
if (brands.some((b) => b.includes(h))) continue;
|
|
10680
|
+
if (core.images.remove(h)) hooks.evict?.(h);
|
|
10681
|
+
}
|
|
10682
|
+
}
|
|
10683
|
+
|
|
9087
10684
|
// src/routes/assetBuilds.ts
|
|
9088
|
-
function
|
|
10685
|
+
function makeBuildDeps(deps) {
|
|
9089
10686
|
const { core, engines, scenes, presenters } = deps;
|
|
9090
10687
|
const analyzer = deps.analyzer ?? createCodexAnalyzer({ runner: engines.codexRunner });
|
|
9091
|
-
const
|
|
9092
|
-
|
|
9093
|
-
const rank = (e) => e.capabilities().id === "codex-cli" ? 0 : 1;
|
|
9094
|
-
return rank(a) - rank(b);
|
|
9095
|
-
});
|
|
9096
|
-
for (const engine of ordered) {
|
|
9097
|
-
const caps = engine.capabilities();
|
|
9098
|
-
if (!caps.maxReferenceImages || caps.placeholder) continue;
|
|
9099
|
-
if ((await engine.isAvailable()).ok) return engine;
|
|
9100
|
-
}
|
|
9101
|
-
return null;
|
|
9102
|
-
};
|
|
10688
|
+
const allowPlaceholder = process.env.SCENRI_DEMO_BUILDS === "1";
|
|
10689
|
+
const buildEngine = () => pickBuildEngine(engines, { allowPlaceholder });
|
|
9103
10690
|
const buildDeps = async () => ({
|
|
9104
10691
|
core,
|
|
9105
10692
|
engine: await buildEngine(),
|
|
@@ -9109,8 +10696,17 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
9109
10696
|
// person can actually click rather than inventing a category of one.
|
|
9110
10697
|
vocabulary: { ...facetsOf(scenes), categories: presenterFacetsOf(presenters).categories }
|
|
9111
10698
|
});
|
|
10699
|
+
return { buildEngine, buildDeps, analyzer };
|
|
10700
|
+
}
|
|
10701
|
+
function registerAssetBuildRoutes(app, deps) {
|
|
10702
|
+
const { core } = deps;
|
|
10703
|
+
const { buildEngine, buildDeps, analyzer } = makeBuildDeps(deps);
|
|
10704
|
+
const hooks = { evict: (hash) => deps.thumbs?.evict(hash) };
|
|
9112
10705
|
app.get("/api/asset-builds/capabilities", async () => {
|
|
9113
|
-
const [engine, probe] = await Promise.all([
|
|
10706
|
+
const [engine, probe] = await Promise.all([
|
|
10707
|
+
buildEngine(),
|
|
10708
|
+
analyzer?.isAvailable() ?? Promise.resolve({ ok: false })
|
|
10709
|
+
]);
|
|
9114
10710
|
return {
|
|
9115
10711
|
canAnalyze: probe.ok,
|
|
9116
10712
|
analyzeReason: probe.ok ? null : probe.reason ?? null,
|
|
@@ -9140,8 +10736,9 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
9140
10736
|
if (!brand) return;
|
|
9141
10737
|
const body = req.body ?? {};
|
|
9142
10738
|
const kind = String(body.kind ?? "");
|
|
9143
|
-
if (kind
|
|
9144
|
-
return reply.status(400).send({ error: "
|
|
10739
|
+
if (kind === "presenter")
|
|
10740
|
+
return reply.status(400).send({ error: "presenters are cast in the studio now: POST /api/brands/:id/presenter-drafts" });
|
|
10741
|
+
if (kind !== "scene") return reply.status(400).send({ error: "kind must be scene" });
|
|
9145
10742
|
try {
|
|
9146
10743
|
return startAssetBuild(await buildDeps(), {
|
|
9147
10744
|
brandId: brand.id,
|
|
@@ -9215,6 +10812,32 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
9215
10812
|
}
|
|
9216
10813
|
return { presenter: built.presenter, brand: core.store.getBrand(brand.id) };
|
|
9217
10814
|
});
|
|
10815
|
+
app.post("/api/brands/:id/presenters/:presenterId/revert", async (req, reply) => {
|
|
10816
|
+
const brand = brandOr404(req, reply);
|
|
10817
|
+
if (!brand) return;
|
|
10818
|
+
const id = String(req.params.presenterId);
|
|
10819
|
+
const rows = brandCharacters(brand.json);
|
|
10820
|
+
const current = rows.find((c) => c.id === id);
|
|
10821
|
+
if (!current) return reply.status(404).send({ error: "presenter not found" });
|
|
10822
|
+
if (!isCustomPresenter(current)) return reply.status(400).send({ error: "this presenter is not editable" });
|
|
10823
|
+
const older = current.revisionOf && !current.supersededBy ? rows.find((c) => c.id === current.revisionOf) : void 0;
|
|
10824
|
+
if (!older) return reply.status(400).send({ error: "nothing to revert" });
|
|
10825
|
+
try {
|
|
10826
|
+
commit(core, brand.id, (json) => {
|
|
10827
|
+
json.characters = brandCharacters(json).map((c) => {
|
|
10828
|
+
if (c.id === older.id) {
|
|
10829
|
+
const { supersededBy: _cleared, ...head } = c;
|
|
10830
|
+
return head;
|
|
10831
|
+
}
|
|
10832
|
+
return c.id === current.id ? { ...c, supersededBy: older.id } : c;
|
|
10833
|
+
});
|
|
10834
|
+
});
|
|
10835
|
+
} catch (err) {
|
|
10836
|
+
return reply.status(err.statusCode ?? 500).send({ error: err.message });
|
|
10837
|
+
}
|
|
10838
|
+
const after = core.store.getBrand(brand.id);
|
|
10839
|
+
return { presenter: brandCharacters(after?.json).find((c) => c.id === older.id), brand: after };
|
|
10840
|
+
});
|
|
9218
10841
|
app.delete("/api/brands/:id/presenters/:presenterId", async (req, reply) => {
|
|
9219
10842
|
const brand = brandOr404(req, reply);
|
|
9220
10843
|
if (!brand) return;
|
|
@@ -9225,7 +10848,8 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
9225
10848
|
commit(core, brand.id, (json) => {
|
|
9226
10849
|
json.characters = brandCharacters(json).filter((c) => c.id !== id);
|
|
9227
10850
|
});
|
|
9228
|
-
|
|
10851
|
+
await releasePresenter(await buildDeps(), brand.id, base, hooks);
|
|
10852
|
+
return { ok: true, brand: core.store.getBrand(brand.id) };
|
|
9229
10853
|
});
|
|
9230
10854
|
app.post("/api/brands/:id/scenes", async (req, reply) => {
|
|
9231
10855
|
const brand = brandOr404(req, reply);
|
|
@@ -9338,14 +10962,191 @@ async function withDerivedCrops(core, body, base) {
|
|
|
9338
10962
|
const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
|
|
9339
10963
|
if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
|
|
9340
10964
|
const firstShot = `asset:${String(shots[0])}`;
|
|
9341
|
-
const
|
|
9342
|
-
const
|
|
10965
|
+
const sourceFiles = Array.isArray(body.sourceHashes) ? body.sourceHashes.map((h) => `asset:${String(h)}`) : (base?.sourceRefs ?? []).map((s) => s?.file);
|
|
10966
|
+
const angles = Array.isArray(body.shotAngles) ? body.shotAngles : [];
|
|
10967
|
+
const firstAngle = angles[0] ?? base?.shots?.find((s) => s?.file === firstShot)?.angle;
|
|
10968
|
+
const derived = await presenterCrops(core, String(shots[0]), presenterCropMode(firstShot, sourceFiles, firstAngle));
|
|
9343
10969
|
return {
|
|
9344
10970
|
...body,
|
|
9345
10971
|
...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
|
|
9346
10972
|
...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
|
|
9347
10973
|
};
|
|
9348
10974
|
}
|
|
10975
|
+
|
|
10976
|
+
// src/routes/presenterDrafts.ts
|
|
10977
|
+
function registerPresenterDraftRoutes(app, deps) {
|
|
10978
|
+
const { core, thumbs } = deps;
|
|
10979
|
+
const { buildDeps } = makeBuildDeps(deps);
|
|
10980
|
+
const hooks = { evict: (hash) => thumbs.evict(hash) };
|
|
10981
|
+
const brandOr404 = (req, reply) => {
|
|
10982
|
+
const brand = core.store.getBrand(String(req.params.id));
|
|
10983
|
+
if (!brand) {
|
|
10984
|
+
reply.status(404).send({ error: "brand not found" });
|
|
10985
|
+
return null;
|
|
10986
|
+
}
|
|
10987
|
+
return brand;
|
|
10988
|
+
};
|
|
10989
|
+
const draftOr404 = (req, reply) => {
|
|
10990
|
+
const brand = brandOr404(req, reply);
|
|
10991
|
+
if (!brand) return null;
|
|
10992
|
+
const draft = getPresenterDraft(core, String(req.params.draftId));
|
|
10993
|
+
if (!draft || draft.brandId !== brand.id) {
|
|
10994
|
+
reply.status(404).send({ error: "draft not found" });
|
|
10995
|
+
return null;
|
|
10996
|
+
}
|
|
10997
|
+
return draft;
|
|
10998
|
+
};
|
|
10999
|
+
const answer = async (reply, work) => {
|
|
11000
|
+
try {
|
|
11001
|
+
return await work();
|
|
11002
|
+
} catch (err) {
|
|
11003
|
+
return reply.status(err?.statusCode ?? 500).send({ error: err?.message ?? "something went wrong" });
|
|
11004
|
+
}
|
|
11005
|
+
};
|
|
11006
|
+
const viewOf = (req) => String(req.params.view);
|
|
11007
|
+
app.post("/api/brands/:id/presenter-drafts", async (req, reply) => {
|
|
11008
|
+
const brand = brandOr404(req, reply);
|
|
11009
|
+
if (!brand) return;
|
|
11010
|
+
const body = req.body ?? {};
|
|
11011
|
+
return answer(
|
|
11012
|
+
reply,
|
|
11013
|
+
async () => createPresenterDraft(await buildDeps(), {
|
|
11014
|
+
brandId: brand.id,
|
|
11015
|
+
source: body.source === "synthetic" ? "synthetic" : "photos",
|
|
11016
|
+
direction: body.direction == null ? void 0 : String(body.direction),
|
|
11017
|
+
keep: body.keep == null ? void 0 : String(body.keep),
|
|
11018
|
+
keepItems: body.keepItems,
|
|
11019
|
+
detailRefs: body.detailRefs,
|
|
11020
|
+
imageHashes: Array.isArray(body.imageHashes) ? body.imageHashes.map((h) => String(h)) : [],
|
|
11021
|
+
attestation: body.attestation === true,
|
|
11022
|
+
name: body.name == null ? void 0 : String(body.name),
|
|
11023
|
+
facets: Array.isArray(body.facets) ? body.facets.map((f) => String(f)) : [],
|
|
11024
|
+
extras: body.extras === true
|
|
11025
|
+
})
|
|
11026
|
+
);
|
|
11027
|
+
});
|
|
11028
|
+
app.get("/api/brands/:id/presenter-drafts", async (req, reply) => {
|
|
11029
|
+
const brand = brandOr404(req, reply);
|
|
11030
|
+
if (!brand) return;
|
|
11031
|
+
return { drafts: listPresenterDraftSummaries(core, brand.id) };
|
|
11032
|
+
});
|
|
11033
|
+
app.post("/api/brands/:id/presenters/:presenterId/edit", async (req, reply) => {
|
|
11034
|
+
const brand = brandOr404(req, reply);
|
|
11035
|
+
if (!brand) return;
|
|
11036
|
+
return answer(reply, async () => openPresenterEdit(core, brand.id, String(req.params.presenterId)));
|
|
11037
|
+
});
|
|
11038
|
+
app.get("/api/brands/:id/presenter-drafts/:draftId", async (req, reply) => {
|
|
11039
|
+
const draft = draftOr404(req, reply);
|
|
11040
|
+
if (!draft) return;
|
|
11041
|
+
return draft;
|
|
11042
|
+
});
|
|
11043
|
+
app.patch("/api/brands/:id/presenter-drafts/:draftId", async (req, reply) => {
|
|
11044
|
+
const draft = draftOr404(req, reply);
|
|
11045
|
+
if (!draft) return;
|
|
11046
|
+
const body = req.body ?? {};
|
|
11047
|
+
return answer(
|
|
11048
|
+
reply,
|
|
11049
|
+
() => updatePresenterDraft(core, draft.id, {
|
|
11050
|
+
name: body.name,
|
|
11051
|
+
facets: body.facets,
|
|
11052
|
+
direction: body.direction,
|
|
11053
|
+
keep: body.keep,
|
|
11054
|
+
keepItems: body.keepItems,
|
|
11055
|
+
setup: body.setup,
|
|
11056
|
+
detailRefs: body.detailRefs,
|
|
11057
|
+
extras: body.extras
|
|
11058
|
+
})
|
|
11059
|
+
);
|
|
11060
|
+
});
|
|
11061
|
+
app.get("/api/brands/:id/presenter-drafts/:draftId/views/:view/plan", async (req, reply) => {
|
|
11062
|
+
if (!process.env.SCENRI_DEBUG) return reply.status(404).send({ error: "not found" });
|
|
11063
|
+
const draft = draftOr404(req, reply);
|
|
11064
|
+
if (!draft) return;
|
|
11065
|
+
const view = viewOf(req);
|
|
11066
|
+
if (!PRESENTER_VIEWS.includes(view))
|
|
11067
|
+
return reply.status(400).send({ error: "no such view" });
|
|
11068
|
+
return answer(reply, async () => {
|
|
11069
|
+
const { engine } = await buildDeps();
|
|
11070
|
+
const cap2 = engine?.capabilities().maxReferenceImages ?? 0;
|
|
11071
|
+
const adjustment = req.query?.adjustment;
|
|
11072
|
+
const step = planStep(draft, view, adjustment == null ? void 0 : String(adjustment), cap2);
|
|
11073
|
+
return {
|
|
11074
|
+
view,
|
|
11075
|
+
cap: cap2,
|
|
11076
|
+
...step,
|
|
11077
|
+
held: {
|
|
11078
|
+
source: draft.source,
|
|
11079
|
+
direction: draft.direction ?? "",
|
|
11080
|
+
keep: draft.keep ?? "",
|
|
11081
|
+
detailRefs: draft.detailRefs ?? null,
|
|
11082
|
+
identityEdits: draft.identityEdits ?? [],
|
|
11083
|
+
analysis: draft.analysis ?? null,
|
|
11084
|
+
sources: draft.sources
|
|
11085
|
+
}
|
|
11086
|
+
};
|
|
11087
|
+
});
|
|
11088
|
+
});
|
|
11089
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/generate", async (req, reply) => {
|
|
11090
|
+
const draft = draftOr404(req, reply);
|
|
11091
|
+
if (!draft) return;
|
|
11092
|
+
const body = req.body ?? {};
|
|
11093
|
+
return answer(
|
|
11094
|
+
reply,
|
|
11095
|
+
async () => generateView(await buildDeps(), draft.id, viewOf(req), {
|
|
11096
|
+
adjustment: body.adjustment == null ? void 0 : String(body.adjustment),
|
|
11097
|
+
decide: body.decide === "auto" ? "auto" : void 0
|
|
11098
|
+
})
|
|
11099
|
+
);
|
|
11100
|
+
});
|
|
11101
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/approve", async (req, reply) => {
|
|
11102
|
+
const draft = draftOr404(req, reply);
|
|
11103
|
+
if (!draft) return;
|
|
11104
|
+
return answer(reply, async () => approveView(await buildDeps(), draft.id, viewOf(req)));
|
|
11105
|
+
});
|
|
11106
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/redo", async (req, reply) => {
|
|
11107
|
+
const draft = draftOr404(req, reply);
|
|
11108
|
+
if (!draft) return;
|
|
11109
|
+
return answer(reply, async () => redoView(await buildDeps(), draft.id, viewOf(req)));
|
|
11110
|
+
});
|
|
11111
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/revert", async (req, reply) => {
|
|
11112
|
+
const draft = draftOr404(req, reply);
|
|
11113
|
+
if (!draft) return;
|
|
11114
|
+
return answer(reply, async () => revertView(await buildDeps(), draft.id, viewOf(req)));
|
|
11115
|
+
});
|
|
11116
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/restore", async (req, reply) => {
|
|
11117
|
+
const draft = draftOr404(req, reply);
|
|
11118
|
+
if (!draft) return;
|
|
11119
|
+
const hash = String(req.body?.hash ?? "");
|
|
11120
|
+
return answer(reply, async () => restoreView(await buildDeps(), draft.id, viewOf(req), hash));
|
|
11121
|
+
});
|
|
11122
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/views/:view/use-photo", async (req, reply) => {
|
|
11123
|
+
const draft = draftOr404(req, reply);
|
|
11124
|
+
if (!draft) return;
|
|
11125
|
+
const body = req.body ?? {};
|
|
11126
|
+
return answer(
|
|
11127
|
+
reply,
|
|
11128
|
+
async () => usePhotoForView(await buildDeps(), draft.id, viewOf(req), String(body.hash ?? ""))
|
|
11129
|
+
);
|
|
11130
|
+
});
|
|
11131
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/save", async (req, reply) => {
|
|
11132
|
+
const draft = draftOr404(req, reply);
|
|
11133
|
+
if (!draft) return;
|
|
11134
|
+
return answer(reply, async () => savePresenterDraft(await buildDeps(), draft.id, hooks));
|
|
11135
|
+
});
|
|
11136
|
+
app.post("/api/brands/:id/presenter-drafts/:draftId/stop", async (req, reply) => {
|
|
11137
|
+
const draft = draftOr404(req, reply);
|
|
11138
|
+
if (!draft) return;
|
|
11139
|
+
return answer(reply, async () => stopPresenterDraft(await buildDeps(), draft.id));
|
|
11140
|
+
});
|
|
11141
|
+
app.delete("/api/brands/:id/presenter-drafts/:draftId", async (req, reply) => {
|
|
11142
|
+
const draft = draftOr404(req, reply);
|
|
11143
|
+
if (!draft) return;
|
|
11144
|
+
return answer(reply, async () => {
|
|
11145
|
+
await discardPresenterDraft(await buildDeps(), draft.id, hooks);
|
|
11146
|
+
return { ok: true };
|
|
11147
|
+
});
|
|
11148
|
+
});
|
|
11149
|
+
}
|
|
9349
11150
|
function registerDemoProductRoutes(app, deps) {
|
|
9350
11151
|
const { templatesRoot, demoProducts, demoProductById, thumbs } = deps;
|
|
9351
11152
|
const demoProductThumbPath = (id) => {
|
|
@@ -9824,13 +11625,12 @@ function registerImageRoutes(app, deps) {
|
|
|
9824
11625
|
return reply.send(thumbs.stream(path));
|
|
9825
11626
|
});
|
|
9826
11627
|
app.post("/api/images", async (req, reply) => {
|
|
9827
|
-
const part = await req
|
|
9828
|
-
|
|
9829
|
-
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
|
|
9833
|
-
return { hash: core.images.save(png) };
|
|
11628
|
+
const part = await readImagePart(core, req, async (buf) => {
|
|
11629
|
+
const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
|
|
11630
|
+
return fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
|
|
11631
|
+
});
|
|
11632
|
+
if ("error" in part) return reply.status(400).send({ error: part.error });
|
|
11633
|
+
return { hash: part.hash };
|
|
9834
11634
|
});
|
|
9835
11635
|
app.post("/api/diff", async (req, reply) => {
|
|
9836
11636
|
const { imageA, imageB } = req.body;
|
|
@@ -9851,6 +11651,21 @@ function registerImageRoutes(app, deps) {
|
|
|
9851
11651
|
|
|
9852
11652
|
// src/release/notes.data.ts
|
|
9853
11653
|
var RELEASES = [
|
|
11654
|
+
{
|
|
11655
|
+
version: "0.10.0",
|
|
11656
|
+
date: "2026-09-16",
|
|
11657
|
+
title: "Presenters are cast by talking to Scenri, one question at a time.",
|
|
11658
|
+
sections: [
|
|
11659
|
+
{
|
|
11660
|
+
heading: "Presenters",
|
|
11661
|
+
body: "Casting a presenter is a conversation: describe someone or add a few photos, and their portrait arrives first, then each further view as you go. Editing a saved presenter opens that same conversation, seeded from what is already there, and every change becomes a new version you can step back through. An unfinished presenter is saved as a draft and offered back the next time you open Presenters, and deleting one clears it everywhere it was used."
|
|
11662
|
+
},
|
|
11663
|
+
{
|
|
11664
|
+
heading: "Products",
|
|
11665
|
+
body: "Importing a store's products shows its progress in the library as it happens, carrying the brand's own mark and a running clock. A site that is rate-limiting requests is reported as such instead of looking like an empty import, and a run that stops partway keeps what it already found rather than losing it."
|
|
11666
|
+
}
|
|
11667
|
+
]
|
|
11668
|
+
},
|
|
9854
11669
|
{
|
|
9855
11670
|
version: "0.9.4",
|
|
9856
11671
|
date: "2026-09-14",
|
|
@@ -10859,7 +12674,7 @@ function registerDesktopRoutes(app, deps) {
|
|
|
10859
12674
|
record: null
|
|
10860
12675
|
};
|
|
10861
12676
|
}
|
|
10862
|
-
const { installDeps } = await import('./cli-
|
|
12677
|
+
const { installDeps } = await import('./cli-EXASA5BK.js');
|
|
10863
12678
|
const { desktopStatus } = await import('./install-VXJPLYWM.js');
|
|
10864
12679
|
return desktopStatus(installDeps(runtime.entry));
|
|
10865
12680
|
});
|
|
@@ -10867,7 +12682,7 @@ function registerDesktopRoutes(app, deps) {
|
|
|
10867
12682
|
if (!runtime.entry) {
|
|
10868
12683
|
return { ok: false, reason: "unsupported", message: "Desktop shortcuts are not available on this system yet." };
|
|
10869
12684
|
}
|
|
10870
|
-
const { addToDesktop } = await import('./cli-
|
|
12685
|
+
const { addToDesktop } = await import('./cli-EXASA5BK.js');
|
|
10871
12686
|
return addToDesktop(runtime.entry);
|
|
10872
12687
|
});
|
|
10873
12688
|
app.get("/api/desktop", async () => {
|
|
@@ -11153,7 +12968,10 @@ function buildServer(opts) {
|
|
|
11153
12968
|
const presentersDir = join(templatesRoot, "presenters");
|
|
11154
12969
|
const { presenters } = loadPresenters(presentersDir);
|
|
11155
12970
|
registerPresenterRoutes(app, { templatesRoot, presenters, thumbs });
|
|
11156
|
-
registerAssetBuildRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters });
|
|
12971
|
+
registerAssetBuildRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters, thumbs });
|
|
12972
|
+
sweepPresenterDrafts(core);
|
|
12973
|
+
sweepAbandonedPresenterDrafts(core, { evict: (hash) => thumbs.evict(hash) });
|
|
12974
|
+
registerPresenterDraftRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters, thumbs });
|
|
11157
12975
|
const { demoProducts } = loadDemoProducts(join(templatesRoot, "demo-products"));
|
|
11158
12976
|
const demoProductById = demoProductResolver(demoProducts);
|
|
11159
12977
|
registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById, thumbs });
|
|
@@ -12287,7 +14105,7 @@ function buildServer(opts) {
|
|
|
12287
14105
|
// one physical run counts once, however many sibling nodes share its
|
|
12288
14106
|
// controller — an update gate held open by a 4-shot batch is still held
|
|
12289
14107
|
// open by exactly one piece of work
|
|
12290
|
-
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
|
|
14108
|
+
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
|
|
12291
14109
|
});
|
|
12292
14110
|
let drained = null;
|
|
12293
14111
|
app.decorate("drain", () => {
|
|
@@ -12309,7 +14127,7 @@ function buildServer(opts) {
|
|
|
12309
14127
|
core,
|
|
12310
14128
|
runtime,
|
|
12311
14129
|
exitImpl: opts.exitImpl,
|
|
12312
|
-
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
|
|
14130
|
+
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
|
|
12313
14131
|
});
|
|
12314
14132
|
if (opts.studioDist && existsSync(opts.studioDist)) {
|
|
12315
14133
|
const dist = opts.studioDist;
|
|
@@ -12354,6 +14172,7 @@ async function run() {
|
|
|
12354
14172
|
const core = createCore();
|
|
12355
14173
|
const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
|
|
12356
14174
|
const engines = createEngineRegistry(core, stubs);
|
|
14175
|
+
const analyzer = process.env.SCENRI_DEMO_ANALYSIS === "usable" || process.env.SCENRI_DEMO_ANALYSIS === "unusable" ? createDemoAnalyzer({ photos: process.env.SCENRI_DEMO_ANALYSIS }) : void 0;
|
|
12357
14176
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
12358
14177
|
const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];
|
|
12359
14178
|
const studioDist = candidates.find((p) => existsSync(p));
|
|
@@ -12365,6 +14184,7 @@ async function run() {
|
|
|
12365
14184
|
const app = buildServer({
|
|
12366
14185
|
core,
|
|
12367
14186
|
engines,
|
|
14187
|
+
analyzer,
|
|
12368
14188
|
studioDist,
|
|
12369
14189
|
access: { allowedHosts: reachableAt, token },
|
|
12370
14190
|
runtime: {
|
|
@@ -12470,7 +14290,7 @@ async function run() {
|
|
|
12470
14290
|
}
|
|
12471
14291
|
}
|
|
12472
14292
|
const ownEntry = fileURLToPath(import.meta.url);
|
|
12473
|
-
const { addToDesktop, installDeps } = await import('./cli-
|
|
14293
|
+
const { addToDesktop, installDeps } = await import('./cli-EXASA5BK.js');
|
|
12474
14294
|
const { refreshLauncher } = await import('./refresh-5HBDLLLB.js');
|
|
12475
14295
|
const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-W6NJXMWT.js');
|
|
12476
14296
|
const { launcherInstalled } = await import('./paths-6ZXRZUHY.js');
|