image-deep-research 0.0.0-stage → 1.2.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/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +38 -0
- package/.codex-plugin/plugin.json +53 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +42 -0
- package/README.md +243 -2
- package/commands/find-reference-images.md +30 -0
- package/commands/website-contact-sheet.md +33 -0
- package/package.json +50 -4
- package/skills/image-deep-research/SKILL.md +137 -0
- package/skills/image-deep-research/scripts/args.mjs +27 -0
- package/skills/image-deep-research/scripts/browser.mjs +300 -0
- package/skills/image-deep-research/scripts/images.mjs +393 -0
- package/skills/image-deep-research/scripts/network.mjs +104 -0
- package/skills/image-deep-research/scripts/sheet.mjs +168 -0
- package/skills/image-deep-research/scripts/study.mjs +272 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* image-deep-research/images - search open image collections, check every
|
|
3
|
+
result is really an image, and keep the licence beside it.
|
|
4
|
+
|
|
5
|
+
node images.mjs "<query>" [--sources openverse,commons,aic,met] [--n 6]
|
|
6
|
+
--commercial keep only licences that allow commercial use and changes
|
|
7
|
+
(drops NC and ND; CC0, public domain, BY and BY-SA stay)
|
|
8
|
+
--out <dir> where results.json, the moodboard and downloads go
|
|
9
|
+
--sheet tile the verified images into a moodboard contact sheet
|
|
10
|
+
(needs Chrome, Edge or Chromium)
|
|
11
|
+
--download save each verified image into <out>/images
|
|
12
|
+
--json print the results as JSON instead of a table
|
|
13
|
+
--compact one line per verified result, no URLs, and one compact
|
|
14
|
+
sheet of up to 16 numbered tiles (1288x812, 1,334 image
|
|
15
|
+
tokens); implies --sheet, and --n defaults to 4
|
|
16
|
+
|
|
17
|
+
node images.mjs --pick 3,7 --results <out>/results.json
|
|
18
|
+
the full record (licence URL, image URL, page) of the
|
|
19
|
+
results numbered 3 and 7 in that run; no network
|
|
20
|
+
|
|
21
|
+
No keys. Openverse (about 800M CC and public-domain images), Wikimedia
|
|
22
|
+
Commons, the Art Institute of Chicago (public-domain works only) and the
|
|
23
|
+
Metropolitan Museum of Art (Open Access works only). Every URL is fetched
|
|
24
|
+
before it is reported: a result whose URL does not answer with an image is
|
|
25
|
+
listed as unverified and never put on the moodboard. */
|
|
26
|
+
|
|
27
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { join, resolve } from 'node:path';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { parseArgs, int } from './args.mjs';
|
|
32
|
+
import { checkUrl, publicFetch, readLimited } from './network.mjs';
|
|
33
|
+
|
|
34
|
+
/* The Art Institute of Chicago's image server answers 403 to any user agent
|
|
35
|
+
that carries a URL (measured 2026-09-25: "image-deep-research/1.0" gets the
|
|
36
|
+
image, the same string plus "(https://github.com/...)" gets 403), while
|
|
37
|
+
Wikimedia asks for contact details in it. So the contact goes to Wikimedia
|
|
38
|
+
only, and to the AIC in the header its API documents for that purpose. */
|
|
39
|
+
export const UA = 'image-deep-research/1.0';
|
|
40
|
+
export const UA_CONTACT = 'image-deep-research/1.0 (https://github.com/ridelink0/image-deep-research)';
|
|
41
|
+
export const SOURCES = ['openverse', 'commons', 'aic', 'met'];
|
|
42
|
+
|
|
43
|
+
// Commons wraps QuickStatements hints in hidden spans (title QS:P1476,...); they are not the title.
|
|
44
|
+
const strip = (html) => String(html || '').replace(/<(span|div)[^>]*display:\s*none[^>]*>[\s\S]*?<\/\1>/gi, '').replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/"/g, '"').replace(/�?39;/g, "'").replace(/\s+/g, ' ').trim();
|
|
45
|
+
|
|
46
|
+
/* True when a licence allows commercial use and modification. */
|
|
47
|
+
export function commercialOk(licence) {
|
|
48
|
+
const l = String(licence || '').toLowerCase();
|
|
49
|
+
if (!l) return false;
|
|
50
|
+
if (/\b(nc|nd)\b|noncommercial|non-commercial|noderiv|no-deriv/.test(l)) return false;
|
|
51
|
+
return /^(?:cc0(?:\s+\((?:public domain|met open access)\))?|public domain|pdm|pd|(?:cc[ -])?by(?:-sa)?(?:\s+\d(?:\.\d)?)?)$/.test(l);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseOpenverse(json) {
|
|
55
|
+
return (json.results || []).map((r) => ({
|
|
56
|
+
source: 'openverse',
|
|
57
|
+
title: r.title || '',
|
|
58
|
+
creator: r.creator || '',
|
|
59
|
+
licence: r.license ? (['cc0', 'pdm'].includes(r.license) ? r.license.toUpperCase() : `CC ${r.license.toUpperCase()} ${r.license_version || ''}`.trim()) : '',
|
|
60
|
+
licenceUrl: r.license_url || '',
|
|
61
|
+
url: r.url,
|
|
62
|
+
page: r.foreign_landing_url || '',
|
|
63
|
+
width: r.width || null,
|
|
64
|
+
height: r.height || null,
|
|
65
|
+
via: r.provider || r.source || '',
|
|
66
|
+
})).filter((r) => r.url);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseCommons(json) {
|
|
70
|
+
const pages = Object.values((json.query && json.query.pages) || {}).sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
71
|
+
return pages.map((p) => {
|
|
72
|
+
const ii = (p.imageinfo || [])[0] || {};
|
|
73
|
+
const md = ii.extmetadata || {};
|
|
74
|
+
return {
|
|
75
|
+
source: 'commons',
|
|
76
|
+
title: strip(md.ObjectName && md.ObjectName.value) || String(p.title || '').replace(/^File:/, ''),
|
|
77
|
+
creator: strip(md.Artist && md.Artist.value),
|
|
78
|
+
licence: strip(md.LicenseShortName && md.LicenseShortName.value),
|
|
79
|
+
licenceUrl: (md.LicenseUrl && md.LicenseUrl.value) || '',
|
|
80
|
+
url: ii.thumburl || ii.url,
|
|
81
|
+
original: ii.url || '',
|
|
82
|
+
page: ii.descriptionurl || '',
|
|
83
|
+
width: ii.width || null,
|
|
84
|
+
height: ii.height || null,
|
|
85
|
+
mime: ii.mime || '',
|
|
86
|
+
};
|
|
87
|
+
}).filter((r) => r.url && /^image\/(jpeg|png|webp|gif)/.test(r.mime || 'image/jpeg'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseAic(json) {
|
|
91
|
+
const base = (json.config && json.config.iiif_url) || 'https://www.artic.edu/iiif/2';
|
|
92
|
+
return (json.data || []).filter((d) => d.image_id && d.is_public_domain === true).map((d) => ({
|
|
93
|
+
source: 'aic',
|
|
94
|
+
title: d.title || '',
|
|
95
|
+
creator: String(d.artist_display || '').split('\n')[0],
|
|
96
|
+
licence: 'CC0 (public domain)',
|
|
97
|
+
licenceUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
|
|
98
|
+
url: `${base}/${d.image_id}/full/843,/0/default.jpg`,
|
|
99
|
+
page: `https://www.artic.edu/artworks/${d.id}`,
|
|
100
|
+
width: (d.thumbnail && d.thumbnail.width) || null,
|
|
101
|
+
height: (d.thumbnail && d.thumbnail.height) || null,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseMetObject(o) {
|
|
106
|
+
if (!o || o.isPublicDomain !== true || !o.primaryImageSmall) return null;
|
|
107
|
+
return {
|
|
108
|
+
source: 'met',
|
|
109
|
+
title: o.title || '',
|
|
110
|
+
creator: o.artistDisplayName || '',
|
|
111
|
+
licence: 'CC0 (Met Open Access)',
|
|
112
|
+
licenceUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
|
|
113
|
+
url: o.primaryImageSmall,
|
|
114
|
+
original: o.primaryImage || '',
|
|
115
|
+
page: o.objectURL || `https://www.metmuseum.org/art/collection/search/${o.objectID}`,
|
|
116
|
+
width: null,
|
|
117
|
+
height: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getJson(url, headers = {}, ms = 20000) {
|
|
122
|
+
const r = await publicFetch(url, { headers: { 'user-agent': UA, accept: 'application/json', ...headers }, signal: AbortSignal.timeout(ms) });
|
|
123
|
+
if (!r.ok) { await r.body?.cancel(); throw new Error(`${new URL(url).host} answered ${r.status}`); }
|
|
124
|
+
return JSON.parse((await readLimited(r, 2 * 1024 * 1024)).toString('utf8'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const SEARCH = {
|
|
128
|
+
async openverse(q, n) {
|
|
129
|
+
return parseOpenverse(await getJson(`https://api.openverse.org/v1/images/?q=${encodeURIComponent(q)}&page_size=${Math.min(n * 2, 40)}&mature=false`));
|
|
130
|
+
},
|
|
131
|
+
async commons(q, n) {
|
|
132
|
+
const u = 'https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6'
|
|
133
|
+
+ `&gsrsearch=${encodeURIComponent(q + ' filetype:bitmap')}&gsrlimit=${Math.min(n * 2, 40)}`
|
|
134
|
+
+ '&prop=imageinfo&iiprop=url|size|mime|extmetadata&iiurlwidth=1280'
|
|
135
|
+
+ '&iiextmetadatafilter=LicenseShortName|LicenseUrl|Artist|ObjectName';
|
|
136
|
+
return parseCommons(await getJson(u, { 'user-agent': UA_CONTACT }));
|
|
137
|
+
},
|
|
138
|
+
async aic(q, n) {
|
|
139
|
+
const u = `https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(q)}&limit=${Math.min(n * 3, 60)}`
|
|
140
|
+
+ '&fields=id,title,image_id,artist_display,is_public_domain,thumbnail';
|
|
141
|
+
return parseAic(await getJson(u, { 'AIC-User-Agent': UA_CONTACT }));
|
|
142
|
+
},
|
|
143
|
+
async met(q, n) {
|
|
144
|
+
const s = await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/search?hasImages=true&q=${encodeURIComponent(q)}`);
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const id of (s.objectIDs || []).slice(0, n * 4)) {
|
|
147
|
+
if (out.length >= n * 2) break;
|
|
148
|
+
try {
|
|
149
|
+
const r = parseMetObject(await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`));
|
|
150
|
+
if (r) out.push(r);
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/* Fetches the URL the way a page would load it. HEAD first; a server that
|
|
158
|
+
refuses HEAD (405, 403, 416 and friends) gets a one-byte ranged GET. The
|
|
159
|
+
result counts only if the answer is 2xx and says it is an image. */
|
|
160
|
+
export async function verifyImage(url, { fetchImpl = publicFetch, ms = 15000 } = {}) {
|
|
161
|
+
try { checkUrl(url); } catch (e) { return { ok: false, status: 0, type: '', error: e.message }; }
|
|
162
|
+
const tryOnce = async (method) => {
|
|
163
|
+
const headers = { 'user-agent': UA };
|
|
164
|
+
if (method === 'GET') headers.range = 'bytes=0-0';
|
|
165
|
+
const r = await fetchImpl(url, { method, headers, redirect: 'follow', signal: AbortSignal.timeout(ms) });
|
|
166
|
+
try { if (r.body && r.body.cancel) await r.body.cancel(); } catch {}
|
|
167
|
+
return { status: r.status, type: (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(),
|
|
168
|
+
url: r.url || url, checkedAt: new Date().toISOString(), evidence: 'response headers only; decode checked when tiled' };
|
|
169
|
+
};
|
|
170
|
+
let res;
|
|
171
|
+
try {
|
|
172
|
+
res = await tryOnce('HEAD');
|
|
173
|
+
if (!(res.status >= 200 && res.status < 300 && res.type.startsWith('image/'))) res = await tryOnce('GET');
|
|
174
|
+
} catch (e) {
|
|
175
|
+
try { res = await tryOnce('GET'); } catch (e2) { return { ok: false, status: 0, type: '', error: (e2 && e2.message) || String(e2) }; }
|
|
176
|
+
}
|
|
177
|
+
return { ok: res.status >= 200 && res.status < 300 && /^image\/(jpeg|png|webp|gif)$/.test(res.type), ...res };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
181
|
+
export function rasterType(bytes) {
|
|
182
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return { type: 'image/png', ext: 'png' };
|
|
183
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return { type: 'image/jpeg', ext: 'jpg' };
|
|
184
|
+
if (/^GIF8[79]a/.test(bytes.subarray(0, 6).toString('ascii'))) return { type: 'image/gif', ext: 'gif' };
|
|
185
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return { type: 'image/webp', ext: 'webp' };
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function downloadImage(url, { fetchImpl = publicFetch, maxBytes = MAX_IMAGE_BYTES, ms = 30000 } = {}) {
|
|
190
|
+
checkUrl(url);
|
|
191
|
+
const response = await fetchImpl(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(ms) });
|
|
192
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error('HTTP ' + response.status); }
|
|
193
|
+
const declared = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
194
|
+
if (!/^image\/(jpeg|png|gif|webp)$/.test(declared)) {
|
|
195
|
+
await response.body?.cancel();
|
|
196
|
+
throw new Error('unsupported raster image content type: ' + declared);
|
|
197
|
+
}
|
|
198
|
+
const bytes = await readLimited(response, maxBytes);
|
|
199
|
+
const actual = rasterType(bytes);
|
|
200
|
+
if (!actual || actual.type !== declared) throw new Error('image bytes do not match the declared raster format');
|
|
201
|
+
return { bytes, ext: actual.ext, url: response.url || url };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function mapLimit(items, limit, fn) {
|
|
205
|
+
const out = new Array(items.length);
|
|
206
|
+
let next = 0;
|
|
207
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
208
|
+
while (next < items.length) { const i = next++; out[i] = await fn(items[i], i); }
|
|
209
|
+
}));
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function searchImages(q, { sources = SOURCES, n = 6, commercial = false, verify = verifyImage } = {}) {
|
|
214
|
+
const errors = [];
|
|
215
|
+
const per = await Promise.all(sources.map(async (s) => {
|
|
216
|
+
try {
|
|
217
|
+
let rows = await SEARCH[s](q, n);
|
|
218
|
+
if (commercial) rows = rows.filter((r) => commercialOk(r.licence));
|
|
219
|
+
return rows.slice(0, n);
|
|
220
|
+
} catch (e) { errors.push({ source: s, error: (e && e.message) || String(e) }); return []; }
|
|
221
|
+
}));
|
|
222
|
+
const results = per.flat();
|
|
223
|
+
await mapLimit(results, 6, async (r) => { r.verified = await verify(r.url); });
|
|
224
|
+
numberResults(results);
|
|
225
|
+
return { query: q, sources, commercial, results, errors };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* One number per verified result, 1..k in list order, and none for a failed
|
|
229
|
+
one. The table, the compact lines, the moodboard tiles, the download file
|
|
230
|
+
names and --pick all use it, so "05" names one image everywhere. Before
|
|
231
|
+
1.1.0 the table counted failures and the sheet did not, so they parted
|
|
232
|
+
from the first failed result on. */
|
|
233
|
+
export function numberResults(results) {
|
|
234
|
+
let k = 0;
|
|
235
|
+
for (const r of results) {
|
|
236
|
+
if (r.verified && r.verified.ok) r.n = ++k;
|
|
237
|
+
else delete r.n;
|
|
238
|
+
}
|
|
239
|
+
return results;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const nn = (n) => String(n).padStart(2, '0');
|
|
243
|
+
|
|
244
|
+
export function formatTable(res) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
lines.push(`images "${res.query}" ${res.results.filter((r) => r.verified.ok).length} verified of ${res.results.length}`);
|
|
247
|
+
for (const e of res.errors) lines.push(` source ${e.source} failed: ${e.error}`);
|
|
248
|
+
for (const r of res.results) {
|
|
249
|
+
const mark = r.verified.ok ? 'ok ' : `FAIL ${r.verified.status || ''}`.padEnd(8);
|
|
250
|
+
lines.push(` ${r.verified.ok ? nn(r.n) : '--'} ${mark} [${r.source}] ${r.title.slice(0, 70)}${r.creator ? ' - ' + r.creator.slice(0, 40) : ''}`);
|
|
251
|
+
lines.push(` ${r.licence || 'licence not stated'} ${r.url}`);
|
|
252
|
+
if (r.page) lines.push(` page ${r.page}`);
|
|
253
|
+
if (r.downloadError) lines.push(` download failed: ${r.downloadError}`);
|
|
254
|
+
}
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Squeezes a field for a compact line: one line, no URL, at most max chars. */
|
|
259
|
+
const squeeze = (s, max) => {
|
|
260
|
+
const t = String(s || '').replace(/\b(https?|ftp):\/\/\S*/gi, '').replace(/\s+/g, ' ').trim();
|
|
261
|
+
return t.length > max ? t.slice(0, max - 3).trimEnd() + '...' : t;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/* "CC0 (Met Open Access)" -> "CC0": the source already says where it is from,
|
|
265
|
+
and --pick prints the licence in full. */
|
|
266
|
+
const shortLicence = (l) => squeeze(String(l || '').replace(/\s*\([^)]*\)\s*/g, ' '), 16) || 'licence?';
|
|
267
|
+
|
|
268
|
+
/* A path the details line can hand to a shell as it is. Git Bash, which
|
|
269
|
+
Claude Code runs on Windows, eats backslashes (C:\Users -> C:Users), and a
|
|
270
|
+
space splits the argument; Node reads C:/Users/... on Windows. */
|
|
271
|
+
const shellPath = (p) => {
|
|
272
|
+
const f = String(p).replace(/\\/g, '/');
|
|
273
|
+
return /^[\w./:@%+,=-]+$/.test(f) ? f : `"${f}"`;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/* The compact report: a header, one line per verified result, the sheet
|
|
277
|
+
lines, how many failed, and how to get the rest. Never a URL: those stay in
|
|
278
|
+
results.json and come out through --pick. */
|
|
279
|
+
export function formatCompact(res, { out, resultsPath = join(out, 'results.json') } = {}) {
|
|
280
|
+
const good = res.results.filter((r) => r.verified.ok);
|
|
281
|
+
const lines = [`images "${squeeze(res.query, 80)}" ${good.length}/${res.results.length} verified -> ${out}`];
|
|
282
|
+
for (const e of res.errors || []) lines.push(`source ${e.source} failed: ${squeeze(e.error, 60)}`);
|
|
283
|
+
for (const r of good) {
|
|
284
|
+
const creator = squeeze(r.creator, 24);
|
|
285
|
+
lines.push(`${nn(r.n)} ${r.source} ${shortLicence(r.licence)} | ${squeeze(r.title, 48) || 'untitled'}${creator ? ' | ' + creator : ''}`);
|
|
286
|
+
}
|
|
287
|
+
for (const s of res.sheets || []) {
|
|
288
|
+
const missing = s.tiles.filter((t) => !t.loaded).map((t) => nn(t.n));
|
|
289
|
+
lines.push(`sheet ${s.file} ${s.w}x${s.h} ${s.tokens} tok${missing.length ? ` (${missing.join(',')} did not load)` : ''}`);
|
|
290
|
+
}
|
|
291
|
+
if (res.sheetError) lines.push('sheet none: ' + squeeze(res.sheetError.split('\n')[0], 100));
|
|
292
|
+
const downloadsFailed = good.filter((r) => r.downloadError).length;
|
|
293
|
+
if (downloadsFailed) lines.push(`download failed ${downloadsFailed} (see results.json)`);
|
|
294
|
+
const failed = res.results.length - good.length;
|
|
295
|
+
if (failed) lines.push(`failed ${failed} (see results.json)`);
|
|
296
|
+
if (good.length) lines.push(`details: node images.mjs --pick ${good.slice(0, 2).map((r) => r.n).join(',')} --results ${shellPath(resultsPath)}`);
|
|
297
|
+
return lines.join('\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* --pick: the full record of chosen results from a saved run, three lines
|
|
301
|
+
each. Reads results.json only; no network. Throws on a number that is not
|
|
302
|
+
a verified result of that run. */
|
|
303
|
+
export function formatPick(saved, picks) {
|
|
304
|
+
const results = numberResults(saved.results || []).filter((r) => r.n);
|
|
305
|
+
const byN = new Map(results.map((r) => [r.n, r]));
|
|
306
|
+
const lines = [];
|
|
307
|
+
for (const n of picks) {
|
|
308
|
+
const r = byN.get(n);
|
|
309
|
+
if (!r) throw new Error(`no verified result ${n} in this run (${results.length ? '1-' + results.length : 'none verified'})`);
|
|
310
|
+
lines.push(`${nn(n)} ${r.title || 'untitled'} | ${r.creator || 'creator not stated'} | ${r.licence || 'licence not stated'}${r.licenceUrl ? ' ' + r.licenceUrl : ''}`);
|
|
311
|
+
lines.push(` image ${r.url}${r.file ? ' file ' + r.file : ''}`);
|
|
312
|
+
lines.push(` page ${r.page || 'not stated'}`);
|
|
313
|
+
}
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function pick(flags) {
|
|
318
|
+
const fail = (m) => { console.error('images: ' + m); process.exit(2); };
|
|
319
|
+
if (!flags.results) fail('--pick needs --results <path to results.json>');
|
|
320
|
+
let picks;
|
|
321
|
+
try { picks = String(flags.pick).split(',').map((v) => v.trim()).filter(Boolean).map((v) => int(v, 0, { min: 1, max: 999 })); }
|
|
322
|
+
catch (e) { fail('--pick ' + e.message); }
|
|
323
|
+
if (!picks.length) fail('--pick needs numbers, such as --pick 3,7');
|
|
324
|
+
let saved;
|
|
325
|
+
try { saved = JSON.parse(readFileSync(flags.results, 'utf8')); }
|
|
326
|
+
catch (e) { fail(`cannot read ${flags.results}: ${(e && e.code) || (e && e.message) || e}`); }
|
|
327
|
+
try { console.log(formatPick(saved, picks)); } catch (e) { fail(e.message); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function main() {
|
|
331
|
+
let a;
|
|
332
|
+
try { a = parseArgs(process.argv.slice(2), { switches: ['commercial', 'sheet', 'download', 'json', 'compact'] }); }
|
|
333
|
+
catch (e) { console.error('images: ' + e.message); process.exit(2); }
|
|
334
|
+
const { positional, flags } = a;
|
|
335
|
+
if (flags.pick !== undefined) return pick(flags);
|
|
336
|
+
const q = positional.join(' ').trim();
|
|
337
|
+
if (!q) { console.error('usage: node images.mjs "<query>" [--sources ' + SOURCES.join(',') + '] [--n 6] [--commercial] [--sheet] [--download] [--compact] [--out dir] [--json]\n node images.mjs --pick 3,7 --results <path to results.json>'); process.exit(2); }
|
|
338
|
+
const compact = !!flags.compact;
|
|
339
|
+
const sources = String(flags.sources || SOURCES.join(',')).split(',').map((s) => s.trim()).filter(Boolean);
|
|
340
|
+
const unknown = sources.filter((s) => !SOURCES.includes(s));
|
|
341
|
+
if (unknown.length) { console.error(`images: unknown source ${unknown.join(', ')}. Sources: ${SOURCES.join(', ')}`); process.exit(2); }
|
|
342
|
+
let n;
|
|
343
|
+
try { n = int(flags.n, compact ? 4 : 6, { min: 1, max: 20 }); } catch (e) { console.error('images: --n ' + e.message); process.exit(2); }
|
|
344
|
+
const out = resolve(flags.out || join(tmpdir(), 'image-deep-research', 'images-' + new Date().toISOString().replace(/[:.]/g, '-')));
|
|
345
|
+
mkdirSync(out, { recursive: true });
|
|
346
|
+
|
|
347
|
+
const res = await searchImages(q, { sources, n, commercial: !!flags.commercial });
|
|
348
|
+
const good = res.results.filter((r) => r.verified.ok);
|
|
349
|
+
|
|
350
|
+
if ((flags.download || flags.sheet || compact) && good.length) {
|
|
351
|
+
const dir = join(out, 'images');
|
|
352
|
+
mkdirSync(dir, { recursive: true });
|
|
353
|
+
await mapLimit(good, 4, async (r) => {
|
|
354
|
+
try {
|
|
355
|
+
const image = await downloadImage(r.url);
|
|
356
|
+
const file = join(dir, `${nn(r.n)}-${r.source}.${image.ext}`);
|
|
357
|
+
writeFileSync(file, image.bytes);
|
|
358
|
+
r.file = file;
|
|
359
|
+
r.downloaded = { bytes: image.bytes.length, url: image.url, checkedAt: new Date().toISOString() };
|
|
360
|
+
} catch (e) { r.downloadError = (e && e.message) || String(e); }
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if ((flags.sheet || compact) && good.length) {
|
|
365
|
+
try {
|
|
366
|
+
if (!good.some((r) => r.file)) throw new Error('no raster files downloaded; see downloadError in results.json');
|
|
367
|
+
const { withBrowser } = await import('./browser.mjs');
|
|
368
|
+
const { renderSheets } = await import('./sheet.mjs');
|
|
369
|
+
res.sheets = await withBrowser((session) => renderSheets(session,
|
|
370
|
+
good.filter((r) => r.file).map((r) => ({ n: r.n, src: r.file, label: `[${r.source}] ${r.licence} ${r.title}` })), { out, prefix: 'moodboard', fit: 'contain', compact }));
|
|
371
|
+
for (const sheet of res.sheets) for (const tile of sheet.tiles) {
|
|
372
|
+
const result = good.find((r) => r.n === tile.n);
|
|
373
|
+
if (result) result.decoded = tile.loaded;
|
|
374
|
+
}
|
|
375
|
+
} catch (e) { res.sheetError = (e && e.message) || String(e); }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
writeFileSync(join(out, 'results.json'), JSON.stringify(res, null, 2));
|
|
379
|
+
if (flags.json) { console.log(JSON.stringify(res, null, 2)); }
|
|
380
|
+
else if (compact) console.log(formatCompact(res, { out }));
|
|
381
|
+
else {
|
|
382
|
+
console.log(formatTable(res));
|
|
383
|
+
if (res.sheets) for (const s of res.sheets) {
|
|
384
|
+
const missing = s.tiles.filter((t) => !t.loaded).map((t) => t.n);
|
|
385
|
+
console.log(` moodboard ${s.file}${missing.length ? ' (tiles ' + missing.join(', ') + ' did not load in the browser)' : ''}`);
|
|
386
|
+
}
|
|
387
|
+
if (res.sheetError) console.log(' no moodboard: ' + res.sheetError.split('\n')[0]);
|
|
388
|
+
console.log(' results: ' + join(out, 'results.json'));
|
|
389
|
+
}
|
|
390
|
+
if (!good.length || ((flags.sheet || compact) && !(res.sheets || []).some((s) => s.tiles.some((t) => t.loaded)))) process.exitCode = 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* Public collection responses are untrusted. Resolve every redirect, reject
|
|
2
|
+
private destinations, and pin the checked address for the actual socket. */
|
|
3
|
+
import { lookup } from 'node:dns/promises';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { request as httpRequest } from 'node:http';
|
|
6
|
+
import { request as httpsRequest } from 'node:https';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
|
|
9
|
+
export function publicAddress(address) {
|
|
10
|
+
if (isIP(address) === 4) {
|
|
11
|
+
const [a, b, c] = address.split('.').map(Number);
|
|
12
|
+
return !(a === 0 || a === 10 || a === 127 || a >= 224
|
|
13
|
+
|| (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
14
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2))))
|
|
15
|
+
|| (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
|
|
16
|
+
|| (a === 203 && b === 0 && c === 113));
|
|
17
|
+
}
|
|
18
|
+
if (isIP(address) !== 6) return false;
|
|
19
|
+
const first = Number.parseInt(address.split(':')[0], 16);
|
|
20
|
+
const second = Number.parseInt(address.split(':')[1] || '0', 16);
|
|
21
|
+
// Only global unicast; exclude documentation, transition and special-use ranges.
|
|
22
|
+
return first >= 0x2000 && first < 0x3fff && first !== 0x2002
|
|
23
|
+
&& !(first === 0x2001 && (second < 0x200 || second === 0xdb8));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function checkUrl(value) {
|
|
27
|
+
const u = new URL(value);
|
|
28
|
+
if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password)
|
|
29
|
+
throw new Error('image URL must use HTTP(S) without credentials');
|
|
30
|
+
const hostname = u.hostname.replace(/^\[|\]$/g, '');
|
|
31
|
+
if (isIP(hostname) && !publicAddress(hostname)) throw new Error('private or special-use image destination blocked');
|
|
32
|
+
if (hostname === 'localhost' || /\.(?:localhost|local|internal)$/i.test(hostname))
|
|
33
|
+
throw new Error('private image destination blocked');
|
|
34
|
+
return u;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function publicFetch(value, { method = 'GET', headers = {}, signal,
|
|
38
|
+
lookupImpl = lookup, requestImpl, maxRedirects = 5 } = {}) {
|
|
39
|
+
let u = checkUrl(value);
|
|
40
|
+
for (let redirects = 0; ; redirects++) {
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
const hostname = u.hostname.replace(/^\[|\]$/g, '');
|
|
43
|
+
let onAbort;
|
|
44
|
+
const aborted = signal && new Promise((_, reject) => {
|
|
45
|
+
onAbort = () => reject(signal.reason);
|
|
46
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
47
|
+
});
|
|
48
|
+
let addresses;
|
|
49
|
+
try {
|
|
50
|
+
const resolving = isIP(hostname) ? Promise.resolve([{ address: hostname, family: isIP(hostname) }])
|
|
51
|
+
: lookupImpl(hostname, { all: true, verbatim: true });
|
|
52
|
+
addresses = await (aborted ? Promise.race([resolving, aborted]) : resolving);
|
|
53
|
+
} finally { if (onAbort) signal.removeEventListener('abort', onAbort); }
|
|
54
|
+
if (!addresses.length || addresses.some((a) => !publicAddress(a.address)))
|
|
55
|
+
throw new Error('private or special-use image destination blocked');
|
|
56
|
+
const chosen = addresses[0];
|
|
57
|
+
const request = requestImpl || (u.protocol === 'https:' ? httpsRequest : httpRequest);
|
|
58
|
+
const res = await new Promise((resolve, reject) => {
|
|
59
|
+
const req = request(u, { method, headers, signal, agent: false,
|
|
60
|
+
lookup: (_host, options, cb) => cb(null, options?.all ? [chosen] : chosen.address, chosen.family) }, resolve);
|
|
61
|
+
req.once('error', reject);
|
|
62
|
+
req.end();
|
|
63
|
+
});
|
|
64
|
+
const responseHeaders = new Headers();
|
|
65
|
+
for (const [name, val] of Object.entries(res.headers)) {
|
|
66
|
+
if (val !== undefined) responseHeaders.set(name, Array.isArray(val) ? val.join(', ') : String(val));
|
|
67
|
+
}
|
|
68
|
+
const status = res.statusCode;
|
|
69
|
+
const location = responseHeaders.get('location');
|
|
70
|
+
if ([301, 302, 303, 307, 308].includes(status) && location) {
|
|
71
|
+
res.destroy();
|
|
72
|
+
if (redirects >= maxRedirects) throw new Error('too many image redirects');
|
|
73
|
+
u = checkUrl(new URL(location, u).href);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const response = new Response(method === 'HEAD' || [204, 205, 304].includes(status) ? null : Readable.toWeb(res),
|
|
77
|
+
{ status, headers: responseHeaders });
|
|
78
|
+
if (method === 'HEAD') res.destroy();
|
|
79
|
+
Object.defineProperty(response, 'url', { value: u.href });
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function readLimited(response, maxBytes) {
|
|
85
|
+
const declared = Number(response.headers.get('content-length'));
|
|
86
|
+
if (declared > maxBytes) {
|
|
87
|
+
await response.body?.cancel();
|
|
88
|
+
throw new Error(`response exceeds ${maxBytes} bytes`);
|
|
89
|
+
}
|
|
90
|
+
const reader = response.body?.getReader();
|
|
91
|
+
if (!reader) return Buffer.alloc(0);
|
|
92
|
+
const parts = [];
|
|
93
|
+
let size = 0;
|
|
94
|
+
try {
|
|
95
|
+
while (true) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (done) break;
|
|
98
|
+
size += value.byteLength;
|
|
99
|
+
if (size > maxBytes) throw new Error(`response exceeds ${maxBytes} bytes`);
|
|
100
|
+
parts.push(Buffer.from(value));
|
|
101
|
+
}
|
|
102
|
+
return Buffer.concat(parts, size);
|
|
103
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
104
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* image-deep-research/sheet - tile pictures into one contact sheet.
|
|
2
|
+
|
|
3
|
+
The browser does the tiling: a small HTML page lays the images out on a
|
|
4
|
+
4 x 2 grid, each cell numbered, and the page is screenshotted. No ffmpeg,
|
|
5
|
+
no image codec, and it works the same for local screenshots and for remote
|
|
6
|
+
image URLs (a moodboard). One sheet costs one image read instead of eight.
|
|
7
|
+
|
|
8
|
+
--compact draws a denser sheet, sized for what a Claude model pays: 16
|
|
9
|
+
tiles with a number badge and no caption row, 1288 x 812 px, which is
|
|
10
|
+
46 x 29 patches of 28 px, 1,334 image tokens, under every model's cap. */
|
|
11
|
+
|
|
12
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { join, relative, isAbsolute, dirname, resolve, sep } from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { load, screenshot } from './browser.mjs';
|
|
16
|
+
|
|
17
|
+
export const PER_SHEET = 8;
|
|
18
|
+
export const COLS = 4;
|
|
19
|
+
export const CELL_W = 640;
|
|
20
|
+
export const CELL_H = 400;
|
|
21
|
+
|
|
22
|
+
/* The compact geometry: 4 x 4 cells of 317 x 198 with a 4 px gap and pad. */
|
|
23
|
+
export const COMPACT = { cols: 4, perSheet: 16, cellW: 317, cellH: 198, gap: 4, pad: 4 };
|
|
24
|
+
export const COMPACT_MAX_BYTES = 150000;
|
|
25
|
+
export const COMPACT_QUALITIES = [80, 70, 60];
|
|
26
|
+
|
|
27
|
+
/* What an image costs a Claude model, as Anthropic's vision docs price it
|
|
28
|
+
(platform.claude.com/docs/en/build-with-claude/vision, read 2026-09-29):
|
|
29
|
+
ceil(w/28) x ceil(h/28) tokens, after the image is scaled down, keeping its
|
|
30
|
+
aspect ratio, to the largest size that fits both the tier's long edge and
|
|
31
|
+
its token cap. The high tier (Claude 4.7 and later) allows a 2576 px long
|
|
32
|
+
edge and 4,784 tokens; the standard tier 1568 px and 1,568 tokens. */
|
|
33
|
+
export const PATCH = 28;
|
|
34
|
+
export const TIERS = { high: { edge: 2576, cap: 4784 }, standard: { edge: 1568, cap: 1568 } };
|
|
35
|
+
|
|
36
|
+
export function visionTokens(w, h, { tier = 'high' } = {}) {
|
|
37
|
+
const t = TIERS[tier];
|
|
38
|
+
if (!t) throw new Error(`unknown tier "${tier}"`);
|
|
39
|
+
const cost = (W, H) => Math.ceil(W / PATCH) * Math.ceil(H / PATCH);
|
|
40
|
+
const long = Math.max(w, h);
|
|
41
|
+
const at = (edge) => {
|
|
42
|
+
const s = edge / long;
|
|
43
|
+
return [Math.max(1, Math.round(w * s)), Math.max(1, Math.round(h * s))];
|
|
44
|
+
};
|
|
45
|
+
let edge = Math.min(long, t.edge);
|
|
46
|
+
if (cost(...at(edge)) > t.cap) {
|
|
47
|
+
// The cost only grows with the long edge, so the largest edge that fits
|
|
48
|
+
// the cap is found by halving the interval.
|
|
49
|
+
let lo = 1, hi = edge;
|
|
50
|
+
while (lo < hi) {
|
|
51
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
52
|
+
if (cost(...at(mid)) <= t.cap) lo = mid; else hi = mid - 1;
|
|
53
|
+
}
|
|
54
|
+
edge = lo;
|
|
55
|
+
}
|
|
56
|
+
return cost(...at(edge));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* The pixel size of a sheet of count tiles in a badge-only geometry. */
|
|
60
|
+
export function sheetSize(count, geom = COMPACT) {
|
|
61
|
+
const cols = Math.max(1, Math.min(geom.cols, count));
|
|
62
|
+
const rows = Math.max(1, Math.ceil(count / geom.cols));
|
|
63
|
+
const w = 2 * geom.pad + cols * geom.cellW + (cols - 1) * geom.gap;
|
|
64
|
+
const h = 2 * geom.pad + rows * geom.cellH + (rows - 1) * geom.gap;
|
|
65
|
+
return { w, h, tokens: visionTokens(w, h) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
69
|
+
|
|
70
|
+
/* Splits items into sheets of PER_SHEET, numbering every tile across the run
|
|
71
|
+
so "tile 11" means the same thing in the sheet and in the printed legend.
|
|
72
|
+
An item that already carries its number keeps it: images.mjs numbers the
|
|
73
|
+
verified results, and a failed one before them must not shift the tiles. */
|
|
74
|
+
export function planSheets(items, per = PER_SHEET) {
|
|
75
|
+
const sheets = [];
|
|
76
|
+
items.forEach((item, i) => {
|
|
77
|
+
const s = Math.floor(i / per);
|
|
78
|
+
(sheets[s] ||= []).push({ ...item, n: item.n ?? i + 1 });
|
|
79
|
+
});
|
|
80
|
+
return sheets;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* The HTML for one sheet. src is a URL, or a local path that is written
|
|
84
|
+
relative to the sheet so the page can load it from disk. */
|
|
85
|
+
export function sheetHtml(tiles, { baseDir, fit = 'cover', title = '', compact = false } = {}) {
|
|
86
|
+
const src = (s) => {
|
|
87
|
+
if (/^(https?|data):/i.test(s)) return s;
|
|
88
|
+
const p = isAbsolute(s) ? s : resolve(s);
|
|
89
|
+
return relative(baseDir, p).split(sep).map(encodeURIComponent).join('/');
|
|
90
|
+
};
|
|
91
|
+
if (compact) return compactHtml(tiles, { src, fit, title });
|
|
92
|
+
const cells = tiles.map((t) => `<figure><img src="${esc(src(t.src))}" alt="" referrerpolicy="no-referrer"><figcaption>${String(t.n).padStart(2, '0')} ${esc(t.label || '')}</figcaption></figure>`).join('\n');
|
|
93
|
+
return `<!doctype html><meta charset="utf-8"><title>${esc(title)}</title>
|
|
94
|
+
<style>
|
|
95
|
+
html,body{margin:0;background:#111}
|
|
96
|
+
main{display:grid;grid-template-columns:repeat(${Math.max(1, Math.min(COLS, tiles.length))},${CELL_W}px);gap:6px;padding:6px;width:max-content}
|
|
97
|
+
figure{margin:0;background:#1b1b1b}
|
|
98
|
+
img{display:block;width:${CELL_W}px;height:${CELL_H}px;object-fit:${fit === 'contain' ? 'contain' : 'cover'};object-position:${fit === 'contain' ? 'center' : 'top center'}}
|
|
99
|
+
figcaption{font:13px/1.2 ui-monospace,Consolas,monospace;color:#ddd;padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:${CELL_W - 12}px}
|
|
100
|
+
</style>
|
|
101
|
+
<main>
|
|
102
|
+
${cells}
|
|
103
|
+
</main>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* The compact sheet: no caption row, only the tile's number on a dark badge
|
|
107
|
+
in its top-left corner (or the tile's own badge text, such as s03 for a
|
|
108
|
+
site). Every other fact about a tile goes out as text. */
|
|
109
|
+
function compactHtml(tiles, { src, fit, title }) {
|
|
110
|
+
const g = COMPACT;
|
|
111
|
+
const cells = tiles.map((t) => `<figure><img src="${esc(src(t.src))}" alt="" referrerpolicy="no-referrer"><span>${esc(t.badge ?? String(t.n).padStart(2, '0'))}</span></figure>`).join('\n');
|
|
112
|
+
return `<!doctype html><meta charset="utf-8"><title>${esc(title)}</title>
|
|
113
|
+
<style>
|
|
114
|
+
html,body{margin:0;background:#111}
|
|
115
|
+
main{display:grid;grid-template-columns:repeat(${Math.max(1, Math.min(g.cols, tiles.length))},${g.cellW}px);gap:${g.gap}px;padding:${g.pad}px;width:max-content}
|
|
116
|
+
figure{margin:0;position:relative;background:#1b1b1b;width:${g.cellW}px;height:${g.cellH}px}
|
|
117
|
+
img{display:block;width:${g.cellW}px;height:${g.cellH}px;object-fit:${fit === 'contain' ? 'contain' : 'cover'};object-position:${fit === 'contain' ? 'center' : 'top center'}}
|
|
118
|
+
span{position:absolute;top:0;left:0;font:12px/1 ui-monospace,Consolas,monospace;color:#fff;background:rgba(0,0,0,.7);padding:2px 4px}
|
|
119
|
+
</style>
|
|
120
|
+
<main>
|
|
121
|
+
${cells}
|
|
122
|
+
</main>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Takes a picture at each quality in turn until one is at most maxBytes, so a
|
|
126
|
+
busy sheet still fits a Read without a silent downscale. shoot(quality)
|
|
127
|
+
returns the JPEG bytes. The last quality is kept even when it is over. */
|
|
128
|
+
export async function underBytes(shoot, { maxBytes = COMPACT_MAX_BYTES, qualities = COMPACT_QUALITIES } = {}) {
|
|
129
|
+
let buf, quality;
|
|
130
|
+
for (quality of qualities) {
|
|
131
|
+
buf = await shoot(quality);
|
|
132
|
+
if (buf.length <= maxBytes) break;
|
|
133
|
+
}
|
|
134
|
+
return { buf, quality };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Renders every sheet with an open browser session. Returns
|
|
138
|
+
[{ file, tiles: [{n, label, src, loaded}] }]. A tile whose image failed to
|
|
139
|
+
load is reported as loaded:false rather than silently left grey. With
|
|
140
|
+
compact, sixteen to a sheet in the compact geometry, and each sheet also
|
|
141
|
+
reports { w, h, bytes, quality, tokens }. */
|
|
142
|
+
export async function renderSheets(session, items, { out, prefix = 'sheet', fit = 'cover', per, compact = false } = {}) {
|
|
143
|
+
mkdirSync(out, { recursive: true });
|
|
144
|
+
per ??= compact ? COMPACT.perSheet : PER_SHEET;
|
|
145
|
+
const results = [];
|
|
146
|
+
for (const [i, tiles] of planSheets(items, per).entries()) {
|
|
147
|
+
const html = join(out, `_${prefix}${i + 1}.html`);
|
|
148
|
+
writeFileSync(html, sheetHtml(tiles, { baseDir: dirname(html), fit, title: `${prefix} ${i + 1}`, compact }));
|
|
149
|
+
const width = compact ? sheetSize(COMPACT.cols, COMPACT).w : COLS * (CELL_W + 6) + 6;
|
|
150
|
+
await load(session, pathToFileURL(html).href, { width, height: 900, wait: 200 });
|
|
151
|
+
const state = await session.evaluate(`Promise.all([...document.images].map((im) =>
|
|
152
|
+
im.complete ? 0 : new Promise((r) => { im.onload = im.onerror = r; setTimeout(r, 15000); })))
|
|
153
|
+
.then(() => { const m = document.querySelector('main').getBoundingClientRect();
|
|
154
|
+
return { w: Math.ceil(m.width), h: Math.ceil(m.height), ok: [...document.images].map((im) => im.naturalWidth > 0) }; })`, { awaitPromise: true });
|
|
155
|
+
const file = join(out, `${prefix}${i + 1}.jpg`);
|
|
156
|
+
const clip = { x: 0, y: 0, width: state.w, height: state.h };
|
|
157
|
+
const shown = tiles.map((t, k) => ({ n: t.n, label: t.label, src: t.src, loaded: !!state.ok[k] }));
|
|
158
|
+
if (!compact) {
|
|
159
|
+
writeFileSync(file, await screenshot(session, { format: 'jpeg', quality: 84, clip }));
|
|
160
|
+
results.push({ file, tiles: shown });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const { buf, quality } = await underBytes((q) => screenshot(session, { format: 'jpeg', quality: q, clip }));
|
|
164
|
+
writeFileSync(file, buf);
|
|
165
|
+
results.push({ file, w: state.w, h: state.h, bytes: buf.length, quality, tokens: visionTokens(state.w, state.h), tiles: shown });
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|