scenri 0.9.3 → 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 +72 -0
- package/dist/{chunk-Y5L3KLX5.js → chunk-WORM6GB3.js} +139 -7
- package/dist/{cli-SX46UQO6.js → cli-EXASA5BK.js} +3 -3
- package/dist/index.js +2 -2
- package/dist/serve.js +2289 -337
- package/dist/{src-F3KJZNZT.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-eKmAemHO.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
|
-
|
|
4417
|
-
|
|
4642
|
+
throw refused();
|
|
4643
|
+
}
|
|
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
|
+
}
|
|
4418
4654
|
}
|
|
4419
|
-
throw
|
|
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() : "";
|
|
@@ -4887,6 +5222,67 @@ async function inspectMark(buf, toPng2) {
|
|
|
4887
5222
|
};
|
|
4888
5223
|
}
|
|
4889
5224
|
|
|
5225
|
+
// ../catalog/src/failures.ts
|
|
5226
|
+
function pageFailure(status, challenged = false) {
|
|
5227
|
+
if (challenged) return "CHALLENGED";
|
|
5228
|
+
if (status === 429 || status === 503) return "RATE_LIMITED";
|
|
5229
|
+
if (status === 401 || status === 403 || status === 405) return "PAGE_BLOCKED";
|
|
5230
|
+
if (status === 404 || status === 410) return "PAGE_NOT_FOUND";
|
|
5231
|
+
return "PAGE_UNREADABLE";
|
|
5232
|
+
}
|
|
5233
|
+
function imageFailure(status) {
|
|
5234
|
+
if (status === 429 || status === 503) return "RATE_LIMITED";
|
|
5235
|
+
if (status === 401 || status === 403) return "IMAGE_FORBIDDEN";
|
|
5236
|
+
if (status === 404 || status === 410) return "IMAGE_NOT_FOUND";
|
|
5237
|
+
return "DOWNLOAD_FAILED";
|
|
5238
|
+
}
|
|
5239
|
+
function thrownFailure(err, aborted = false) {
|
|
5240
|
+
if (aborted) return "ABORTED";
|
|
5241
|
+
const m = String(err?.message ?? err).toLowerCase();
|
|
5242
|
+
if (m.includes("abort") || m.includes("timed out") || m.includes("timeout")) return "IMAGE_TIMEOUT";
|
|
5243
|
+
return "DOWNLOAD_FAILED";
|
|
5244
|
+
}
|
|
5245
|
+
function tally(into, reason, n = 1) {
|
|
5246
|
+
into[reason] = (into[reason] ?? 0) + n;
|
|
5247
|
+
}
|
|
5248
|
+
function leadingReason(t) {
|
|
5249
|
+
const rows = Object.entries(t).filter(([, n]) => n > 0);
|
|
5250
|
+
if (!rows.length) return null;
|
|
5251
|
+
return rows.sort((a, b) => b[1] - a[1])[0][0];
|
|
5252
|
+
}
|
|
5253
|
+
function shortfall(asked, worked) {
|
|
5254
|
+
return Math.max(0, asked - Math.max(0, worked));
|
|
5255
|
+
}
|
|
5256
|
+
function summarise(t, saved, asked, failedCount) {
|
|
5257
|
+
const reason = leadingReason(t);
|
|
5258
|
+
if (!reason) return null;
|
|
5259
|
+
const none = saved === 0;
|
|
5260
|
+
const failed = Math.min(asked, failedCount ?? Math.max(0, asked - saved));
|
|
5261
|
+
switch (reason) {
|
|
5262
|
+
case "RATE_LIMITED":
|
|
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.`;
|
|
5268
|
+
case "PAGE_BLOCKED":
|
|
5269
|
+
return none ? "This store would not let us read its product pages." : `${failed} of ${asked} product pages would not open.`;
|
|
5270
|
+
case "PAGE_NOT_FOUND":
|
|
5271
|
+
return `${failed} of ${asked} products are no longer on the site.`;
|
|
5272
|
+
case "IMAGE_FORBIDDEN":
|
|
5273
|
+
case "IMAGE_NOT_FOUND":
|
|
5274
|
+
case "IMAGE_TIMEOUT":
|
|
5275
|
+
case "IMAGE_EMPTY":
|
|
5276
|
+
case "UNSUPPORTED_MEDIA":
|
|
5277
|
+
case "DOWNLOAD_FAILED":
|
|
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.";
|
|
5279
|
+
case "ABORTED":
|
|
5280
|
+
return null;
|
|
5281
|
+
default:
|
|
5282
|
+
return none ? "The products on this site could not be read." : null;
|
|
5283
|
+
}
|
|
5284
|
+
}
|
|
5285
|
+
|
|
4890
5286
|
// ../catalog/src/url.ts
|
|
4891
5287
|
function normalizeStoreUrl(input) {
|
|
4892
5288
|
let raw = input.trim();
|
|
@@ -4919,6 +5315,19 @@ function absolutize(base, href) {
|
|
|
4919
5315
|
return null;
|
|
4920
5316
|
}
|
|
4921
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
|
+
}
|
|
4922
5331
|
function upgradeImageUrl(url) {
|
|
4923
5332
|
try {
|
|
4924
5333
|
const u = new URL(url);
|
|
@@ -5102,7 +5511,7 @@ async function readBounded2(res, maxBytes) {
|
|
|
5102
5511
|
}
|
|
5103
5512
|
return out + decoder.decode();
|
|
5104
5513
|
}
|
|
5105
|
-
function
|
|
5514
|
+
function sleep5(ms) {
|
|
5106
5515
|
return new Promise((r) => setTimeout(r, ms));
|
|
5107
5516
|
}
|
|
5108
5517
|
async function httpGet(url, opts = {}) {
|
|
@@ -5110,7 +5519,10 @@ async function httpGet(url, opts = {}) {
|
|
|
5110
5519
|
const retries = opts.retries ?? 3;
|
|
5111
5520
|
const timeoutMs = opts.timeoutMs ?? 25e3;
|
|
5112
5521
|
let lastErr;
|
|
5522
|
+
const host = hostOf(url);
|
|
5113
5523
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
5524
|
+
await waitForHost(host, opts.signal);
|
|
5525
|
+
if (opts.signal?.aborted) throw new Error("aborted");
|
|
5114
5526
|
const ctrl = new AbortController();
|
|
5115
5527
|
const onAbort = () => ctrl.abort();
|
|
5116
5528
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -5142,8 +5554,20 @@ async function httpGet(url, opts = {}) {
|
|
|
5142
5554
|
} catch {
|
|
5143
5555
|
}
|
|
5144
5556
|
}
|
|
5145
|
-
if ((res
|
|
5146
|
-
|
|
5557
|
+
if (isChallenge(res)) return res;
|
|
5558
|
+
if (res.status === 429 || res.status === 503) {
|
|
5559
|
+
const asked = retryAfterMs(res.headers.get("retry-after"));
|
|
5560
|
+
const wait = asked ?? throttleBackoff(attempt);
|
|
5561
|
+
coolHost(host, wait);
|
|
5562
|
+
if (attempt < retries) {
|
|
5563
|
+
await res.body?.cancel().catch(() => {
|
|
5564
|
+
});
|
|
5565
|
+
continue;
|
|
5566
|
+
}
|
|
5567
|
+
return res;
|
|
5568
|
+
}
|
|
5569
|
+
if (res.status >= 500 && attempt < retries) {
|
|
5570
|
+
await sleep5(throttleBackoff(attempt));
|
|
5147
5571
|
continue;
|
|
5148
5572
|
}
|
|
5149
5573
|
return res;
|
|
@@ -5151,7 +5575,7 @@ async function httpGet(url, opts = {}) {
|
|
|
5151
5575
|
lastErr = err;
|
|
5152
5576
|
if (opts.signal?.aborted) throw err;
|
|
5153
5577
|
if (attempt < retries) {
|
|
5154
|
-
await
|
|
5578
|
+
await sleep5(400 * 2 ** attempt);
|
|
5155
5579
|
continue;
|
|
5156
5580
|
}
|
|
5157
5581
|
throw err;
|
|
@@ -5164,12 +5588,14 @@ async function httpGet(url, opts = {}) {
|
|
|
5164
5588
|
}
|
|
5165
5589
|
async function httpText(url, opts = {}) {
|
|
5166
5590
|
const res = await httpGet(url, opts);
|
|
5591
|
+
const challenged = isChallenge(res);
|
|
5167
5592
|
const text = await readBounded2(res, opts.maxBytes);
|
|
5168
|
-
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 };
|
|
5169
5594
|
}
|
|
5170
5595
|
async function httpJson(url, opts = {}) {
|
|
5171
5596
|
const res = await httpGet(url, { ...opts, accept: opts.accept ?? "application/json" });
|
|
5172
|
-
const
|
|
5597
|
+
const challenged = isChallenge(res);
|
|
5598
|
+
const text = await readBounded2(res, opts.maxBytes ?? 16e6);
|
|
5173
5599
|
let json = null;
|
|
5174
5600
|
if (res.ok) {
|
|
5175
5601
|
try {
|
|
@@ -5178,7 +5604,10 @@ async function httpJson(url, opts = {}) {
|
|
|
5178
5604
|
json = null;
|
|
5179
5605
|
}
|
|
5180
5606
|
}
|
|
5181
|
-
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;
|
|
5182
5611
|
}
|
|
5183
5612
|
async function mapPool(items, concurrency, fn, signal) {
|
|
5184
5613
|
const results = new Array(items.length);
|
|
@@ -5335,6 +5764,16 @@ var PRODUCT_MARKERS = [
|
|
|
5335
5764
|
var BUY_WORDS = /add to (cart|bag|basket)|buy now|add to my bag/i;
|
|
5336
5765
|
var BUILD_ASSET = /\/_next\/static\/|\/static\/media\/|\/assets\/(icons|flags|ui)\//i;
|
|
5337
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
|
+
}
|
|
5338
5777
|
function looksLikeProduct(html, doc) {
|
|
5339
5778
|
const $ = doc ?? loadHtml(html);
|
|
5340
5779
|
for (const sel of PRODUCT_MARKERS) {
|
|
@@ -5351,17 +5790,27 @@ function looksLikeProduct(html, doc) {
|
|
|
5351
5790
|
}
|
|
5352
5791
|
function galleryImages($, pageUrl, cap2 = 12) {
|
|
5353
5792
|
const images = [];
|
|
5793
|
+
const taken = /* @__PURE__ */ new Set();
|
|
5354
5794
|
const og = attr($.querySelector('meta[property="og:image"]'), "content");
|
|
5355
5795
|
if (og) {
|
|
5356
5796
|
const abs = absolutize(pageUrl, og);
|
|
5357
|
-
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
|
+
}
|
|
5358
5801
|
}
|
|
5359
5802
|
for (const el of $.querySelectorAll("img[src]")) {
|
|
5360
5803
|
if (images.length >= cap2) break;
|
|
5361
5804
|
const src = attr(el, "src") || attr(el, "data-src");
|
|
5362
5805
|
const abs = src ? absolutize(pageUrl, src) : null;
|
|
5363
5806
|
if (!abs || NOT_A_PACKSHOT.test(abs) || BUILD_ASSET.test(abs)) continue;
|
|
5364
|
-
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);
|
|
5365
5814
|
images.push({ url: abs, position: images.length, width: null, height: null, alt: attr(el, "alt") ?? null });
|
|
5366
5815
|
}
|
|
5367
5816
|
return images;
|
|
@@ -5412,11 +5861,14 @@ function productsFromPage(html, url) {
|
|
|
5412
5861
|
const one = parseProductHtml(html, url, doc);
|
|
5413
5862
|
return one ? [one] : [];
|
|
5414
5863
|
}
|
|
5415
|
-
var
|
|
5864
|
+
var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5416
5865
|
async function fetchProductPages(ctx, urls, opts = {}) {
|
|
5417
5866
|
const out = [];
|
|
5418
5867
|
const seen = /* @__PURE__ */ new Set();
|
|
5419
5868
|
let kept = 0;
|
|
5869
|
+
let refusedRun = 0;
|
|
5870
|
+
const giveUpAt = opts.giveUpAfterRefusals ?? Number.POSITIVE_INFINITY;
|
|
5871
|
+
let givenUp = false;
|
|
5420
5872
|
const want = opts.want ?? Number.POSITIVE_INFINITY;
|
|
5421
5873
|
const ceiling = opts.maxPages ?? (Number.isFinite(want) ? want * 3 : urls.length);
|
|
5422
5874
|
const take = urls.slice(0, ceiling);
|
|
@@ -5425,12 +5877,12 @@ async function fetchProductPages(ctx, urls, opts = {}) {
|
|
|
5425
5877
|
take,
|
|
5426
5878
|
opts.delayMs ? 1 : opts.concurrency ?? 5,
|
|
5427
5879
|
async (u) => {
|
|
5428
|
-
if (kept >= want) return;
|
|
5880
|
+
if (kept >= want || givenUp) return;
|
|
5429
5881
|
if (opts.deadline != null && Date.now() > opts.deadline) return;
|
|
5430
5882
|
if (opts.maxTotalBytes != null && bytes >= opts.maxTotalBytes) return;
|
|
5431
5883
|
try {
|
|
5432
|
-
if (opts.delayMs) await
|
|
5433
|
-
const { ok, text, url } = await httpText(u, {
|
|
5884
|
+
if (opts.delayMs) await sleep6(opts.delayMs);
|
|
5885
|
+
const { ok, status, text, url, challenged } = await httpText(u, {
|
|
5434
5886
|
fetchImpl: ctx.fetchImpl,
|
|
5435
5887
|
signal: ctx.signal,
|
|
5436
5888
|
accept: "text/html",
|
|
@@ -5442,9 +5894,14 @@ async function fetchProductPages(ctx, urls, opts = {}) {
|
|
|
5442
5894
|
opts.stats.bytes = bytes;
|
|
5443
5895
|
}
|
|
5444
5896
|
if (!ok) {
|
|
5445
|
-
if (opts.stats)
|
|
5897
|
+
if (opts.stats) {
|
|
5898
|
+
opts.stats.refused = (opts.stats.refused ?? 0) + 1;
|
|
5899
|
+
if (opts.stats.reasons) tally(opts.stats.reasons, pageFailure(status, challenged));
|
|
5900
|
+
}
|
|
5901
|
+
if (++refusedRun >= giveUpAt) givenUp = true;
|
|
5446
5902
|
return;
|
|
5447
5903
|
}
|
|
5904
|
+
refusedRun = 0;
|
|
5448
5905
|
for (const p of productsFromPage(text, url)) {
|
|
5449
5906
|
if (seen.has(p.externalKey)) continue;
|
|
5450
5907
|
seen.add(p.externalKey);
|
|
@@ -5453,8 +5910,12 @@ async function fetchProductPages(ctx, urls, opts = {}) {
|
|
|
5453
5910
|
else out.push(p);
|
|
5454
5911
|
}
|
|
5455
5912
|
opts.onProduct?.(kept);
|
|
5456
|
-
} catch {
|
|
5457
|
-
if (opts.stats)
|
|
5913
|
+
} catch (err) {
|
|
5914
|
+
if (opts.stats) {
|
|
5915
|
+
opts.stats.refused = (opts.stats.refused ?? 0) + 1;
|
|
5916
|
+
if (opts.stats.reasons) tally(opts.stats.reasons, thrownFailure(err, ctx.signal?.aborted));
|
|
5917
|
+
}
|
|
5918
|
+
if (!ctx.signal?.aborted && ++refusedRun >= giveUpAt) givenUp = true;
|
|
5458
5919
|
}
|
|
5459
5920
|
},
|
|
5460
5921
|
ctx.signal
|
|
@@ -5521,14 +5982,28 @@ async function fetchProductsJsonPage(ctx, page, limit = 250) {
|
|
|
5521
5982
|
if (ok && json?.products) return { products: json.products, blocked: false };
|
|
5522
5983
|
return { products: [], blocked: status === 401 || status === 403 || status === 404 || status >= 500 };
|
|
5523
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
|
+
}
|
|
5524
5997
|
var JSON_BLOCKED = "json-blocked";
|
|
5998
|
+
var JSON_GIVE_UP = 3;
|
|
5999
|
+
var MAX_SITEMAPS = 40;
|
|
5525
6000
|
async function collectSitemapProductUrls(ctx) {
|
|
5526
6001
|
const origin = originOf(ctx.baseUrl);
|
|
5527
6002
|
const urls = /* @__PURE__ */ new Set();
|
|
5528
6003
|
const candidates = [`${origin}/sitemap.xml`, `${origin}/sitemap_products_1.xml`];
|
|
5529
6004
|
const queue = [...candidates];
|
|
5530
6005
|
const seen = /* @__PURE__ */ new Set();
|
|
5531
|
-
while (queue.length) {
|
|
6006
|
+
while (queue.length && seen.size < MAX_SITEMAPS && !outOfTime(ctx.deadline)) {
|
|
5532
6007
|
const next = queue.shift();
|
|
5533
6008
|
if (seen.has(next)) continue;
|
|
5534
6009
|
seen.add(next);
|
|
@@ -5582,11 +6057,16 @@ var shopifyAdapter = {
|
|
|
5582
6057
|
const hints = [];
|
|
5583
6058
|
const keys = /* @__PURE__ */ new Set();
|
|
5584
6059
|
const productUrls = /* @__PURE__ */ new Set();
|
|
6060
|
+
const cards = [];
|
|
5585
6061
|
const origin = originOf(ctx.baseUrl);
|
|
5586
6062
|
let page = 1;
|
|
5587
6063
|
let emptyStreak = 0;
|
|
5588
6064
|
while (emptyStreak < 1) {
|
|
5589
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
|
+
}
|
|
5590
6070
|
const { products, blocked: blocked2 } = await fetchProductsJsonPage(ctx, page);
|
|
5591
6071
|
ctx.onProgress?.({ stage: "discovering", discovered: keys.size, message: `Shopify page ${page}` });
|
|
5592
6072
|
if (blocked2 && page === 1) {
|
|
@@ -5600,6 +6080,7 @@ var shopifyAdapter = {
|
|
|
5600
6080
|
for (const p of products) {
|
|
5601
6081
|
keys.add(String(p.id));
|
|
5602
6082
|
if (p.handle) productUrls.add(`${origin}/products/${p.handle}`);
|
|
6083
|
+
cards.push(cardOf(origin, p));
|
|
5603
6084
|
}
|
|
5604
6085
|
if (products.length < 250) break;
|
|
5605
6086
|
page++;
|
|
@@ -5620,6 +6101,7 @@ var shopifyAdapter = {
|
|
|
5620
6101
|
return {
|
|
5621
6102
|
productKeys: [...keys],
|
|
5622
6103
|
productUrls: [...productUrls],
|
|
6104
|
+
cards,
|
|
5623
6105
|
estimatedTotal: keys.size || productUrls.size || null,
|
|
5624
6106
|
// A store whose own product API answered but refused us leaves nothing
|
|
5625
6107
|
// to read but the pages themselves, one request each. That is what
|
|
@@ -5629,6 +6111,43 @@ var shopifyAdapter = {
|
|
|
5629
6111
|
hints
|
|
5630
6112
|
};
|
|
5631
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
|
+
},
|
|
5632
6151
|
async fetchAll(ctx, discovered) {
|
|
5633
6152
|
const origin = originOf(ctx.baseUrl);
|
|
5634
6153
|
const out = [];
|
|
@@ -5662,20 +6181,29 @@ var shopifyAdapter = {
|
|
|
5662
6181
|
const handle = handleOf(u);
|
|
5663
6182
|
return handle && !out.some((p) => p.handle === handle);
|
|
5664
6183
|
});
|
|
5665
|
-
|
|
6184
|
+
const bulkCoveredAll = out.length > 0 && out.length >= discovered.productKeys.length;
|
|
6185
|
+
if (!jsonBlocked && !bulkCoveredAll) {
|
|
5666
6186
|
const missingUrls = stillMissing();
|
|
5667
6187
|
if (missingUrls.length) {
|
|
6188
|
+
let refusedRun = 0;
|
|
6189
|
+
let abandoned = false;
|
|
5668
6190
|
await mapPool(
|
|
5669
6191
|
missingUrls,
|
|
5670
|
-
|
|
6192
|
+
4,
|
|
5671
6193
|
async (u) => {
|
|
6194
|
+
if (abandoned) return;
|
|
5672
6195
|
const handle = handleOf(u);
|
|
5673
6196
|
if (!handle) return;
|
|
5674
|
-
const { ok, json } = await httpJson(`${origin}/products/${handle}.json`, {
|
|
6197
|
+
const { ok, json, challenged } = await httpJson(`${origin}/products/${handle}.json`, {
|
|
5675
6198
|
fetchImpl: ctx.fetchImpl,
|
|
5676
6199
|
signal: ctx.signal
|
|
5677
6200
|
});
|
|
5678
|
-
if (ok
|
|
6201
|
+
if (!ok) {
|
|
6202
|
+
if (challenged || ++refusedRun >= JSON_GIVE_UP) abandoned = true;
|
|
6203
|
+
return;
|
|
6204
|
+
}
|
|
6205
|
+
refusedRun = 0;
|
|
6206
|
+
if (json?.product) {
|
|
5679
6207
|
const mapped = mapShopifyProduct(origin, json.product);
|
|
5680
6208
|
if (!seen.has(mapped.externalKey)) {
|
|
5681
6209
|
seen.add(mapped.externalKey);
|
|
@@ -5733,7 +6261,7 @@ async function extractSitemapUrls(ctx, filter = () => true) {
|
|
|
5733
6261
|
}
|
|
5734
6262
|
if (filter(loc)) out.add(loc.split("?")[0]);
|
|
5735
6263
|
}
|
|
5736
|
-
if (seen.size > 200) break;
|
|
6264
|
+
if (seen.size > 200 || outOfTime(ctx.deadline)) break;
|
|
5737
6265
|
}
|
|
5738
6266
|
return preferCanonicalLocale([...out]);
|
|
5739
6267
|
}
|
|
@@ -5772,7 +6300,7 @@ async function crawlListingPages(ctx) {
|
|
|
5772
6300
|
const found = /* @__PURE__ */ new Set();
|
|
5773
6301
|
const visited = /* @__PURE__ */ new Set();
|
|
5774
6302
|
const queue = [origin, `${origin}/collections/all`, `${origin}/shop`, `${origin}/products`, `${origin}/catalog`];
|
|
5775
|
-
while (queue.length && visited.size < 40) {
|
|
6303
|
+
while (queue.length && visited.size < 40 && !outOfTime(ctx.deadline)) {
|
|
5776
6304
|
const page = queue.shift();
|
|
5777
6305
|
if (visited.has(page)) continue;
|
|
5778
6306
|
visited.add(page);
|
|
@@ -5928,6 +6456,11 @@ var woocommerceAdapter = {
|
|
|
5928
6456
|
let page = 1;
|
|
5929
6457
|
let usedApi = false;
|
|
5930
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
|
+
}
|
|
5931
6464
|
const batch = await tryStoreApi(ctx, page, 100);
|
|
5932
6465
|
if (batch === null) break;
|
|
5933
6466
|
usedApi = true;
|
|
@@ -6287,6 +6820,7 @@ var DEFAULT_SCAN_BUDGET = {
|
|
|
6287
6820
|
*/
|
|
6288
6821
|
previewFloorMs: 12e3
|
|
6289
6822
|
};
|
|
6823
|
+
var DISCOVERY_FLOOR_MS = 8e3;
|
|
6290
6824
|
var COMMERCE_SIGNALS = /* @__PURE__ */ new Set([
|
|
6291
6825
|
"products.json",
|
|
6292
6826
|
"shopify-html",
|
|
@@ -6317,12 +6851,14 @@ async function scanOnce(opts, baseUrl) {
|
|
|
6317
6851
|
const started = Date.now();
|
|
6318
6852
|
const budget = { ...DEFAULT_SCAN_BUDGET, ...opts.budget };
|
|
6319
6853
|
const deadline = started + budget.budgetMs;
|
|
6854
|
+
const discoveryDeadline = Math.max(deadline, started + DISCOVERY_FLOOR_MS);
|
|
6320
6855
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
6321
6856
|
const ctx = {
|
|
6322
6857
|
fetchImpl,
|
|
6323
6858
|
baseUrl,
|
|
6324
6859
|
signal: opts.signal,
|
|
6325
|
-
onProgress: opts.onProgress
|
|
6860
|
+
onProgress: opts.onProgress,
|
|
6861
|
+
deadline: discoveryDeadline
|
|
6326
6862
|
};
|
|
6327
6863
|
const warnings = [];
|
|
6328
6864
|
const stats = { pages: 0, bytes: 0 };
|
|
@@ -6343,10 +6879,18 @@ async function scanOnce(opts, baseUrl) {
|
|
|
6343
6879
|
if (refused > 0) warnings.push(`${refused} pages are disallowed by this site's robots.txt`);
|
|
6344
6880
|
const fromApi = detection.platform !== "generic" && !discovered.hints?.includes("json-blocked");
|
|
6345
6881
|
const countSource = urls.length === 0 ? "none" : fromApi ? "api" : "sitemap";
|
|
6882
|
+
const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
|
|
6346
6883
|
if (urls.length === 0) {
|
|
6347
|
-
const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
|
|
6348
6884
|
return done(commerce ? "likely" : "none", [], [], 0, countSource, warnings);
|
|
6349
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
|
+
}
|
|
6350
6894
|
opts.onProgress?.({ stage: "discovering", discovered: urls.length, message: "Reading a few products" });
|
|
6351
6895
|
const previewDeadline = Math.max(deadline, Date.now() + budget.previewFloorMs);
|
|
6352
6896
|
const read = dedupeProducts(
|
|
@@ -6362,15 +6906,16 @@ async function scanOnce(opts, baseUrl) {
|
|
|
6362
6906
|
})
|
|
6363
6907
|
);
|
|
6364
6908
|
const preview = read.slice(0, budget.maxPreviewPages);
|
|
6365
|
-
const verdict = preview.length ? "found" : "blocked";
|
|
6909
|
+
const verdict = preview.length ? "found" : commerce ? "blocked" : "none";
|
|
6366
6910
|
return done(verdict, preview, urls, urls.length, countSource, warnings);
|
|
6367
|
-
function done(verdict2, candidates, candidateUrls, count, source, notes) {
|
|
6911
|
+
function done(verdict2, candidates, candidateUrls, count, source, notes, cards = []) {
|
|
6368
6912
|
return {
|
|
6369
6913
|
baseUrl,
|
|
6370
6914
|
platform: detection?.platform ?? "unknown",
|
|
6371
6915
|
signals: detection?.signals ?? [],
|
|
6372
6916
|
verdict: verdict2,
|
|
6373
6917
|
count,
|
|
6918
|
+
cards,
|
|
6374
6919
|
countSource: source,
|
|
6375
6920
|
candidates,
|
|
6376
6921
|
candidateUrls,
|
|
@@ -6438,11 +6983,14 @@ function progressWriter(patch2) {
|
|
|
6438
6983
|
});
|
|
6439
6984
|
};
|
|
6440
6985
|
}
|
|
6986
|
+
function stoppedMessage(saved) {
|
|
6987
|
+
return saved ? `Stopped after saving ${saved.toLocaleString()} products` : "Stopped before anything was saved";
|
|
6988
|
+
}
|
|
6441
6989
|
async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
6442
6990
|
const { core, fetchImpl } = deps;
|
|
6443
6991
|
const patch2 = (p) => core.catalog.updateJob(jobId, p);
|
|
6992
|
+
const tally2 = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
|
|
6444
6993
|
try {
|
|
6445
|
-
const tally = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
|
|
6446
6994
|
const ctx = { fetchImpl: fetchImpl ?? fetch, baseUrl: url, signal };
|
|
6447
6995
|
let urls = [];
|
|
6448
6996
|
let bulk = null;
|
|
@@ -6458,6 +7006,16 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6458
7006
|
baseUrl = url;
|
|
6459
7007
|
platform = detection.platform;
|
|
6460
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
|
+
}
|
|
6461
7019
|
} else {
|
|
6462
7020
|
patch2({ stage: "discovering", message: "Detecting store platform" });
|
|
6463
7021
|
const found = await discoverCatalog({ url, fetchImpl, signal, onProgress: progressWriter(patch2) });
|
|
@@ -6481,7 +7039,7 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6481
7039
|
if (!found.byPage) {
|
|
6482
7040
|
const products = await found.fetchAll();
|
|
6483
7041
|
if (signal.aborted) {
|
|
6484
|
-
patch2({ stage: "cancelled", errors: [], message:
|
|
7042
|
+
patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
|
|
6485
7043
|
return;
|
|
6486
7044
|
}
|
|
6487
7045
|
if (!products.length) {
|
|
@@ -6506,64 +7064,74 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6506
7064
|
patch2({ stage: "fetching_products", discovered, message: `Reading ${discovered.toLocaleString()} products` });
|
|
6507
7065
|
}
|
|
6508
7066
|
}
|
|
6509
|
-
const run2 = beginWrite(deps, jobId, brandId, baseUrl, platform,
|
|
6510
|
-
const pictures = drainPictures(deps, jobId, brandId,
|
|
7067
|
+
const run2 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered);
|
|
7068
|
+
const pictures = drainPictures(deps, jobId, brandId, tally2, signal, hostOf(baseUrl));
|
|
6511
7069
|
let pictureErrors;
|
|
6512
|
-
|
|
7070
|
+
let pictureReasons = {};
|
|
7071
|
+
const stats = { pages: 0, bytes: 0, refused: 0, reasons: {} };
|
|
6513
7072
|
try {
|
|
6514
7073
|
if (bulk) for (const p of bulk) run2.write(p);
|
|
6515
|
-
|
|
7074
|
+
if (urls.length)
|
|
6516
7075
|
await fetchProductPages(ctx, urls, {
|
|
6517
7076
|
concurrency: IMPORT_CONCURRENCY,
|
|
6518
7077
|
maxBytes: 15e5,
|
|
6519
7078
|
onEach: run2.write,
|
|
6520
|
-
stats
|
|
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,
|
|
7085
|
+
// Past this many refusals in a row the store is not going to change
|
|
7086
|
+
// its mind inside this import, and waiting out its cooldown for the
|
|
7087
|
+
// rest of the catalogue is time nobody gets a product for.
|
|
7088
|
+
giveUpAfterRefusals: 24
|
|
6521
7089
|
});
|
|
6522
|
-
if (!signal.aborted &&
|
|
7090
|
+
if (!signal.aborted && tally2.upserted) {
|
|
6523
7091
|
patch2({
|
|
6524
7092
|
stage: "processing_assets",
|
|
6525
|
-
message: `Downloading ${
|
|
6526
|
-
upserted:
|
|
6527
|
-
fetched:
|
|
7093
|
+
message: `Downloading ${tally2.imagesTotal.toLocaleString()} pictures`,
|
|
7094
|
+
upserted: tally2.upserted,
|
|
7095
|
+
fetched: tally2.fetched
|
|
6528
7096
|
});
|
|
6529
7097
|
}
|
|
6530
7098
|
} finally {
|
|
6531
7099
|
pictures.stop();
|
|
6532
|
-
|
|
7100
|
+
const settled = await pictures.done;
|
|
7101
|
+
pictureErrors = settled.errors;
|
|
7102
|
+
pictureReasons = settled.reasons;
|
|
6533
7103
|
}
|
|
6534
7104
|
if (signal.aborted) {
|
|
6535
7105
|
patch2({
|
|
6536
7106
|
stage: "cancelled",
|
|
6537
7107
|
errors: [],
|
|
6538
|
-
message:
|
|
7108
|
+
message: stoppedMessage(tally2.upserted),
|
|
6539
7109
|
finished: true
|
|
6540
7110
|
});
|
|
6541
7111
|
core.catalog.setSourceStatus(run2.sourceId, "partial", true);
|
|
6542
7112
|
return;
|
|
6543
7113
|
}
|
|
6544
|
-
if (!tally.upserted) {
|
|
6545
|
-
const message = only?.length ? "None of the chosen products could be read" : `Found pages on this ${platform} store but could not read a product from any of them. The store may be blocking automated readers.`;
|
|
6546
|
-
patch2({
|
|
6547
|
-
stage: "failed",
|
|
6548
|
-
message,
|
|
6549
|
-
errors: [{ code: "no_products_fetched", message }],
|
|
6550
|
-
finished: true
|
|
6551
|
-
});
|
|
6552
|
-
core.catalog.setSourceStatus(run2.sourceId, "failed", true);
|
|
6553
|
-
return;
|
|
6554
|
-
}
|
|
6555
7114
|
run2.finish({
|
|
6556
7115
|
sweep,
|
|
6557
7116
|
warnings,
|
|
6558
7117
|
errors: pictureErrors,
|
|
6559
7118
|
refused: stats.refused,
|
|
6560
|
-
|
|
6561
|
-
//
|
|
6562
|
-
|
|
7119
|
+
reasons: { ...stats.reasons, ...pictureReasons },
|
|
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
|
|
6563
7131
|
});
|
|
6564
7132
|
} catch (err) {
|
|
6565
7133
|
if (signal.aborted) {
|
|
6566
|
-
patch2({ stage: "cancelled", errors: [], message:
|
|
7134
|
+
patch2({ stage: "cancelled", errors: [], message: stoppedMessage(tally2.upserted), finished: true });
|
|
6567
7135
|
return;
|
|
6568
7136
|
}
|
|
6569
7137
|
patch2({
|
|
@@ -6577,8 +7145,16 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
|
|
|
6577
7145
|
var IMAGES_PER_PRODUCT = 3;
|
|
6578
7146
|
var TERMINAL = /* @__PURE__ */ new Set(["completed", "partial", "no_catalog", "cancelled", "failed"]);
|
|
6579
7147
|
var IMAGE_CONCURRENCY = 4;
|
|
6580
|
-
var
|
|
6581
|
-
function
|
|
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));
|
|
7157
|
+
function beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered) {
|
|
6582
7158
|
const { core } = deps;
|
|
6583
7159
|
const patch2 = (p) => core.catalog.updateJob(jobId, p);
|
|
6584
7160
|
const source = core.catalog.upsertSource(brandId, baseUrl, platform);
|
|
@@ -6616,71 +7192,93 @@ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally, discovered)
|
|
|
6616
7192
|
})),
|
|
6617
7193
|
collections: (p.collections ?? []).map((c) => ({ externalKey: c, title: c }))
|
|
6618
7194
|
});
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
patch2({ upserted:
|
|
7195
|
+
tally2.seenKeys.push(p.externalKey);
|
|
7196
|
+
tally2.upserted++;
|
|
7197
|
+
tally2.fetched++;
|
|
7198
|
+
patch2({ upserted: tally2.upserted, fetched: tally2.fetched });
|
|
6623
7199
|
},
|
|
6624
7200
|
finish({
|
|
6625
7201
|
sweep,
|
|
6626
7202
|
warnings,
|
|
6627
7203
|
errors,
|
|
6628
7204
|
refused = 0,
|
|
6629
|
-
covered = true
|
|
7205
|
+
covered = true,
|
|
7206
|
+
reasons = {},
|
|
7207
|
+
asked = 0,
|
|
7208
|
+
worked
|
|
6630
7209
|
}) {
|
|
6631
|
-
|
|
6632
|
-
|
|
7210
|
+
const mayRetire = sweep && covered && refused === 0 && tally2.upserted > 0;
|
|
7211
|
+
if (mayRetire) core.catalog.markMissingUnavailable(source.id, tally2.seenKeys);
|
|
7212
|
+
if (tally2.upserted > 0) core.catalog.excludeSharedImages(source.id);
|
|
7213
|
+
tally2.errors = errors;
|
|
6633
7214
|
const partial = !covered || refused > 0;
|
|
6634
|
-
const
|
|
6635
|
-
const
|
|
7215
|
+
const savedNothing = tally2.upserted === 0;
|
|
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
|
+
);
|
|
7223
|
+
const stage = savedNothing ? "failed" : partial ? "partial" : "completed";
|
|
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`);
|
|
6636
7225
|
patch2({
|
|
6637
|
-
stage
|
|
6638
|
-
upserted:
|
|
6639
|
-
fetched:
|
|
6640
|
-
imagesDone:
|
|
6641
|
-
imagesTotal:
|
|
6642
|
-
errors
|
|
7226
|
+
stage,
|
|
7227
|
+
upserted: tally2.upserted,
|
|
7228
|
+
fetched: tally2.fetched,
|
|
7229
|
+
imagesDone: tally2.imagesDone,
|
|
7230
|
+
imagesTotal: tally2.imagesTotal,
|
|
7231
|
+
errors,
|
|
6643
7232
|
warnings,
|
|
6644
7233
|
message,
|
|
6645
7234
|
finished: true
|
|
6646
7235
|
});
|
|
6647
|
-
core.catalog.setSourceStatus(source.id, partial ? "partial" : "ready", true);
|
|
7236
|
+
core.catalog.setSourceStatus(source.id, savedNothing ? "failed" : partial ? "partial" : "ready", true);
|
|
6648
7237
|
}
|
|
6649
7238
|
};
|
|
6650
7239
|
}
|
|
6651
7240
|
var IMPORT_CONCURRENCY = 4;
|
|
7241
|
+
var IMPORT_DEADLINE_MS = 40 * 6e4;
|
|
6652
7242
|
var PICTURE_ROUND = 60;
|
|
6653
|
-
function drainPictures(deps, jobId, brandId,
|
|
7243
|
+
function drainPictures(deps, jobId, brandId, tally2, signal, storeHost = "", imagesPerProduct = IMAGES_PER_PRODUCT) {
|
|
6654
7244
|
const { core, fetchImpl } = deps;
|
|
6655
7245
|
const patch2 = (p) => core.catalog.updateJob(jobId, p);
|
|
6656
7246
|
const errors = [...core.catalog.getJob(jobId)?.errors ?? []];
|
|
7247
|
+
const reasons = {};
|
|
6657
7248
|
let stopped = false;
|
|
6658
7249
|
const done = (async () => {
|
|
6659
7250
|
while (!signal.aborted) {
|
|
6660
7251
|
const round = core.catalog.listImagesNeedingAssets(brandId, PICTURE_ROUND).filter((img) => img.position < imagesPerProduct);
|
|
6661
7252
|
if (!round.length) {
|
|
6662
7253
|
if (stopped) break;
|
|
6663
|
-
await
|
|
7254
|
+
await sleep7(150);
|
|
6664
7255
|
continue;
|
|
6665
7256
|
}
|
|
6666
|
-
|
|
7257
|
+
tally2.imagesTotal = tally2.imagesDone + core.catalog.countImagesNeedingAssets(brandId, imagesPerProduct);
|
|
6667
7258
|
await mapPool(
|
|
6668
7259
|
round,
|
|
6669
|
-
IMAGE_CONCURRENCY,
|
|
7260
|
+
allOffHost(round, storeHost) ? IMAGE_CONCURRENCY_OFF_HOST : IMAGE_CONCURRENCY,
|
|
6670
7261
|
async (img) => {
|
|
6671
7262
|
if (signal.aborted) return;
|
|
6672
7263
|
try {
|
|
6673
7264
|
const res = await httpGet(img.sourceUrl, { fetchImpl, signal, timeoutMs: 4e4, retries: 2 });
|
|
6674
7265
|
if (!res.ok) {
|
|
6675
7266
|
errors.push({ code: "image_http", message: `HTTP ${res.status}`, url: img.sourceUrl });
|
|
7267
|
+
tally(reasons, imageFailure(res.status));
|
|
6676
7268
|
return;
|
|
6677
7269
|
}
|
|
6678
7270
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
6679
7271
|
if (!buf.length) {
|
|
6680
7272
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
7273
|
+
tally(reasons, "IMAGE_EMPTY");
|
|
6681
7274
|
return;
|
|
6682
7275
|
}
|
|
6683
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
|
+
}
|
|
6684
7282
|
const turned = (probe.orientation ?? 1) > 1;
|
|
6685
7283
|
const keep = turned ? await sharp21(buf).rotate().toBuffer() : buf;
|
|
6686
7284
|
const meta = turned ? await sharp21(keep).metadata() : probe;
|
|
@@ -6691,6 +7289,7 @@ function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = I
|
|
|
6691
7289
|
});
|
|
6692
7290
|
} catch (err) {
|
|
6693
7291
|
if (signal.aborted) return;
|
|
7292
|
+
tally(reasons, thrownFailure(err, signal.aborted));
|
|
6694
7293
|
errors.push({
|
|
6695
7294
|
code: "image_failed",
|
|
6696
7295
|
message: String(err?.message ?? err),
|
|
@@ -6698,8 +7297,8 @@ function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = I
|
|
|
6698
7297
|
retryable: true
|
|
6699
7298
|
});
|
|
6700
7299
|
} finally {
|
|
6701
|
-
|
|
6702
|
-
patch2({ imagesDone:
|
|
7300
|
+
tally2.imagesDone++;
|
|
7301
|
+
patch2({ imagesDone: tally2.imagesDone, imagesTotal: tally2.imagesTotal, errors });
|
|
6703
7302
|
}
|
|
6704
7303
|
},
|
|
6705
7304
|
signal
|
|
@@ -6710,8 +7309,8 @@ function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = I
|
|
|
6710
7309
|
break;
|
|
6711
7310
|
}
|
|
6712
7311
|
}
|
|
6713
|
-
patch2({ imagesDone:
|
|
6714
|
-
return errors;
|
|
7312
|
+
patch2({ imagesDone: tally2.imagesDone, imagesTotal: tally2.imagesTotal, errors });
|
|
7313
|
+
return { errors, reasons };
|
|
6715
7314
|
})();
|
|
6716
7315
|
return {
|
|
6717
7316
|
stop() {
|
|
@@ -6779,12 +7378,26 @@ function brandJsonWithCatalogProducts(core, brandId) {
|
|
|
6779
7378
|
function runningImportCount() {
|
|
6780
7379
|
return running.size;
|
|
6781
7380
|
}
|
|
7381
|
+
var IDENTITY_EDITS_MAX = 8;
|
|
7382
|
+
var IDENTITY_EDIT_CHARS = 120;
|
|
7383
|
+
var PRESENTER_SOURCES = ["synthetic", "photos"];
|
|
7384
|
+
var LIKENESS_VERSION = "v1";
|
|
6782
7385
|
var PRESENTER_ID_PREFIX = "up";
|
|
6783
7386
|
var SCENE_ID_PREFIX = "us";
|
|
6784
7387
|
var ASSET_WIDTH = 1024;
|
|
6785
7388
|
var ASSET_HEIGHT = 1280;
|
|
6786
7389
|
var mintId = (prefix) => `${prefix}-${randomUUID().slice(0, 8)}`;
|
|
6787
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
|
+
};
|
|
6788
7401
|
var strList = (v, max, each) => Array.isArray(v) ? v.map((x) => str3(x, each)).filter(Boolean).slice(0, max) : [];
|
|
6789
7402
|
var assetRef = (hash) => {
|
|
6790
7403
|
const h = String(hash ?? "");
|
|
@@ -6802,6 +7415,30 @@ function brandSceneById(brandJson, id) {
|
|
|
6802
7415
|
function isCustomPresenter(c) {
|
|
6803
7416
|
return c?.origin === "custom";
|
|
6804
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
|
+
}
|
|
6805
7442
|
var SUBJECTS2 = /* @__PURE__ */ new Set(["product", "person", "either"]);
|
|
6806
7443
|
function sceneRecordFrom(input, base) {
|
|
6807
7444
|
const has = (k) => input[k] !== void 0;
|
|
@@ -6852,7 +7489,11 @@ function presenterRecordFrom(input, base) {
|
|
|
6852
7489
|
const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
|
|
6853
7490
|
if (!name) return { ok: false, error: "a presenter needs a name" };
|
|
6854
7491
|
const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
|
|
6855
|
-
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 ?? [];
|
|
6856
7497
|
if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
|
|
6857
7498
|
const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
|
|
6858
7499
|
const presenter = {
|
|
@@ -6865,13 +7506,13 @@ function presenterRecordFrom(input, base) {
|
|
|
6865
7506
|
if (promptName) presenter.promptName = promptName;
|
|
6866
7507
|
const presentation = has("presentation") ? str3(input.presentation, 10).toLowerCase() : base?.presentation;
|
|
6867
7508
|
if (presentation === "woman" || presentation === "man") presenter.presentation = presentation;
|
|
6868
|
-
const descriptor = has("descriptor") ?
|
|
7509
|
+
const descriptor = has("descriptor") ? phrase(input.descriptor, 120) : base?.descriptor;
|
|
6869
7510
|
if (descriptor) presenter.descriptor = descriptor;
|
|
6870
7511
|
const ageRange = has("ageRange") ? str3(input.ageRange, 40) : base?.ageRange;
|
|
6871
7512
|
if (ageRange) presenter.ageRange = ageRange;
|
|
6872
|
-
const hair = has("hair") ?
|
|
7513
|
+
const hair = has("hair") ? phrase(input.hair, 120) : base?.hair;
|
|
6873
7514
|
if (hair) presenter.hair = hair;
|
|
6874
|
-
const identityNotes = has("identityNotes") ?
|
|
7515
|
+
const identityNotes = has("identityNotes") ? phrase(input.identityNotes, 900) : base?.identityNotes;
|
|
6875
7516
|
if (identityNotes) presenter.identityNotes = identityNotes;
|
|
6876
7517
|
const negatives = has("negativeConstraints") ? strList(input.negativeConstraints, 8, 160) : base?.negativeConstraints;
|
|
6877
7518
|
if (negatives?.length) presenter.negativeConstraints = negatives;
|
|
@@ -6882,8 +7523,36 @@ function presenterRecordFrom(input, base) {
|
|
|
6882
7523
|
if (previewRef) presenter.preview = previewRef;
|
|
6883
7524
|
const avatarRef = has("avatarHash") ? assetRef(input.avatarHash) : base?.avatar ?? null;
|
|
6884
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;
|
|
6885
7541
|
return { ok: true, presenter };
|
|
6886
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
|
+
}
|
|
6887
7556
|
function lintSceneProse(brandJson, scene) {
|
|
6888
7557
|
const haystack = `${scene.prompt} ${scene.description} ${scene.figure ?? ""} ${scene.figureTreatment ?? ""}`.toLowerCase();
|
|
6889
7558
|
const named = /* @__PURE__ */ new Set();
|
|
@@ -6899,14 +7568,14 @@ function lintSceneProse(brandJson, scene) {
|
|
|
6899
7568
|
`This scene names ${[...named].join(", ")}. A scene is a place, so whatever you stage in it later has to argue with that.`
|
|
6900
7569
|
] : [];
|
|
6901
7570
|
}
|
|
6902
|
-
function commit(core, brandId,
|
|
7571
|
+
function commit(core, brandId, mutate2, dropDraftId) {
|
|
6903
7572
|
const brand = core.store.getBrand(brandId);
|
|
6904
7573
|
if (!brand) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
|
|
6905
7574
|
const json = { ...brand.json };
|
|
6906
|
-
|
|
7575
|
+
mutate2(json);
|
|
6907
7576
|
const v = validateBrand(json);
|
|
6908
7577
|
if (!v.valid) throw Object.assign(new Error(`brand became invalid: ${v.errors.join("; ")}`), { statusCode: 400 });
|
|
6909
|
-
return core.store.updateBrand(brand.id, json);
|
|
7578
|
+
return dropDraftId ? core.store.updateBrandAndDropPresenterDraft(brand.id, json, dropDraftId) : core.store.updateBrand(brand.id, json);
|
|
6910
7579
|
}
|
|
6911
7580
|
|
|
6912
7581
|
// src/customAssets.ts
|
|
@@ -6938,20 +7607,17 @@ function startAssetBuild(deps, input) {
|
|
|
6938
7607
|
if (input.sceneId && !prior) throw Object.assign(new Error("scene not found"), { statusCode: 404 });
|
|
6939
7608
|
const supplied = input.imageHashes.length ? input.imageHashes : (prior?.refs ?? []).map((r) => String(r?.file ?? "").replace(/^asset:/, ""));
|
|
6940
7609
|
const hashes = supplied.filter((h) => /^[a-f0-9]{32}$/.test(h) && core.images.has(h));
|
|
6941
|
-
if (
|
|
6942
|
-
throw Object.assign(new Error("add at least one photo of this person"), { statusCode: 400 });
|
|
6943
|
-
}
|
|
6944
|
-
if (input.kind === "scene" && !hashes.length && !input.instruction?.trim()) {
|
|
7610
|
+
if (!hashes.length && !input.instruction?.trim()) {
|
|
6945
7611
|
throw Object.assign(new Error("add a reference image, or describe the place in a sentence"), { statusCode: 400 });
|
|
6946
7612
|
}
|
|
6947
7613
|
const job = {
|
|
6948
7614
|
id: `ab-${randomUUID().slice(0, 8)}`,
|
|
6949
7615
|
brandId: brand.id,
|
|
6950
7616
|
kind: input.kind,
|
|
6951
|
-
name: str3(input.name, 60) ||
|
|
7617
|
+
name: str3(input.name, 60) || "New scene",
|
|
6952
7618
|
stage: "queued",
|
|
6953
7619
|
step: 0,
|
|
6954
|
-
steps:
|
|
7620
|
+
steps: 1,
|
|
6955
7621
|
message: null,
|
|
6956
7622
|
assetId: null,
|
|
6957
7623
|
previewHash: null,
|
|
@@ -6979,8 +7645,7 @@ function patch(job, next) {
|
|
|
6979
7645
|
}
|
|
6980
7646
|
async function runBuild(deps, job, hashes, instruction, signal) {
|
|
6981
7647
|
try {
|
|
6982
|
-
|
|
6983
|
-
else await runSceneBuild(deps, job, hashes, instruction, signal);
|
|
7648
|
+
await runSceneBuild(deps, job, hashes, instruction, signal);
|
|
6984
7649
|
} catch (err) {
|
|
6985
7650
|
if (signal.aborted) {
|
|
6986
7651
|
patch(job, { stage: "cancelled", message: null, finished: true });
|
|
@@ -6989,162 +7654,6 @@ async function runBuild(deps, job, hashes, instruction, signal) {
|
|
|
6989
7654
|
patch(job, { stage: "failed", error: err?.message ?? "build failed", message: null, finished: true });
|
|
6990
7655
|
}
|
|
6991
7656
|
}
|
|
6992
|
-
var STUDIO_FRAMES = [
|
|
6993
|
-
/*
|
|
6994
|
-
* The identity frame, and it comes first because that is the order a brief
|
|
6995
|
-
* attaches: `shots[0]` is the essential character reference.
|
|
6996
|
-
*
|
|
6997
|
-
* Every other frame here is full-length head-to-toe, which is right for
|
|
6998
|
-
* build, proportion and wardrobe and useless for a face — in a 1024x1280
|
|
6999
|
-
* full-length frame the face is about 105px brow to chin, while a portrait
|
|
7000
|
-
* output renders it at four times that. Measured 2026-08-30 against the
|
|
7001
|
-
* reported failure: four outputs of one brief, four different jaws, and
|
|
7002
|
-
* drift that tracked nothing but how big the face was in the output.
|
|
7003
|
-
*
|
|
7004
|
-
* Drawn `from: 'sources'` rather than chained off the front view, because
|
|
7005
|
-
* the user's own photographs are the only real face evidence in the system
|
|
7006
|
-
* and a chain would just enlarge the same 105px.
|
|
7007
|
-
*/
|
|
7008
|
-
{
|
|
7009
|
-
angle: "portrait",
|
|
7010
|
-
from: "sources",
|
|
7011
|
-
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`
|
|
7012
|
-
},
|
|
7013
|
-
{
|
|
7014
|
-
angle: "front",
|
|
7015
|
-
from: "sources",
|
|
7016
|
-
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`
|
|
7017
|
-
},
|
|
7018
|
-
{
|
|
7019
|
-
angle: "left-profile",
|
|
7020
|
-
from: "front",
|
|
7021
|
-
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"
|
|
7022
|
-
},
|
|
7023
|
-
{
|
|
7024
|
-
angle: "right-profile",
|
|
7025
|
-
from: "left-profile",
|
|
7026
|
-
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"
|
|
7027
|
-
},
|
|
7028
|
-
{
|
|
7029
|
-
angle: "back",
|
|
7030
|
-
from: "front",
|
|
7031
|
-
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"
|
|
7032
|
-
}
|
|
7033
|
-
];
|
|
7034
|
-
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";
|
|
7035
|
-
function studioPrompt(subject) {
|
|
7036
|
-
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.`;
|
|
7037
|
-
}
|
|
7038
|
-
function whoIs(name, draft) {
|
|
7039
|
-
if (!draft) return `the exact person in the attached photographs`;
|
|
7040
|
-
const bits = [draft.promptName];
|
|
7041
|
-
if (draft.hair && !draft.promptName.toLowerCase().includes(draft.hair.toLowerCase())) bits.push(draft.hair);
|
|
7042
|
-
if (draft.identityNotes) bits.push(draft.identityNotes);
|
|
7043
|
-
return bits.filter(Boolean).join(", ") || name;
|
|
7044
|
-
}
|
|
7045
|
-
async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
7046
|
-
const { core } = deps;
|
|
7047
|
-
const sourcePaths = hashes.map((h) => core.images.pathFor(h));
|
|
7048
|
-
let draft = null;
|
|
7049
|
-
if (deps.analyzer) {
|
|
7050
|
-
patch(job, { stage: "analyzing", message: "Reading the photos" });
|
|
7051
|
-
draft = await deps.analyzer.analyze(
|
|
7052
|
-
{
|
|
7053
|
-
kind: "presenter",
|
|
7054
|
-
imagePaths: sourcePaths,
|
|
7055
|
-
name: job.name,
|
|
7056
|
-
instruction: instruction || void 0,
|
|
7057
|
-
vocabulary: deps.vocabulary
|
|
7058
|
-
},
|
|
7059
|
-
signal
|
|
7060
|
-
);
|
|
7061
|
-
patch(job, { coverage: draft.coverage ?? [] });
|
|
7062
|
-
}
|
|
7063
|
-
if (signal.aborted) throw new Error("cancelled");
|
|
7064
|
-
let shotHashes = hashes;
|
|
7065
|
-
let shotAngles = [];
|
|
7066
|
-
const warnings = [];
|
|
7067
|
-
if (deps.engine) {
|
|
7068
|
-
patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
|
|
7069
|
-
const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
|
|
7070
|
-
if (built2.hashes.length) {
|
|
7071
|
-
shotHashes = built2.hashes;
|
|
7072
|
-
shotAngles = built2.angles;
|
|
7073
|
-
} else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
|
|
7074
|
-
} else {
|
|
7075
|
-
warnings.push("No engine could draw the studio views, so the photos are being used directly.");
|
|
7076
|
-
}
|
|
7077
|
-
if (signal.aborted) throw new Error("cancelled");
|
|
7078
|
-
patch(job, { stage: "saving", message: null });
|
|
7079
|
-
const generated = shotHashes !== hashes;
|
|
7080
|
-
const frontIndex = shotAngles.indexOf("front");
|
|
7081
|
-
const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
|
|
7082
|
-
const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
|
|
7083
|
-
const built = presenterRecordFrom({
|
|
7084
|
-
name: job.name,
|
|
7085
|
-
shotHashes,
|
|
7086
|
-
shotAngles,
|
|
7087
|
-
sourceHashes: hashes,
|
|
7088
|
-
previewHash,
|
|
7089
|
-
avatarHash,
|
|
7090
|
-
promptName: draft?.promptName,
|
|
7091
|
-
presentation: draft?.presentation,
|
|
7092
|
-
descriptor: draft?.descriptor,
|
|
7093
|
-
ageRange: draft?.ageRange,
|
|
7094
|
-
hair: draft?.hair,
|
|
7095
|
-
identityNotes: draft?.identityNotes,
|
|
7096
|
-
negativeConstraints: draft?.negativeConstraints,
|
|
7097
|
-
// What the caller asked for wins over what the analyzer guessed: the
|
|
7098
|
-
// person choosing where this belongs knows their own library.
|
|
7099
|
-
suitableCategories: job.facets.length ? job.facets : draft?.suitableCategories
|
|
7100
|
-
});
|
|
7101
|
-
if (!built.ok) throw new Error(built.error);
|
|
7102
|
-
commit(core, job.brandId, (json) => {
|
|
7103
|
-
json.characters = [...brandCharacters(json), built.presenter];
|
|
7104
|
-
});
|
|
7105
|
-
patch(job, {
|
|
7106
|
-
stage: "done",
|
|
7107
|
-
step: job.steps,
|
|
7108
|
-
assetId: built.presenter.id,
|
|
7109
|
-
previewHash: previewHash ?? cardSource ?? null,
|
|
7110
|
-
warnings: [...job.warnings, ...warnings],
|
|
7111
|
-
finished: true
|
|
7112
|
-
});
|
|
7113
|
-
}
|
|
7114
|
-
async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
7115
|
-
const engine = deps.engine;
|
|
7116
|
-
if (!engine) return { hashes: [], angles: [] };
|
|
7117
|
-
const caps = engine.capabilities();
|
|
7118
|
-
if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
|
|
7119
|
-
const byAngle = /* @__PURE__ */ new Map();
|
|
7120
|
-
for (const frame of STUDIO_FRAMES) {
|
|
7121
|
-
if (signal.aborted) throw new Error("cancelled");
|
|
7122
|
-
const refs = frame.from === "sources" ? sourcePaths.slice(0, caps.maxReferenceImages) : [byAngle.get(frame.from)].filter((h) => !!h).map((h) => deps.core.images.pathFor(h));
|
|
7123
|
-
if (!refs.length) continue;
|
|
7124
|
-
try {
|
|
7125
|
-
const drawn = await draw(deps, {
|
|
7126
|
-
prompt: studioPrompt(frame.subject(who)),
|
|
7127
|
-
brandId: job.brandId,
|
|
7128
|
-
referenceImages: refs,
|
|
7129
|
-
referenceRoles: refs.map(() => "character"),
|
|
7130
|
-
signal
|
|
7131
|
-
});
|
|
7132
|
-
const hash = await trimEdgeBars(deps.core, drawn);
|
|
7133
|
-
byAngle.set(frame.angle, hash);
|
|
7134
|
-
patch(job, {
|
|
7135
|
-
step: byAngle.size,
|
|
7136
|
-
previewHash: job.previewHash ?? hash,
|
|
7137
|
-
message: `Building the studio views (${byAngle.size} of ${STUDIO_FRAMES.length})`
|
|
7138
|
-
});
|
|
7139
|
-
} catch (err) {
|
|
7140
|
-
if (signal.aborted) throw err;
|
|
7141
|
-
if (frame.angle === "front") throw err;
|
|
7142
|
-
patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
|
|
7143
|
-
}
|
|
7144
|
-
}
|
|
7145
|
-
const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
|
|
7146
|
-
return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
|
|
7147
|
-
}
|
|
7148
7657
|
async function edgeBarGeometry(buf) {
|
|
7149
7658
|
const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
7150
7659
|
const W = info.width;
|
|
@@ -7215,7 +7724,7 @@ async function cardCrop(core, hash) {
|
|
|
7215
7724
|
}
|
|
7216
7725
|
var AVATAR_FIGURE_FRACTION = 0.22;
|
|
7217
7726
|
var AVATAR_HEADROOM = 0.1;
|
|
7218
|
-
var AVATAR_MAX_PX =
|
|
7727
|
+
var AVATAR_MAX_PX = 1024;
|
|
7219
7728
|
var FIGURE_TRIM_THRESHOLDS = [12, 25];
|
|
7220
7729
|
async function avatarCrop(core, hash) {
|
|
7221
7730
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
@@ -7318,10 +7827,25 @@ async function figureBox(buf) {
|
|
|
7318
7827
|
return null;
|
|
7319
7828
|
}
|
|
7320
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
|
+
}
|
|
7321
7834
|
const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
|
|
7322
7835
|
const avatarHash = mode === "generated" ? await avatarCrop(core, hash) ?? await avatarCropSmart(core, hash) : await avatarCropSmart(core, hash) ?? await avatarCrop(core, hash);
|
|
7323
7836
|
return { previewHash, avatarHash };
|
|
7324
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
|
+
}
|
|
7325
7849
|
async function cardCropSmart(core, hash) {
|
|
7326
7850
|
return smartCover(core, hash, (w, h) => {
|
|
7327
7851
|
if (w / h > 0.8) return { width: Math.round(h * 0.8), height: h };
|
|
@@ -7460,8 +7984,8 @@ async function draw(deps, req) {
|
|
|
7460
7984
|
brand: deps.brandContext(req.brandId),
|
|
7461
7985
|
referenceImages: req.referenceImages,
|
|
7462
7986
|
referenceRoles: req.referenceRoles,
|
|
7463
|
-
width: ASSET_WIDTH,
|
|
7464
|
-
height: ASSET_HEIGHT,
|
|
7987
|
+
width: req.width ?? ASSET_WIDTH,
|
|
7988
|
+
height: req.height ?? ASSET_HEIGHT,
|
|
7465
7989
|
count: 1
|
|
7466
7990
|
};
|
|
7467
7991
|
const estimate = await engine.costEstimate(generateReq).catch(() => 0);
|
|
@@ -8616,6 +9140,8 @@ function registerLogoRoutes(app, deps) {
|
|
|
8616
9140
|
var scans = /* @__PURE__ */ new Map();
|
|
8617
9141
|
var controllers = /* @__PURE__ */ new Map();
|
|
8618
9142
|
var KEEP = 32;
|
|
9143
|
+
var HARD_STOP_MS = 42e3;
|
|
9144
|
+
var KEEP_MS = 10 * 6e4;
|
|
8619
9145
|
function remember(state) {
|
|
8620
9146
|
scans.set(state.id, state);
|
|
8621
9147
|
while (scans.size > KEEP) {
|
|
@@ -8626,7 +9152,20 @@ function remember(state) {
|
|
|
8626
9152
|
}
|
|
8627
9153
|
}
|
|
8628
9154
|
function getScan(scanId) {
|
|
8629
|
-
|
|
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;
|
|
8630
9169
|
}
|
|
8631
9170
|
function startCatalogScan(deps, brandId, url) {
|
|
8632
9171
|
const { core, fetchImpl } = deps;
|
|
@@ -8636,13 +9175,22 @@ function startCatalogScan(deps, brandId, url) {
|
|
|
8636
9175
|
const state = { id, brandId, url, status: "running", startedAt: Date.now() };
|
|
8637
9176
|
remember(state);
|
|
8638
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?.();
|
|
8639
9184
|
void scanForCandidates({ url, fetchImpl, signal: ctrl.signal }).then((result) => {
|
|
8640
9185
|
state.result = result;
|
|
8641
9186
|
state.status = "done";
|
|
8642
9187
|
}).catch((err) => {
|
|
8643
9188
|
state.status = "error";
|
|
8644
|
-
state.error = err instanceof Error ? err.message : String(err);
|
|
8645
|
-
}).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
|
+
});
|
|
8646
9194
|
return { scanId: id };
|
|
8647
9195
|
}
|
|
8648
9196
|
|
|
@@ -8686,6 +9234,13 @@ function registerCatalogImportRoutes(app, deps) {
|
|
|
8686
9234
|
if (!scan || scan.brandId !== brandId) return reply.status(404).send({ error: "scan not found" });
|
|
8687
9235
|
return scan;
|
|
8688
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
|
+
});
|
|
8689
9244
|
app.post("/api/brands/:id/catalog/details", async (req, reply) => {
|
|
8690
9245
|
const brand = core.store.getBrand(req.params.id);
|
|
8691
9246
|
if (!brand) return reply.status(404).send({ error: "brand not found" });
|
|
@@ -8935,8 +9490,11 @@ function registerPresenterRoutes(app, deps) {
|
|
|
8935
9490
|
}
|
|
8936
9491
|
|
|
8937
9492
|
// src/presenterRepair.ts
|
|
8938
|
-
function presenterCropMode(firstShotFile,
|
|
8939
|
-
|
|
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";
|
|
8940
9498
|
}
|
|
8941
9499
|
async function repairPresenterCrops(core, log = () => {
|
|
8942
9500
|
}) {
|
|
@@ -8948,7 +9506,11 @@ async function repairPresenterCrops(core, log = () => {
|
|
|
8948
9506
|
const firstShot = c.shots?.[0]?.file;
|
|
8949
9507
|
const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
|
|
8950
9508
|
if (!hash) continue;
|
|
8951
|
-
const mode = presenterCropMode(
|
|
9509
|
+
const mode = presenterCropMode(
|
|
9510
|
+
firstShot,
|
|
9511
|
+
(c.sourceRefs ?? []).map((s) => s?.file),
|
|
9512
|
+
c.shots?.[0]?.angle
|
|
9513
|
+
);
|
|
8952
9514
|
const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
|
|
8953
9515
|
const preview = previewHash ? `asset:${previewHash}` : void 0;
|
|
8954
9516
|
const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
|
|
@@ -8967,22 +9529,1164 @@ async function repairPresenterCrops(core, log = () => {
|
|
|
8967
9529
|
return { repaired };
|
|
8968
9530
|
}
|
|
8969
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
|
+
|
|
8970
10684
|
// src/routes/assetBuilds.ts
|
|
8971
|
-
function
|
|
10685
|
+
function makeBuildDeps(deps) {
|
|
8972
10686
|
const { core, engines, scenes, presenters } = deps;
|
|
8973
10687
|
const analyzer = deps.analyzer ?? createCodexAnalyzer({ runner: engines.codexRunner });
|
|
8974
|
-
const
|
|
8975
|
-
|
|
8976
|
-
const rank = (e) => e.capabilities().id === "codex-cli" ? 0 : 1;
|
|
8977
|
-
return rank(a) - rank(b);
|
|
8978
|
-
});
|
|
8979
|
-
for (const engine of ordered) {
|
|
8980
|
-
const caps = engine.capabilities();
|
|
8981
|
-
if (!caps.maxReferenceImages || caps.placeholder) continue;
|
|
8982
|
-
if ((await engine.isAvailable()).ok) return engine;
|
|
8983
|
-
}
|
|
8984
|
-
return null;
|
|
8985
|
-
};
|
|
10688
|
+
const allowPlaceholder = process.env.SCENRI_DEMO_BUILDS === "1";
|
|
10689
|
+
const buildEngine = () => pickBuildEngine(engines, { allowPlaceholder });
|
|
8986
10690
|
const buildDeps = async () => ({
|
|
8987
10691
|
core,
|
|
8988
10692
|
engine: await buildEngine(),
|
|
@@ -8992,8 +10696,17 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
8992
10696
|
// person can actually click rather than inventing a category of one.
|
|
8993
10697
|
vocabulary: { ...facetsOf(scenes), categories: presenterFacetsOf(presenters).categories }
|
|
8994
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) };
|
|
8995
10705
|
app.get("/api/asset-builds/capabilities", async () => {
|
|
8996
|
-
const [engine, probe] = await Promise.all([
|
|
10706
|
+
const [engine, probe] = await Promise.all([
|
|
10707
|
+
buildEngine(),
|
|
10708
|
+
analyzer?.isAvailable() ?? Promise.resolve({ ok: false })
|
|
10709
|
+
]);
|
|
8997
10710
|
return {
|
|
8998
10711
|
canAnalyze: probe.ok,
|
|
8999
10712
|
analyzeReason: probe.ok ? null : probe.reason ?? null,
|
|
@@ -9023,8 +10736,9 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
9023
10736
|
if (!brand) return;
|
|
9024
10737
|
const body = req.body ?? {};
|
|
9025
10738
|
const kind = String(body.kind ?? "");
|
|
9026
|
-
if (kind
|
|
9027
|
-
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" });
|
|
9028
10742
|
try {
|
|
9029
10743
|
return startAssetBuild(await buildDeps(), {
|
|
9030
10744
|
brandId: brand.id,
|
|
@@ -9098,6 +10812,32 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
9098
10812
|
}
|
|
9099
10813
|
return { presenter: built.presenter, brand: core.store.getBrand(brand.id) };
|
|
9100
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
|
+
});
|
|
9101
10841
|
app.delete("/api/brands/:id/presenters/:presenterId", async (req, reply) => {
|
|
9102
10842
|
const brand = brandOr404(req, reply);
|
|
9103
10843
|
if (!brand) return;
|
|
@@ -9108,7 +10848,8 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
9108
10848
|
commit(core, brand.id, (json) => {
|
|
9109
10849
|
json.characters = brandCharacters(json).filter((c) => c.id !== id);
|
|
9110
10850
|
});
|
|
9111
|
-
|
|
10851
|
+
await releasePresenter(await buildDeps(), brand.id, base, hooks);
|
|
10852
|
+
return { ok: true, brand: core.store.getBrand(brand.id) };
|
|
9112
10853
|
});
|
|
9113
10854
|
app.post("/api/brands/:id/scenes", async (req, reply) => {
|
|
9114
10855
|
const brand = brandOr404(req, reply);
|
|
@@ -9221,14 +10962,191 @@ async function withDerivedCrops(core, body, base) {
|
|
|
9221
10962
|
const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
|
|
9222
10963
|
if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
|
|
9223
10964
|
const firstShot = `asset:${String(shots[0])}`;
|
|
9224
|
-
const
|
|
9225
|
-
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));
|
|
9226
10969
|
return {
|
|
9227
10970
|
...body,
|
|
9228
10971
|
...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
|
|
9229
10972
|
...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
|
|
9230
10973
|
};
|
|
9231
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
|
+
}
|
|
9232
11150
|
function registerDemoProductRoutes(app, deps) {
|
|
9233
11151
|
const { templatesRoot, demoProducts, demoProductById, thumbs } = deps;
|
|
9234
11152
|
const demoProductThumbPath = (id) => {
|
|
@@ -9707,13 +11625,12 @@ function registerImageRoutes(app, deps) {
|
|
|
9707
11625
|
return reply.send(thumbs.stream(path));
|
|
9708
11626
|
});
|
|
9709
11627
|
app.post("/api/images", async (req, reply) => {
|
|
9710
|
-
const part = await req
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
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 };
|
|
9717
11634
|
});
|
|
9718
11635
|
app.post("/api/diff", async (req, reply) => {
|
|
9719
11636
|
const { imageA, imageB } = req.body;
|
|
@@ -9734,6 +11651,36 @@ function registerImageRoutes(app, deps) {
|
|
|
9734
11651
|
|
|
9735
11652
|
// src/release/notes.data.ts
|
|
9736
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
|
+
},
|
|
11669
|
+
{
|
|
11670
|
+
version: "0.9.4",
|
|
11671
|
+
date: "2026-09-14",
|
|
11672
|
+
title: "A store that asks Scenri to slow down no longer looks like an empty one.",
|
|
11673
|
+
sections: [
|
|
11674
|
+
{
|
|
11675
|
+
heading: "Products",
|
|
11676
|
+
body: "Some stores limit how fast anything can read them. Scenri now waits the way such a store asks and says what happened: an import that could not read anything tells you to try again in a few minutes, and one that got part way says how much is left. Products from an earlier import are never retired by a run that was refused, and a store that keeps saying no ends the import early rather than grinding on."
|
|
11677
|
+
},
|
|
11678
|
+
{
|
|
11679
|
+
heading: "Fixes",
|
|
11680
|
+
body: "A product picture saved in a newer format such as AVIF is found again instead of going missing."
|
|
11681
|
+
}
|
|
11682
|
+
]
|
|
11683
|
+
},
|
|
9737
11684
|
{
|
|
9738
11685
|
version: "0.9.3",
|
|
9739
11686
|
date: "2026-09-14",
|
|
@@ -10727,7 +12674,7 @@ function registerDesktopRoutes(app, deps) {
|
|
|
10727
12674
|
record: null
|
|
10728
12675
|
};
|
|
10729
12676
|
}
|
|
10730
|
-
const { installDeps } = await import('./cli-
|
|
12677
|
+
const { installDeps } = await import('./cli-EXASA5BK.js');
|
|
10731
12678
|
const { desktopStatus } = await import('./install-VXJPLYWM.js');
|
|
10732
12679
|
return desktopStatus(installDeps(runtime.entry));
|
|
10733
12680
|
});
|
|
@@ -10735,7 +12682,7 @@ function registerDesktopRoutes(app, deps) {
|
|
|
10735
12682
|
if (!runtime.entry) {
|
|
10736
12683
|
return { ok: false, reason: "unsupported", message: "Desktop shortcuts are not available on this system yet." };
|
|
10737
12684
|
}
|
|
10738
|
-
const { addToDesktop } = await import('./cli-
|
|
12685
|
+
const { addToDesktop } = await import('./cli-EXASA5BK.js');
|
|
10739
12686
|
return addToDesktop(runtime.entry);
|
|
10740
12687
|
});
|
|
10741
12688
|
app.get("/api/desktop", async () => {
|
|
@@ -11021,7 +12968,10 @@ function buildServer(opts) {
|
|
|
11021
12968
|
const presentersDir = join(templatesRoot, "presenters");
|
|
11022
12969
|
const { presenters } = loadPresenters(presentersDir);
|
|
11023
12970
|
registerPresenterRoutes(app, { templatesRoot, presenters, thumbs });
|
|
11024
|
-
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 });
|
|
11025
12975
|
const { demoProducts } = loadDemoProducts(join(templatesRoot, "demo-products"));
|
|
11026
12976
|
const demoProductById = demoProductResolver(demoProducts);
|
|
11027
12977
|
registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById, thumbs });
|
|
@@ -12155,7 +14105,7 @@ function buildServer(opts) {
|
|
|
12155
14105
|
// one physical run counts once, however many sibling nodes share its
|
|
12156
14106
|
// controller — an update gate held open by a 4-shot batch is still held
|
|
12157
14107
|
// open by exactly one piece of work
|
|
12158
|
-
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
|
|
14108
|
+
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
|
|
12159
14109
|
});
|
|
12160
14110
|
let drained = null;
|
|
12161
14111
|
app.decorate("drain", () => {
|
|
@@ -12177,7 +14127,7 @@ function buildServer(opts) {
|
|
|
12177
14127
|
core,
|
|
12178
14128
|
runtime,
|
|
12179
14129
|
exitImpl: opts.exitImpl,
|
|
12180
|
-
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
|
|
14130
|
+
busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount() + runningDraftJobCount()
|
|
12181
14131
|
});
|
|
12182
14132
|
if (opts.studioDist && existsSync(opts.studioDist)) {
|
|
12183
14133
|
const dist = opts.studioDist;
|
|
@@ -12222,6 +14172,7 @@ async function run() {
|
|
|
12222
14172
|
const core = createCore();
|
|
12223
14173
|
const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
|
|
12224
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;
|
|
12225
14176
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
12226
14177
|
const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];
|
|
12227
14178
|
const studioDist = candidates.find((p) => existsSync(p));
|
|
@@ -12233,6 +14184,7 @@ async function run() {
|
|
|
12233
14184
|
const app = buildServer({
|
|
12234
14185
|
core,
|
|
12235
14186
|
engines,
|
|
14187
|
+
analyzer,
|
|
12236
14188
|
studioDist,
|
|
12237
14189
|
access: { allowedHosts: reachableAt, token },
|
|
12238
14190
|
runtime: {
|
|
@@ -12338,7 +14290,7 @@ async function run() {
|
|
|
12338
14290
|
}
|
|
12339
14291
|
}
|
|
12340
14292
|
const ownEntry = fileURLToPath(import.meta.url);
|
|
12341
|
-
const { addToDesktop, installDeps } = await import('./cli-
|
|
14293
|
+
const { addToDesktop, installDeps } = await import('./cli-EXASA5BK.js');
|
|
12342
14294
|
const { refreshLauncher } = await import('./refresh-5HBDLLLB.js');
|
|
12343
14295
|
const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-W6NJXMWT.js');
|
|
12344
14296
|
const { launcherInstalled } = await import('./paths-6ZXRZUHY.js');
|