dsh-reference-frames 0.0.0-stage → 1.6.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/index.js ADDED
@@ -0,0 +1,1478 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { homedir } from 'node:os'
7
+
8
+ const API_BASE = 'https://framethrower.ai/api/v1'
9
+ // FrameThrower keeps three independent libraries behind one token. The client
10
+ // treats each as its own switchable source; the host maps a source to the right
11
+ // endpoints and normalizes the three different response shapes into one frame.
12
+ const FRAME_SOURCES = {
13
+ animation: {
14
+ search: '/animation/frames/search',
15
+ similar: '/animation/frames/similar',
16
+ },
17
+ film: {
18
+ search: '/search',
19
+ similar: '/similar',
20
+ // no image endpoint: /search/image only accepts a publicly reachable imageUrl
21
+ },
22
+ commercial: {
23
+ search: '/commercials/shots/search',
24
+ similar: '/commercials/shots/similar',
25
+ image: '/commercials/shots/image',
26
+ },
27
+ }
28
+ const FRAME_SOURCE_KEYS = Object.keys(FRAME_SOURCES)
29
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url))
30
+ // boards the package ships as a starting point; the live file lives in the
31
+ // user's data directory so an npm update can never overwrite a saved board
32
+ const BUNDLED_PRESET = join(PLUGIN_DIR, 'presets', 'moodboards.json')
33
+ const DATA_DIR = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'reference-frames')
34
+ const PRESET_PATH = join(DATA_DIR, 'moodboards.json')
35
+ const run = promisify(execFile)
36
+ const CACHE_TTL_MS = 30 * 60 * 1000
37
+ const VOCAB_TTL_MS = 24 * 60 * 60 * 1000
38
+ const CACHE_MAX = 120
39
+ const WEB_CACHE_TTL_MS = 15 * 60 * 1000
40
+ const WEB_CACHE_MAX = 80
41
+ const IMAGE_TTL_MS = 24 * 60 * 60 * 1000
42
+ const IMAGE_MAX_BYTES = 12 * 1024 * 1024
43
+ const IMAGE_CACHE_BYTES = 64 * 1024 * 1024
44
+ // Web image search reads public endpoints: Bing Images' server-rendered result
45
+ // page first, with DuckDuckGo's Bing-backed image endpoint as a fallback. No
46
+ // key, no quota, and the endpoints can change or throttle without notice.
47
+ const WEB_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
48
+ const NON_LATIN_RE = /[\u0370-\u03ff\u0400-\u052f\u0530-\u058f\u0590-\u05ff\u0600-\u06ff\u0900-\u097f\u0e00-\u0e7f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]/
49
+
50
+ /** Pick the search market from the query itself, not from the panel UI. */
51
+ function marketFor(query) {
52
+ const value = String(query || '')
53
+ if (/[\u3040-\u30ff]/.test(value)) return 'ja-JP'
54
+ if (/[\uac00-\ud7af]/.test(value)) return 'ko-KR'
55
+ if (/[\u3400-\u9fff]/.test(value)) return 'zh-CN'
56
+ if (/[\u0400-\u052f]/.test(value)) return 'ru-RU'
57
+ if (/[\u0600-\u06ff]/.test(value)) return 'ar'
58
+ if (/[\u0590-\u05ff]/.test(value)) return 'he-IL'
59
+ if (/[\u0e00-\u0e7f]/.test(value)) return 'th-TH'
60
+ if (/[\u0900-\u097f]/.test(value)) return 'hi-IN'
61
+ return 'en-US'
62
+ }
63
+
64
+ function acceptLanguage(mkt) {
65
+ const market = String(mkt || 'en-US')
66
+ const base = market.split('-')[0]
67
+ if (base === 'en') return 'en-US,en;q=0.9'
68
+ if (!market.includes('-')) return `${market},en;q=0.8`
69
+ return `${market},${base};q=0.9,en;q=0.8`
70
+ }
71
+
72
+ /**
73
+ * Host half: proxies FrameThrower so the browser never talks to it directly,
74
+ * and caches search results for the session.
75
+ */
76
+ export const inject = ['webServer']
77
+
78
+ export function apply(ctx, config) {
79
+ const configuredToken = typeof config?.apiToken === 'string' ? config.apiToken.trim() : ''
80
+
81
+ const cache = new Map()
82
+ const webCache = new Map()
83
+ const imageCache = new Map()
84
+ const interpretCache = new Map()
85
+ let imageBytes = 0
86
+ let vocabulary = null
87
+ let lastDebug = null
88
+
89
+ // Boards are saved in the user's data directory. Only a git checkout of this
90
+ // repo can publish them back to GitHub; an npm install has no `.git` beside
91
+ // the files, so the upload action is hidden there (the host reports it).
92
+ const pushOpts = { cwd: PLUGIN_DIR, timeout: 25_000, maxBuffer: 1 << 20 }
93
+
94
+ async function isCheckout() {
95
+ try {
96
+ await run('git', ['rev-parse', '--is-inside-work-tree'], { ...pushOpts, timeout: 8_000 })
97
+ return true
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ async function readBoards(file) {
104
+ try {
105
+ const parsed = JSON.parse(await readFile(file, 'utf8'))
106
+ if (Array.isArray(parsed)) return parsed
107
+ if (parsed && Array.isArray(parsed.boards)) return parsed.boards
108
+ return []
109
+ } catch {
110
+ return []
111
+ }
112
+ }
113
+
114
+ // first run: seed the user file from the boards the package ships with
115
+ async function readPresets() {
116
+ const boards = await readBoards(PRESET_PATH)
117
+ if (boards.length) return boards
118
+ const seed = await readBoards(BUNDLED_PRESET)
119
+ if (seed.length) {
120
+ try {
121
+ await mkdir(DATA_DIR, { recursive: true })
122
+ await writeFile(PRESET_PATH, JSON.stringify(seed, null, 2) + '\n', 'utf8')
123
+ } catch {}
124
+ }
125
+ return seed
126
+ }
127
+
128
+ // the repo is pushed with the machine's own git credentials (ssh key or stored helper),
129
+ // so no token ever has to live in the plugin config
130
+ async function pushPresets(cfg, boards) {
131
+ const branch = typeof cfg?.pushBranch === 'string' && cfg.pushBranch.trim() ? cfg.pushBranch.trim() : 'main'
132
+ const remote = typeof cfg?.pushRemote === 'string' && cfg.pushRemote.trim() ? cfg.pushRemote.trim() : 'origin'
133
+ try {
134
+ await writeFile(BUNDLED_PRESET, JSON.stringify(boards, null, 2) + '\n', 'utf8')
135
+ await run('git', ['add', 'presets/moodboards.json'], pushOpts)
136
+ try {
137
+ await run('git', ['commit', '-m', 'Update moodboard presets'], pushOpts)
138
+ } catch {
139
+ // nothing to commit is fine: the file may be identical
140
+ }
141
+ await run('git', ['push', remote, `HEAD:${branch}`], pushOpts)
142
+ return { pushed: true, branch }
143
+ } catch (error) {
144
+ const detail = String((error && (error.stderr || error.message)) || error).trim().split('\n').slice(-2).join(' ')
145
+ return { pushed: false, branch, pushError: detail.slice(0, 240) }
146
+ }
147
+ }
148
+
149
+ // ---------------------------------------------------------------- 版本
150
+ // 更新由 Harness 插件页管理(npm 注册表上的 bundle 版本列表 + 更新按钮),
151
+ // 插件这里只报告自己装在哪个版本。
152
+ let pluginVersion = ''
153
+ async function version() {
154
+ if (pluginVersion) return pluginVersion
155
+ try {
156
+ const parsed = JSON.parse(await readFile(join(PLUGIN_DIR, 'package.json'), 'utf8'))
157
+ if (typeof parsed?.version === 'string') pluginVersion = parsed.version
158
+ } catch {}
159
+ return pluginVersion
160
+ }
161
+
162
+ async function handle(req, res) {
163
+ let url
164
+ try {
165
+ url = new URL(req.url ?? '/', 'http://localhost')
166
+ } catch {
167
+ return send(res, 400, { error: 'bad url' })
168
+ }
169
+ const path = url.pathname.replace(/\/+$/, '')
170
+
171
+ if (req.method === 'OPTIONS') return send(res, 204, null)
172
+
173
+ if (req.method === 'GET' && path === '/anistyle/presets') {
174
+ return send(res, 200, { source: 'presets', boards: await readPresets(), checkout: await isCheckout() })
175
+ }
176
+
177
+ if (req.method === 'POST' && path === '/anistyle/presets') {
178
+ let body
179
+ try {
180
+ body = await readJson(req)
181
+ } catch {
182
+ return send(res, 400, { error: 'invalid json' })
183
+ }
184
+ const boards = Array.isArray(body?.boards) ? body.boards : null
185
+ if (!boards) return send(res, 400, { error: 'boards must be an array' })
186
+ try {
187
+ await mkdir(dirname(PRESET_PATH), { recursive: true })
188
+ await writeFile(PRESET_PATH, JSON.stringify(boards, null, 2) + '\n', 'utf8')
189
+ } catch (error) {
190
+ return send(res, 500, { error: String(error && error.message ? error.message : error) })
191
+ }
192
+ if (config?.autoPush === false) {
193
+ return send(res, 200, { source: 'presets', saved: boards.length, pushed: false })
194
+ }
195
+ // only a checkout has a repo to push into; a registry install saves locally
196
+ if (!(await isCheckout())) {
197
+ return send(res, 200, { source: 'presets', saved: boards.length, pushed: false, local: true })
198
+ }
199
+ const push = await pushPresets(config, boards)
200
+ return send(res, 200, { source: 'presets', saved: boards.length, ...push })
201
+ }
202
+
203
+ if (req.method === 'GET' && path === '/anistyle/last') {
204
+ return send(res, 200, lastDebug || { empty: true })
205
+ }
206
+
207
+ // "清空": wipe every in-memory cache so the next search / "换一批" is fresh.
208
+ // Best-effort from the client (no auth, nothing except caches is touched).
209
+ if (req.method === 'POST' && path === '/anistyle/clear-cache') {
210
+ try { await readJson(req) } catch {}
211
+ const cleared = {
212
+ frames: cache.size,
213
+ web: webCache.size,
214
+ images: imageCache.size,
215
+ interpret: interpretCache.size,
216
+ vocabulary: vocabulary ? 1 : 0,
217
+ }
218
+ cache.clear()
219
+ webCache.clear()
220
+ imageCache.clear()
221
+ imageBytes = 0
222
+ interpretCache.clear()
223
+ vocabulary = null
224
+ lastDebug = null
225
+ return send(res, 200, { ok: true, cleared })
226
+ }
227
+
228
+ if (req.method === 'GET' && path === '/anistyle/vocabulary') {
229
+ const token = resolveToken(req)
230
+ if (!token) return send(res, 200, { source: 'unconfigured' })
231
+ if (vocabulary && Date.now() - vocabulary.at < VOCAB_TTL_MS) {
232
+ return send(res, 200, { source: 'vocabulary', data: vocabulary.data })
233
+ }
234
+ const result = await callUpstream('/animation/vocabulary', null, token, 'GET')
235
+ if (!result.ok) return send(res, 200, result.failure)
236
+ const data = result.json?.data ?? result.json ?? null
237
+ vocabulary = { at: Date.now(), data }
238
+ return send(res, 200, { source: 'vocabulary', data })
239
+ }
240
+
241
+ if (req.method === 'POST' && path === '/anistyle/interpret') {
242
+ let body
243
+ try {
244
+ body = await readJson(req)
245
+ } catch {
246
+ return send(res, 400, { error: 'invalid json' })
247
+ }
248
+ const source = text(body?.text, 600)
249
+ if (!source) return send(res, 200, { source: 'none' })
250
+ const count = clampInt(body?.count, 1, 3, 3)
251
+ const refresh = body?.refresh === true
252
+ const { params, reason, detail } = await interpretQuery(source, count, refresh)
253
+ return send(res, 200, params ? { source: 'model', params } : { source: 'none', reason, detail })
254
+ }
255
+
256
+ if (req.method === 'POST' && path === '/anistyle/interpret-image') {
257
+ let body
258
+ try {
259
+ body = await readJson(req)
260
+ } catch {
261
+ return send(res, 400, { error: 'invalid json' })
262
+ }
263
+ const { ok, web, reason, detail } = await describeImageForWeb(body)
264
+ return send(res, 200, ok ? { source: 'model', params: { web } } : { source: 'none', reason, detail })
265
+ }
266
+
267
+ if (req.method === 'GET' && path === '/anistyle/img') {
268
+ return proxyImage(url, res)
269
+ }
270
+
271
+ if (req.method === 'POST' && path === '/anistyle/web') {
272
+ let body
273
+ try {
274
+ body = await readJson(req)
275
+ } catch {
276
+ return send(res, 400, { error: 'invalid json' })
277
+ }
278
+ return searchWeb(body, res)
279
+ }
280
+
281
+ if (req.method === 'POST' && (path === '/anistyle/frames' || path === '/anistyle/similar')) {
282
+ let body
283
+ try {
284
+ body = await readJson(req)
285
+ } catch {
286
+ return send(res, 400, { error: 'invalid json' })
287
+ }
288
+ const token = resolveToken(req)
289
+ if (!token) {
290
+ return send(res, 200, {
291
+ source: 'unconfigured',
292
+ frames: [],
293
+ hint: 'Add a FrameThrower API token to load real reference frames.',
294
+ })
295
+ }
296
+ return path === '/anistyle/frames' ? searchFrames(body, token, res) : similarFrames(body, token, res)
297
+ }
298
+
299
+ if (req.method === 'GET' && path === '/anistyle/version') {
300
+ return send(res, 200, { source: 'version', version: await version() })
301
+ }
302
+
303
+ return send(res, 404, { error: 'not found' })
304
+ }
305
+
306
+ function styleVocabulary() {
307
+ const data = vocabulary && vocabulary.data
308
+ const groups = data && data.style_groups
309
+ if (!groups || typeof groups !== 'object') return []
310
+ const values = []
311
+ for (const list of Object.values(groups)) {
312
+ if (!Array.isArray(list)) continue
313
+ for (const entry of list) {
314
+ const value = typeof entry === 'string' ? entry : entry && entry.value
315
+ if (typeof value === 'string' && value) values.push(value)
316
+ }
317
+ }
318
+ return values
319
+ }
320
+
321
+ function parseInterpret(raw) {
322
+ const start = raw.indexOf('{')
323
+ const end = raw.lastIndexOf('}')
324
+ if (start < 0 || end <= start) return null
325
+ let parsed
326
+ try {
327
+ parsed = JSON.parse(raw.slice(start, end + 1))
328
+ } catch {
329
+ return null
330
+ }
331
+ const listed = Array.isArray(parsed?.queries) ? parsed.queries : (parsed?.query ? [parsed.query] : [])
332
+ const queries = []
333
+ for (const item of listed) {
334
+ let entry = text(item, 300)
335
+ // translation is the whole point: a non-Latin query means the model
336
+ // echoed the brief instead of translating it for FrameThrower
337
+ if (!entry || NON_LATIN_RE.test(entry)) continue
338
+ // a stray long sentence makes the search muddy, so hard-cap each one
339
+ const words = entry.split(/\s+/).filter(Boolean)
340
+ if (words.length > 18) entry = words.slice(0, 18).join(' ').replace(/[,;:]+$/, '')
341
+ if (!queries.includes(entry)) queries.push(entry)
342
+ if (queries.length === 3) break
343
+ }
344
+ const listedWeb = Array.isArray(parsed?.web) ? parsed.web : (typeof parsed?.web === 'string' ? [parsed.web] : [])
345
+ const web = []
346
+ for (const item of listedWeb) {
347
+ let entry = text(item, 120)
348
+ if (!entry) continue
349
+ const words = entry.split(/\s+/).filter(Boolean)
350
+ if (words.length > 8) entry = words.slice(0, 8).join(' ')
351
+ if (!web.includes(entry)) web.push(entry)
352
+ if (web.length === 3) break
353
+ }
354
+ if (!queries.length && !web.length) return null
355
+ const styles = styleVocabulary()
356
+ const style = text(parsed?.style, 80)
357
+ const title = text(parsed?.title, 140)
358
+ return {
359
+ query: queries[0] || '',
360
+ queries,
361
+ web,
362
+ style: style && (!styles.length || styles.includes(style)) ? style : '',
363
+ title: title && !NON_LATIN_RE.test(title) ? title : '',
364
+ genre: text(parsed?.genre, 60),
365
+ perTitle: clampInt(parsed?.perTitle, 1, 6, 3),
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Turn a free-form brief into retrieval terms with the default model.
371
+ * Optional: when no model service is mounted the client falls back to its
372
+ * local dictionary, so this never blocks a search.
373
+ */
374
+ async function interpretQuery(source, count, refresh) {
375
+ const total = clampInt(count, 1, 3, 3)
376
+ const key = total + ':' + source.toLowerCase().slice(0, 400)
377
+ const hit = interpretCache.get(key)
378
+ if (!refresh && hit && Date.now() - hit.at < 30 * 60 * 1000) return { params: hit.value, reason: '' }
379
+
380
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
381
+ const models = typeof ctx.get === 'function' ? ctx.get('agentDefaultModel') : undefined
382
+ if (!llm) return { params: null, reason: 'no-llm' }
383
+ if (!models) return { params: null, reason: 'no-model' }
384
+
385
+ let selection
386
+ try {
387
+ selection = models.currentSelection()
388
+ } catch {
389
+ return { params: null, reason: 'no-model' }
390
+ }
391
+ const provider = selection?.provider
392
+ const model = selection?.model
393
+ if (!provider || !model) return { params: null, reason: 'no-model' }
394
+
395
+ const controller = new AbortController()
396
+ const timer = setTimeout(() => controller.abort(), 15_000)
397
+ const styles = styleVocabulary().slice(0, 80)
398
+ const example = '[' + Array.from({ length: total }, () => 'string').join(', ') + ']'
399
+ const system = [
400
+ 'You turn a creative brief into FrameThrower frame searches (animation, film stills and commercial shots all share one English frame-description register).',
401
+ 'The brief can arrive in any language — Chinese, Japanese, Korean, French, Spanish, and so on. Detect it, translate the meaning, and never assume the brief is Chinese.',
402
+ total > 1
403
+ ? 'The library matches one single-frame description per search, so return ' + total + ' distinct readings of the brief instead of collapsing it into one scene.'
404
+ : 'The library matches one single-frame description per search, so return one single-frame description of the brief.',
405
+ 'Its own MCP tool search_animation_frames asks for "the scene or character, as a description of one frame".',
406
+ 'Reply with one JSON object and nothing else:',
407
+ '{"queries": ' + example + ', "web": ' + example + ', "title"?: string, "genre"?: string, "style"?: string, "perTitle"?: number}',
408
+ total > 1
409
+ ? 'queries: ' + total + ' short English phrases, 5-12 words each, each describing ONE frame: who or what is in it, where, and the light. The first is the most literal reading of the brief; the others explore different subjects, settings or light that still fit the mood. Each query must change at least two of subject, setting and light; never reword the same scene. If the brief is abstract (a mood, a style, a genre), imagine a different concrete scene for each query rather than one scene with synonyms.'
410
+ : 'queries: one short English phrase, 6-14 words, describing a single frame: who or what is in it, where, and the light.',
411
+ 'Plain words, no camera jargon, no comma-piled adjectives.',
412
+ 'Shape each like: "a girl on a rooftop at dusk watching the city lights".',
413
+ 'web: ' + total + ' short keyword phrases (2-6 words each) for a web image search engine — what a person would type to find this look.',
414
+ 'web is keywords, not a sentence: subject, setting, light, style. Write each phrase in the brief\'s own language when that language works for image search (中文、日本語、한국어、français, ...); switch a title to its official English name only when that finds better images (死神 → Bleach, 鬼滅の刃 → Demon Slayer). Never mix languages inside one phrase. Each phrase must differ.',
415
+ 'queries and web are separate: queries describe one frame for the FrameThrower libraries, web is an image-search phrase.',
416
+ 'Keep what the brief states and drop the rest; never invent a plot.',
417
+ 'title: when the brief names or unmistakably implies ONE animation, put its official English title',
418
+ 'the library indexes — 死神 is "Bleach", 进击的巨人 is "Attack on Titan", 双城之战 is "Arcane".',
419
+ 'A wrong title returns zero frames, so include it only when you actually know it; otherwise omit it',
420
+ 'and let the query carry the look. For a character, keep the character in the query and still name the title.',
421
+ 'genre: only when the brief explicitly names a genre. perTitle: 1-6, only when the brief implies it.',
422
+ 'queries are always English, no matter what language the brief is written in (Chinese, Japanese, Korean, French, Spanish, and so on). Translate the meaning; never echo non-English words into queries.',
423
+ styles.length ? 'style: only one of: ' + styles.join(' | ') + '. Omit it when nothing fits.' : 'style: omit it.',
424
+ 'Nothing but the JSON.',
425
+ ].join(' ')
426
+
427
+ // The harness builds an auxiliary call as: prepare the route (with its
428
+ // controls), then stream `{ ...call.config, messages, system, signal }`.
429
+ // Passing controls to stream() instead of prepareCall is what the adapters reject.
430
+ const message = { role: 'user', content: [{ type: 'text', text: source }] }
431
+ // auxiliary work must stay quick: when the default model thinks, an
432
+ // explicit "off" skips that phase instead of waiting for a long reasoning run
433
+ const leastReasoning = (controls, info) => {
434
+ const efforts = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : null
435
+ if (!efforts || !efforts.length) return controls
436
+ return { ...controls, reasoningEffort: efforts[0].id }
437
+ }
438
+ const routes = [
439
+ () => ({ provider, model, maxTokens: 1200, temperature: 1.0 }),
440
+ () => ({ provider, model, temperature: 1.0 }),
441
+ ]
442
+ const errors = []
443
+ try {
444
+ for (const build of routes) {
445
+ try {
446
+ const call = await llm.prepareCall(build(), controller.signal, leastReasoning)
447
+ const options = { ...call.config, messages: [message], system, signal: controller.signal }
448
+ let out = ''
449
+ let providerError = ''
450
+ let aborted = false
451
+ for await (const chunk of call.stream(options)) {
452
+ if (!chunk) continue
453
+ if (chunk.type === 'text-delta') out += chunk.text
454
+ else if (chunk.type === 'finish') {
455
+ if (chunk.reason?.kind === 'error') providerError = chunk.reason.failure?.message || 'provider error'
456
+ else if (chunk.reason?.kind === 'aborted') aborted = true
457
+ }
458
+ }
459
+ if (aborted) return { params: null, reason: 'timeout' }
460
+ if (providerError && !out) { errors.push(providerError); continue }
461
+ const params = parseInterpret(out)
462
+ if (params) {
463
+ interpretCache.set(key, { at: Date.now(), value: params })
464
+ return { params, reason: '' }
465
+ }
466
+ if (!out.trim()) { errors.push(providerError || 'empty response'); continue }
467
+ return { params: null, reason: 'unparsed', detail: out.trim().slice(0, 160) }
468
+ } catch (error) {
469
+ if (error?.name === 'AbortError') return { params: null, reason: 'timeout' }
470
+ errors.push(String((error && error.message) || error).slice(0, 160))
471
+ }
472
+ }
473
+ return { params: null, reason: 'call-failed', detail: errors.slice(0, 2).join(' | ') }
474
+ } finally {
475
+ clearTimeout(timer)
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Turn one attached image into web image-search phrases with the default
481
+ * vision model. FrameThrower already accepts the image directly; this is the
482
+ * bridge for the text-only Bing source. The image is admitted through the
483
+ * host attachment store first, because model messages carry durable image
484
+ * references rather than inline base64.
485
+ */
486
+ async function describeImageForWeb(body) {
487
+ const parsedImage = parseImageDataUrl(body?.image)
488
+ if (!parsedImage) return { ok: false, reason: 'bad-image' }
489
+ const note = text(body?.text, 600)
490
+ const count = clampInt(body?.count, 1, 3, 3)
491
+
492
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
493
+ const models = typeof ctx.get === 'function' ? ctx.get('agentDefaultModel') : undefined
494
+ const attachments = typeof ctx.get === 'function' ? ctx.get('attachments') : undefined
495
+ if (!llm) return { ok: false, reason: 'no-llm' }
496
+ if (!models) return { ok: false, reason: 'no-model' }
497
+ if (!attachments || typeof attachments.admitPromptContent !== 'function') {
498
+ return { ok: false, reason: 'no-attachments' }
499
+ }
500
+
501
+ let selection
502
+ try {
503
+ selection = models.currentSelection()
504
+ } catch {
505
+ return { ok: false, reason: 'no-model' }
506
+ }
507
+ const provider = selection?.provider
508
+ const model = selection?.model
509
+ if (!provider || !model) return { ok: false, reason: 'no-model' }
510
+
511
+ let modelInfo = null
512
+ try {
513
+ if (typeof llm.resolveModelInfo === 'function') modelInfo = await llm.resolveModelInfo(provider, model)
514
+ } catch {}
515
+ if (modelInfo && Array.isArray(modelInfo.inputModalities) && !modelInfo.inputModalities.includes('image')) {
516
+ return { ok: false, reason: 'no-vision' }
517
+ }
518
+
519
+ let admitted
520
+ try {
521
+ admitted = await attachments.admitPromptContent([{
522
+ type: 'image',
523
+ mediaType: parsedImage.mediaType,
524
+ data: parsedImage.data,
525
+ }])
526
+ } catch (error) {
527
+ return { ok: false, reason: 'image-rejected', detail: String((error && error.message) || error).slice(0, 160) }
528
+ }
529
+ const imagePart = Array.isArray(admitted)
530
+ ? admitted.find((part) => part && part.type === 'image' && part.attachment)
531
+ : null
532
+ if (!imagePart) return { ok: false, reason: 'image-rejected' }
533
+
534
+ const example = '[' + Array.from({ length: count }, () => '"string"').join(', ') + ']'
535
+ const system = [
536
+ 'You help a person find visually similar reference images on the web.',
537
+ 'Look carefully at the attached image and decide what a search engine should look for.',
538
+ 'Reply with one JSON object and nothing else:',
539
+ '{"web":' + example + '}',
540
+ 'web: ' + count + ' short keyword phrases (2-6 words each) for a web image search engine such as DuckDuckGo or Bing.',
541
+ 'Say what is visible: subject, setting, light, colour, composition and style.',
542
+ 'The note can be in any language; detect it. Write the phrases in the note\'s language when there is one, otherwise use English, which works across the engines.',
543
+ 'If the image is a collage, moodboard or screenshot, describe the pictures inside it rather than the app interface or its text.',
544
+ 'Do not name an animation, film or artist unless the image itself proves it.',
545
+ 'The phrases must differ from each other and must be keywords, not sentences.',
546
+ note
547
+ ? 'The user added this note; use it as context, but never let it override what the image shows: ' + JSON.stringify(note)
548
+ : 'There is no additional note.',
549
+ 'Nothing but the JSON.',
550
+ ].join(' ')
551
+
552
+ const message = {
553
+ role: 'user',
554
+ content: [
555
+ { type: 'text', text: note ? 'User note: ' + note : 'Describe this image for an image search.' },
556
+ imagePart,
557
+ ],
558
+ }
559
+ const controller = new AbortController()
560
+ const timer = setTimeout(() => controller.abort(), 20_000)
561
+ const leastReasoning = (controls, info) => {
562
+ const efforts = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : null
563
+ if (!efforts || !efforts.length) return controls
564
+ return { ...controls, reasoningEffort: efforts[0].id }
565
+ }
566
+ const routes = [
567
+ () => ({ provider, model, maxTokens: 1200, temperature: 1.0 }),
568
+ () => ({ provider, model, temperature: 1.0 }),
569
+ ]
570
+ const errors = []
571
+ try {
572
+ for (const build of routes) {
573
+ try {
574
+ const call = await llm.prepareCall(build(), controller.signal, leastReasoning)
575
+ const options = { ...call.config, messages: [message], system, signal: controller.signal }
576
+ let out = ''
577
+ let providerError = ''
578
+ let aborted = false
579
+ for await (const chunk of call.stream(options)) {
580
+ if (!chunk) continue
581
+ if (chunk.type === 'text-delta') out += chunk.text
582
+ else if (chunk.type === 'finish') {
583
+ if (chunk.reason?.kind === 'error') providerError = chunk.reason.failure?.message || 'provider error'
584
+ else if (chunk.reason?.kind === 'aborted') aborted = true
585
+ }
586
+ }
587
+ if (aborted) return { ok: false, reason: 'timeout' }
588
+ if (providerError && !out) { errors.push(providerError); continue }
589
+ const web = parseImageWebQueries(out)
590
+ if (web) return { ok: true, web }
591
+ if (!out.trim()) { errors.push(providerError || 'empty response'); continue }
592
+ return { ok: false, reason: 'unparsed', detail: out.trim().slice(0, 160) }
593
+ } catch (error) {
594
+ if (error?.name === 'AbortError') return { ok: false, reason: 'timeout' }
595
+ const detail = String((error && error.message) || error).slice(0, 160)
596
+ if (!errors.includes(detail)) errors.push(detail)
597
+ }
598
+ }
599
+ return { ok: false, reason: 'call-failed', detail: errors.slice(0, 2).join(' | ') }
600
+ } finally {
601
+ clearTimeout(timer)
602
+ }
603
+ }
604
+
605
+ function resolveToken(req) {
606
+ const header = req.headers['x-ft-token']
607
+ if (typeof header === 'string' && header.trim()) return header.trim()
608
+ if (configuredToken) return configuredToken
609
+ const env = typeof process !== 'undefined' ? process.env?.FRAMETHROWER_API_TOKEN : ''
610
+ return typeof env === 'string' ? env.trim() : ''
611
+ }
612
+
613
+ function frameKind(value) {
614
+ const kind = text(value, 20)
615
+ return FRAME_SOURCE_KEYS.includes(kind) ? kind : 'animation'
616
+ }
617
+
618
+ async function searchFrames(body, token, res) {
619
+ const query = text(body?.query, 400)
620
+ if (!query) return send(res, 400, { error: 'missing query' })
621
+
622
+ const kind = frameKind(body?.source)
623
+ const spec = FRAME_SOURCES[kind]
624
+ const payload = { query }
625
+
626
+ if (kind === 'animation') {
627
+ payload.per_title = clampInt(body?.perTitle, 1, 6, 3)
628
+ payload.nsfw = false
629
+ payload.limit = clampInt(body?.limit, 1, 24, 24)
630
+ payload.offset = clampInt(body?.offset, 0, 200, 0)
631
+ const style = text(body?.style, 240)
632
+ if (style) payload.style = style
633
+ const genre = text(body?.genre, 120)
634
+ if (genre) payload.genre = genre
635
+ const title = text(body?.title, 120)
636
+ if (title) payload.title = title
637
+ } else if (kind === 'film') {
638
+ payload.limit = clampInt(body?.limit, 1, 50, 24)
639
+ payload.offset = clampInt(body?.offset, 0, 500, 0)
640
+ payload.mode = 'hybrid'
641
+ const genre = text(body?.genre, 120)
642
+ if (genre) payload.genre = genre
643
+ const title = text(body?.title, 120)
644
+ if (title) payload.film_title = title
645
+ } else {
646
+ payload.limit = clampInt(body?.limit, 1, 50, 24)
647
+ payload.offset = clampInt(body?.offset, 0, 150, 0)
648
+ payload.per_spot = clampInt(body?.perSpot, 1, 6, 3)
649
+ }
650
+
651
+ const key = kind + '|' + JSON.stringify(payload)
652
+ const cached = cache.get(key)
653
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
654
+ return send(res, 200, { ...cached.value, cached: true })
655
+ }
656
+
657
+ const result = await callUpstream(spec.search, payload, token, 'POST')
658
+ if (!result.ok) return send(res, 200, result.failure)
659
+
660
+ const items = pickFrames(result.json)
661
+ const frames = normalizeFrames(items, kind)
662
+ const value = {
663
+ source: 'framethrower',
664
+ kind,
665
+ frames,
666
+ total: numberOr(result.json?.meta?.total, items.length),
667
+ warnings: warningsOf(result.json),
668
+ }
669
+ cache.set(key, { at: Date.now(), value })
670
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value)
671
+ return send(res, 200, value)
672
+ }
673
+
674
+ async function similarFrames(body, token, res) {
675
+ const kind = frameKind(body?.source)
676
+ const spec = FRAME_SOURCES[kind]
677
+ const limit = clampInt(body?.limit, 1, 24, 18)
678
+ const frameIds = Array.isArray(body?.frameIds)
679
+ ? body.frameIds.filter((id) => typeof id === 'string' && id).slice(0, 10)
680
+ : []
681
+ const frameId = text(body?.frameId, 200)
682
+ const image = typeof body?.image === 'string' ? body.image.trim().slice(0, 3_000_000) : ''
683
+ if (!frameIds.length && !frameId && !image) {
684
+ return send(res, 400, { error: 'need frameId, frameIds or image' })
685
+ }
686
+
687
+ let payload
688
+ let path = spec.similar
689
+ if (kind === 'animation') {
690
+ payload = { by: ['look', 'colour', 'content'].includes(body?.by) ? body.by : 'look', limit }
691
+ if (frameIds.length) payload.frame_ids = frameIds
692
+ else if (frameId) payload.frame_ids = [frameId]
693
+ else payload.image = image
694
+ } else if (kind === 'film') {
695
+ if (image && !frameIds.length && !frameId) {
696
+ return send(res, 200, {
697
+ source: 'unsupported',
698
+ frames: [],
699
+ hint: 'Film stills cannot search by image: the API needs a publicly reachable image URL.',
700
+ })
701
+ }
702
+ payload = {
703
+ mode: ['look', 'colour', 'content'].includes(body?.by) ? { look: 'visual', colour: 'color', content: 'semantic' }[body.by] : 'visual',
704
+ limit,
705
+ }
706
+ if (frameIds.length) payload.frameIds = frameIds
707
+ else payload.frameId = frameId
708
+ } else {
709
+ payload = { limit }
710
+ if (frameIds.length) payload.shot_ids = frameIds
711
+ else if (frameId) payload.shot_ids = [frameId]
712
+ else {
713
+ path = spec.image
714
+ payload.imageUrl = image
715
+ payload.per_spot = clampInt(body?.perSpot, 1, 6, 3)
716
+ }
717
+ }
718
+
719
+ const result = await callUpstream(path, payload, token, 'POST')
720
+ if (!result.ok) return send(res, 200, result.failure)
721
+ return send(res, 200, {
722
+ source: 'framethrower',
723
+ kind,
724
+ frames: normalizeFrames(pickFrames(result.json), kind),
725
+ warnings: warningsOf(result.json),
726
+ })
727
+ }
728
+
729
+ /**
730
+ * The web source: one keyword search against Bing Images, normalized into the
731
+ * same frame shape FrameThrower returns so the grid does not care where a tile
732
+ * came from. No token, no quota, and no promise that the endpoint stays put.
733
+ */
734
+ async function searchWeb(body, res) {
735
+ const query = text(body?.query, 300)
736
+ if (!query) return send(res, 400, { error: 'missing query' })
737
+
738
+ const limit = clampInt(body?.limit, 1, 40, 24)
739
+ const offset = clampInt(body?.offset, 0, 960, 0)
740
+ const mkt = marketFor(query)
741
+ const key = `${mkt}|${offset}|${limit}|${query.toLowerCase()}`
742
+
743
+ const cached = webCache.get(key)
744
+ if (cached && Date.now() - cached.at < WEB_CACHE_TTL_MS) {
745
+ return send(res, 200, { ...cached.value, cached: true })
746
+ }
747
+
748
+ const result = await fetchWebImages(query, offset, limit, mkt)
749
+ if (!result.ok) return send(res, 200, result.failure)
750
+
751
+ const value = {
752
+ source: 'chrome',
753
+ frames: result.frames,
754
+ total: 0,
755
+ warnings: result.frames.length ? [] : ['web-empty'],
756
+ }
757
+ webCache.set(key, { at: Date.now(), value })
758
+ if (webCache.size > WEB_CACHE_MAX) webCache.delete(webCache.keys().next().value)
759
+ return send(res, 200, value)
760
+ }
761
+
762
+ /**
763
+ * Source images are often hotlink-protected or plain http. Fetching them once
764
+ * here keeps the grid working and avoids mixed content in the panel.
765
+ */
766
+ async function proxyImage(url, res) {
767
+ const raw = url.searchParams.get('u') || ''
768
+ let target
769
+ try {
770
+ target = new URL(raw)
771
+ } catch {
772
+ return send(res, 400, { error: 'bad image url' })
773
+ }
774
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
775
+ return send(res, 400, { error: 'bad image protocol' })
776
+ }
777
+ if (isPrivateHost(target.hostname)) return send(res, 403, { error: 'blocked host' })
778
+
779
+ const key = target.href
780
+ const hit = imageCache.get(key)
781
+ if (hit && Date.now() - hit.at < IMAGE_TTL_MS) return sendImage(res, hit)
782
+
783
+ let upstream
784
+ try {
785
+ upstream = await fetch(key, {
786
+ headers: { 'User-Agent': WEB_UA, Accept: 'image/*', Referer: target.origin + '/' },
787
+ redirect: 'follow',
788
+ signal: AbortSignal.timeout(20_000),
789
+ })
790
+ } catch {
791
+ return send(res, 502, { error: 'image fetch failed' })
792
+ }
793
+ if (!upstream.ok) return send(res, 502, { error: `image ${upstream.status}` })
794
+ try {
795
+ if (isPrivateHost(new URL(upstream.url).hostname)) return send(res, 403, { error: 'blocked redirect' })
796
+ } catch {}
797
+
798
+ const declared = Number(upstream.headers.get('content-length') || 0)
799
+ if (declared > IMAGE_MAX_BYTES) return send(res, 413, { error: 'image too large' })
800
+
801
+ let buffer
802
+ try {
803
+ buffer = Buffer.from(await upstream.arrayBuffer())
804
+ } catch {
805
+ return send(res, 502, { error: 'image unreadable' })
806
+ }
807
+ if (!buffer.length || buffer.length > IMAGE_MAX_BYTES) return send(res, 413, { error: 'image too large' })
808
+
809
+ // some hosts (naruto-official.com among them) serve a real webp as
810
+ // application/octet-stream; sniff the bytes rather than leave a hole
811
+ const declaredType = String(upstream.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
812
+ const type = declaredType.startsWith('image/') ? declaredType : sniffImageType(buffer)
813
+ if (!type) return send(res, 415, { error: 'not an image' })
814
+
815
+ const entry = { at: Date.now(), type, buffer }
816
+ imageCache.set(key, entry)
817
+ imageBytes += buffer.length
818
+ while (imageBytes > IMAGE_CACHE_BYTES && imageCache.size > 1) {
819
+ const oldest = imageCache.keys().next().value
820
+ const dropped = imageCache.get(oldest)
821
+ imageCache.delete(oldest)
822
+ imageBytes -= dropped ? dropped.buffer.length : 0
823
+ }
824
+ return sendImage(res, entry)
825
+ }
826
+
827
+ async function callUpstream(path, payload, token, method) {
828
+ let upstream
829
+ try {
830
+ upstream = await fetch(`${API_BASE}${path}`, {
831
+ method,
832
+ headers: {
833
+ Authorization: `Bearer ${token}`,
834
+ ...(payload ? { 'Content-Type': 'application/json' } : {}),
835
+ Accept: 'application/json',
836
+ },
837
+ body: payload ? JSON.stringify(payload) : undefined,
838
+ signal: AbortSignal.timeout(30_000),
839
+ })
840
+ } catch (error) {
841
+ return {
842
+ ok: false,
843
+ failure: {
844
+ source: 'unreachable',
845
+ frames: [],
846
+ hint: `FrameThrower is unreachable (${error?.name ?? 'network error'}).`,
847
+ },
848
+ }
849
+ }
850
+
851
+ if (!upstream.ok) {
852
+ const detail = await safeText(upstream)
853
+ const auth = upstream.status === 401 || upstream.status === 403
854
+ const poor = upstream.status === 402
855
+ return {
856
+ ok: false,
857
+ failure: {
858
+ source: auth ? 'unauthorized' : 'upstream-error',
859
+ status: upstream.status,
860
+ frames: [],
861
+ hint: auth
862
+ ? 'FrameThrower rejected the token. Create one in Settings → API.'
863
+ : poor
864
+ ? 'FrameThrower credits are used up.'
865
+ : `FrameThrower returned ${upstream.status}. ${detail.slice(0, 160)}`,
866
+ },
867
+ }
868
+ }
869
+
870
+ try {
871
+ const json = await upstream.json()
872
+ try {
873
+ lastDebug = {
874
+ at: Date.now(),
875
+ path,
876
+ status: upstream.status,
877
+ keys: Object.keys(json || {}),
878
+ count: pickFrames(json).length,
879
+ sample: JSON.stringify(json).slice(0, 1600),
880
+ }
881
+ } catch {}
882
+ return { ok: true, json }
883
+ } catch {
884
+ return { ok: false, failure: { source: 'unreachable', frames: [], hint: 'FrameThrower returned an unreadable body.' } }
885
+ }
886
+ }
887
+
888
+ const dispose = ctx.webServer.register({ kind: 'prefix', path: '/anistyle', handler: handle })
889
+ ctx.effect(() => dispose)
890
+ }
891
+
892
+ /**
893
+ * The web source. Bing Images' full search page is tried first for every
894
+ * language, with DuckDuckGo's Bing-backed image endpoint as the fallback. A
895
+ * query that finds nothing in one engine can still find something in the other;
896
+ * a challenge page or a parser change in one endpoint does not take the whole
897
+ * source down. Bing's `/images/async` fragment is no longer used: since 2026 it
898
+ * answers a plain request with a script-only stub and no result markup.
899
+ */
900
+ async function fetchWebImages(query, offset, limit, mkt) {
901
+ const attempts = [fetchBingImages, fetchDuckDuckGoImages]
902
+ const failures = []
903
+ let sawSuccess = false
904
+ for (const attempt of attempts) {
905
+ let result
906
+ try {
907
+ result = await attempt(query, offset, limit, mkt)
908
+ } catch (error) {
909
+ failures.push({ source: 'unreachable', frames: [], hint: String((error && error.message) || error).slice(0, 160) })
910
+ continue
911
+ }
912
+ if (result.ok) {
913
+ sawSuccess = true
914
+ if (result.frames.length) return result
915
+ } else if (result.failure) {
916
+ failures.push(result.failure)
917
+ }
918
+ }
919
+ if (sawSuccess) return { ok: true, frames: [], engine: 'web' }
920
+ return {
921
+ ok: false,
922
+ failure: failures[0] || { source: 'unreachable', frames: [], hint: 'Web image search is unreachable.' },
923
+ }
924
+ }
925
+
926
+ async function fetchDuckDuckGoImages(query, offset, limit, mkt) {
927
+ const headers = {
928
+ 'User-Agent': WEB_UA,
929
+ Accept: 'text/html,application/xhtml+xml',
930
+ 'Accept-Language': acceptLanguage(mkt),
931
+ }
932
+ let page
933
+ try {
934
+ page = await fetch(`https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`, {
935
+ headers,
936
+ redirect: 'follow',
937
+ signal: AbortSignal.timeout(20_000),
938
+ })
939
+ } catch (error) {
940
+ return {
941
+ ok: false,
942
+ failure: {
943
+ source: 'unreachable',
944
+ frames: [],
945
+ hint: `Web image search is unreachable (${error?.name ?? 'network error'}).`,
946
+ },
947
+ }
948
+ }
949
+ if (!page.ok) {
950
+ return {
951
+ ok: false,
952
+ failure: {
953
+ source: page.status === 429 ? 'rate-limited' : 'upstream-error',
954
+ status: page.status,
955
+ frames: [],
956
+ hint: page.status === 429
957
+ ? 'DuckDuckGo is rate-limiting image search; try again later.'
958
+ : `DuckDuckGo image search returned ${page.status}.`,
959
+ },
960
+ }
961
+ }
962
+ const html = await page.text()
963
+ const token = /vqd=["']?([\d-]+)["']?/.exec(html)
964
+ if (!token) return { ok: true, frames: [], engine: 'ddg' }
965
+
966
+ const params = new URLSearchParams({
967
+ l: 'wt-wt',
968
+ o: 'json',
969
+ q: query,
970
+ vqd: token[1],
971
+ f: ',,,,',
972
+ p: String(Math.floor(offset / 100) + 1),
973
+ s: String(offset),
974
+ })
975
+ let payload
976
+ try {
977
+ payload = await fetch(`https://duckduckgo.com/i.js?${params}`, {
978
+ headers: {
979
+ ...headers,
980
+ Accept: 'application/json, text/javascript, */*; q=0.01',
981
+ Referer: 'https://duckduckgo.com/',
982
+ },
983
+ redirect: 'follow',
984
+ signal: AbortSignal.timeout(20_000),
985
+ })
986
+ } catch (error) {
987
+ return {
988
+ ok: false,
989
+ failure: {
990
+ source: 'unreachable',
991
+ frames: [],
992
+ hint: `Web image search is unreachable (${error?.name ?? 'network error'}).`,
993
+ },
994
+ }
995
+ }
996
+ if (!payload.ok) {
997
+ return {
998
+ ok: false,
999
+ failure: {
1000
+ source: payload.status === 429 ? 'rate-limited' : 'upstream-error',
1001
+ status: payload.status,
1002
+ frames: [],
1003
+ hint: payload.status === 429
1004
+ ? 'DuckDuckGo is rate-limiting image search; try again later.'
1005
+ : `DuckDuckGo image search returned ${payload.status}.`,
1006
+ },
1007
+ }
1008
+ }
1009
+ let data
1010
+ try {
1011
+ data = await payload.json()
1012
+ } catch {
1013
+ return { ok: true, frames: [], engine: 'ddg' }
1014
+ }
1015
+ return { ok: true, frames: parseDdgFrames(data, limit), engine: 'ddg' }
1016
+ }
1017
+
1018
+ function parseDdgFrames(data, limit) {
1019
+ const items = data && Array.isArray(data.results) ? data.results : []
1020
+ const frames = []
1021
+ const seen = new Set()
1022
+ for (const item of items) {
1023
+ const frame = ddgToFrame(item)
1024
+ if (!frame || seen.has(frame.id)) continue
1025
+ seen.add(frame.id)
1026
+ frames.push(frame)
1027
+ if (frames.length >= Math.min(limit || 40, 40)) break
1028
+ }
1029
+ return frames
1030
+ }
1031
+
1032
+ function ddgToFrame(item) {
1033
+ if (!item || typeof item !== 'object') return null
1034
+ const image = firstHttp([item.image, item.thumbnail])
1035
+ if (!image) return null
1036
+ const thumb = firstHttp([item.thumbnail, item.image, image])
1037
+ const purl = firstHttp([item.url]) || image
1038
+ const hash = typeof item.image_token === 'string' && item.image_token
1039
+ ? item.image_token.slice(0, 32)
1040
+ : hashUrl(image)
1041
+ const title = cleanBingTitle(item.title) || hostOf(purl)
1042
+ return {
1043
+ id: 'web_' + hash,
1044
+ src: 'chrome',
1045
+ thumb,
1046
+ image: '/anistyle/img?u=' + encodeURIComponent(image),
1047
+ original: image,
1048
+ link: purl,
1049
+ title,
1050
+ year: null,
1051
+ slug: '',
1052
+ caption: cleanText(item.title, 220),
1053
+ tags: [],
1054
+ palette: [],
1055
+ score: null,
1056
+ episode: null,
1057
+ timecode: null,
1058
+ }
1059
+ }
1060
+
1061
+ function firstHttp(values) {
1062
+ for (const value of values) {
1063
+ if (typeof value === 'string' && /^https?:/i.test(value)) return value
1064
+ }
1065
+ return ''
1066
+ }
1067
+
1068
+ async function fetchBingImages(query, offset, limit, mkt) {
1069
+ // `first` is 1-based; the async fragment Bing used to serve for scripts is
1070
+ // gone, so read the same server-rendered page a browser lands on
1071
+ const params = new URLSearchParams({
1072
+ q: query,
1073
+ first: String(offset + 1),
1074
+ count: String(Math.min(Math.max(limit, 1), 35)),
1075
+ adlt: 'strict',
1076
+ mkt,
1077
+ form: 'HDRSC3',
1078
+ qs: 'n',
1079
+ sp: '-1',
1080
+ pq: query,
1081
+ })
1082
+ let upstream
1083
+ try {
1084
+ upstream = await fetch(`https://www.bing.com/images/search?${params}`, {
1085
+ headers: {
1086
+ 'User-Agent': WEB_UA,
1087
+ Accept: 'text/html,application/xhtml+xml',
1088
+ 'Accept-Language': acceptLanguage(mkt),
1089
+ Referer: `https://www.bing.com/images/search?q=${encodeURIComponent(query)}`,
1090
+ },
1091
+ redirect: 'follow',
1092
+ signal: AbortSignal.timeout(20_000),
1093
+ })
1094
+ } catch (error) {
1095
+ return {
1096
+ ok: false,
1097
+ failure: {
1098
+ source: 'unreachable',
1099
+ frames: [],
1100
+ hint: `Web image search is unreachable (${error?.name ?? 'network error'}).`,
1101
+ },
1102
+ }
1103
+ }
1104
+
1105
+ if (!upstream.ok) {
1106
+ return {
1107
+ ok: false,
1108
+ failure: {
1109
+ source: upstream.status === 429 ? 'rate-limited' : 'upstream-error',
1110
+ status: upstream.status,
1111
+ frames: [],
1112
+ hint: upstream.status === 429
1113
+ ? 'Bing is rate-limiting image search; try again later.'
1114
+ : `Bing image search returned ${upstream.status}.`,
1115
+ },
1116
+ }
1117
+ }
1118
+
1119
+ const html = await upstream.text()
1120
+ const frames = parseBingFrames(html, limit)
1121
+ if (frames.length) return { ok: true, frames, engine: 'bing' }
1122
+ // an empty grid and a human check both parse to nothing; say which one it was
1123
+ if (/captcha|challenge|verify (that )?you are (a )?human/i.test(html)) {
1124
+ return {
1125
+ ok: false,
1126
+ failure: {
1127
+ source: 'rate-limited',
1128
+ frames: [],
1129
+ hint: 'Bing answered with a human check; try again later.',
1130
+ },
1131
+ }
1132
+ }
1133
+ return { ok: true, frames: [], engine: 'bing' }
1134
+ }
1135
+
1136
+ function parseBingFrames(html, limit) {
1137
+ const max = Math.min(Math.max(limit || 0, 1), 40)
1138
+ const frames = []
1139
+ const seen = new Set()
1140
+ const item = /class="[^"]*\biusc\b[^"]*"[^>]*?\bm="([^"]+)"/g
1141
+ let match
1142
+ while ((match = item.exec(html))) {
1143
+ let payload
1144
+ try {
1145
+ payload = JSON.parse(htmlUnescape(match[1]))
1146
+ } catch {
1147
+ continue
1148
+ }
1149
+ const frame = bingToFrame(payload)
1150
+ if (!frame || seen.has(frame.id)) continue
1151
+ seen.add(frame.id)
1152
+ frames.push(frame)
1153
+ if (frames.length >= max) break
1154
+ }
1155
+ return frames
1156
+ }
1157
+
1158
+ function bingToFrame(item) {
1159
+ if (!item || typeof item !== 'object') return null
1160
+ const murl = typeof item.murl === 'string' ? item.murl : ''
1161
+ const turl = typeof item.turl === 'string' ? item.turl : ''
1162
+ const image = /^https?:/i.test(murl) ? murl : (/^https?:/i.test(turl) ? turl : '')
1163
+ if (!image) return null
1164
+ const thumb = /^https?:/i.test(turl) ? turl : image
1165
+ const purl = typeof item.purl === 'string' && /^https?:/i.test(item.purl) ? item.purl : image
1166
+ const hash = typeof item.md5 === 'string' && item.md5 ? item.md5 : hashUrl(image)
1167
+ const title = cleanBingTitle(item.t) || hostOf(purl)
1168
+ return {
1169
+ id: 'web_' + hash,
1170
+ src: 'chrome',
1171
+ thumb,
1172
+ // every source image goes through the local proxy: hotlink protection and
1173
+ // http-only hosts would otherwise leave holes in the grid
1174
+ image: '/anistyle/img?u=' + encodeURIComponent(image),
1175
+ original: image,
1176
+ link: purl,
1177
+ title,
1178
+ year: null,
1179
+ slug: '',
1180
+ caption: cleanText(item.desc || item.t, 220),
1181
+ tags: [],
1182
+ palette: [],
1183
+ score: null,
1184
+ episode: null,
1185
+ timecode: null,
1186
+ }
1187
+ }
1188
+
1189
+ function hostOf(url) {
1190
+ try {
1191
+ return new URL(url).hostname.replace(/^www\./, '')
1192
+ } catch {
1193
+ return ''
1194
+ }
1195
+ }
1196
+
1197
+ function cleanBingTitle(value) {
1198
+ const title = cleanText(value, 140)
1199
+ return title.replace(/\s+[-–|]\s+[^-–|]{2,48}$/, '').trim()
1200
+ }
1201
+
1202
+ function cleanText(value, max) {
1203
+ if (typeof value !== 'string') return ''
1204
+ return value
1205
+ .replace(/<[^>]*>/g, ' ')
1206
+ // Bing wraps matched terms in private-use markers (U+E000/U+E001); without
1207
+ // stripping them the UI renders tofu blocks where the highlights were
1208
+ .replace(/[\uE000-\uF8FF\u200B\uFEFF\u00AD]/g, '')
1209
+ .replace(/\s+/g, ' ')
1210
+ .trim()
1211
+ .slice(0, max)
1212
+ }
1213
+
1214
+ function htmlUnescape(value) {
1215
+ return String(value)
1216
+ .replace(/&quot;/g, '"')
1217
+ .replace(/&#0?39;|&apos;/g, "'")
1218
+ .replace(/&lt;/g, '<')
1219
+ .replace(/&gt;/g, '>')
1220
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
1221
+ .replace(/&amp;/g, '&')
1222
+ }
1223
+
1224
+ function hashUrl(value) {
1225
+ let hash = 2166136261
1226
+ for (let index = 0; index < value.length; index += 1) {
1227
+ hash ^= value.charCodeAt(index)
1228
+ hash = Math.imul(hash, 16777619)
1229
+ }
1230
+ return (hash >>> 0).toString(36)
1231
+ }
1232
+
1233
+ function isPrivateHost(hostname) {
1234
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
1235
+ if (!host) return true
1236
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) return true
1237
+ if (host === '::1') return true
1238
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
1239
+ const [a, b] = host.split('.').map(Number)
1240
+ if (a === 0 || a === 10 || a === 127) return true
1241
+ if (a === 169 && b === 254) return true
1242
+ if (a === 172 && b >= 16 && b <= 31) return true
1243
+ if (a === 192 && b === 168) return true
1244
+ if (a === 100 && b >= 64 && b <= 127) return true
1245
+ }
1246
+ return false
1247
+ }
1248
+
1249
+ /** Detect an image from its first bytes when the server's type is missing or wrong. */
1250
+ function sniffImageType(buffer) {
1251
+ if (!buffer || buffer.length < 12) return ''
1252
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
1253
+ if (buffer[0] === 0x89 && buffer.toString('latin1', 1, 4) === 'PNG') return 'image/png'
1254
+ if (buffer.toString('latin1', 0, 4) === 'GIF8') return 'image/gif'
1255
+ if (buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
1256
+ if (buffer.toString('latin1', 4, 8) === 'ftyp' && /^avi[fs]$|^heic$|^mif1$/.test(buffer.toString('latin1', 8, 12))) return 'image/avif'
1257
+ return ''
1258
+ }
1259
+
1260
+ function sendImage(res, entry) {
1261
+ res.statusCode = 200
1262
+ res.setHeader('Content-Type', entry.type)
1263
+ res.setHeader('Cache-Control', 'public, max-age=86400')
1264
+ res.end(entry.buffer)
1265
+ }
1266
+
1267
+ /** The API documents `{ data, meta }`; the three libraries wrap frames a few ways. */
1268
+ function pickFrames(json) {
1269
+ if (!json || typeof json !== 'object') return []
1270
+ const candidates = [
1271
+ json.data, json.frames, json.results, json.items, json.shots, json.spots,
1272
+ json.data?.frames, json.data?.results, json.data?.items, json.data?.shots, json.data?.spots,
1273
+ ]
1274
+ for (const candidate of candidates) {
1275
+ if (Array.isArray(candidate) && candidate.length) return candidate
1276
+ }
1277
+ const first = candidates.find((candidate) => Array.isArray(candidate)) || []
1278
+ const nested = []
1279
+ for (const item of first) {
1280
+ if (Array.isArray(item && item.frames)) nested.push(...item.frames)
1281
+ else if (Array.isArray(item && item.shots)) nested.push(...item.shots)
1282
+ }
1283
+ return nested.length ? nested : first
1284
+ }
1285
+
1286
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i
1287
+
1288
+ function normalizeFrames(items, kind) {
1289
+ if (!Array.isArray(items)) return []
1290
+ return items.slice(0, 24).map((item) => toFrame(item, kind)).filter(Boolean)
1291
+ }
1292
+
1293
+ function firstString(...values) {
1294
+ for (const value of values) {
1295
+ if (typeof value === 'string' && value) return value
1296
+ }
1297
+ return ''
1298
+ }
1299
+
1300
+ function toFrame(item, kind) {
1301
+ if (!item || typeof item !== 'object') return null
1302
+
1303
+ // `film` for film stills, `title` (or a bare object) for animation,
1304
+ // `spot` / `commercial` / `ad` for a shot inside an ad.
1305
+ const container = item.film ?? item.title ?? item.series ?? item.animation
1306
+ ?? item.spot ?? item.commercial ?? item.ad ?? null
1307
+ const asObject = container && typeof container === 'object' ? container : null
1308
+ const spot = kind === 'commercial'
1309
+ ? (item.spot ?? item.commercial ?? item.ad ?? asObject)
1310
+ : null
1311
+ let title = typeof container === 'string'
1312
+ ? container
1313
+ : String(asObject?.title ?? asObject?.name ?? item.titleText ?? (typeof item.title === 'string' ? item.title : '') ?? '')
1314
+ if (kind === 'commercial') {
1315
+ const brand = firstString(spot?.brand, item.brand, item.spot_brand, item.brand_name, item.brandName, item.advertiser)
1316
+ const name = firstString(
1317
+ spot?.title, item.spotTitle, item.spot_title, item.ad_title, item.adTitle,
1318
+ typeof item.title === 'string' ? item.title : '', asObject?.name,
1319
+ )
1320
+ title = [brand, name].filter(Boolean).join(' · ')
1321
+ }
1322
+ const year = numberOr(spot?.year ?? item.spot_year ?? item.spotYear ?? asObject?.year ?? item.year, null)
1323
+
1324
+ const thumb = firstString(
1325
+ item.thumbUrl, item.thumbnail, item.thumb_url, item.thumbnail_url,
1326
+ item.thumbnailUrl, item.still_url, item.stillUrl, item.still, item.image?.thumb,
1327
+ spot?.thumbUrl, spot?.thumb_url, spot?.still_url, spot?.stillUrl, spot?.still,
1328
+ )
1329
+ const image = firstString(
1330
+ item.imageUrl, item.image_url, item.full_url, item.still_url, item.stillUrl,
1331
+ item.still, item.image?.url, item.image, item.thumbUrl, thumb,
1332
+ )
1333
+ const link = firstString(
1334
+ item.url, item.link, item.deepLink, item.deep_link, item.watchUrl, item.watch_url,
1335
+ item.shot_url, item.shotUrl, item.spot_url, item.spotUrl,
1336
+ spot?.deepLink, spot?.deep_link, spot?.url, spot?.watchUrl, spot?.watch_url,
1337
+ )
1338
+
1339
+ // The documented shape nests scene data under `metadata`; the OpenAPI schema
1340
+ // puts it at the top level. Film metadata is camelCase, animation snake_case,
1341
+ // commercial shots mostly top-level snake_case.
1342
+ const meta = item.metadata && typeof item.metadata === 'object' ? item.metadata : {}
1343
+ const palette = paletteOf(
1344
+ item.palette ?? item.colorPalette ?? item.colors ?? item.color_palette
1345
+ ?? meta.colors ?? meta.colorPalette,
1346
+ )
1347
+
1348
+ const tags = [
1349
+ firstString(meta.shot_type, meta.shotType, item.shot_type, item.shotType, item.shot_size),
1350
+ firstString(meta.visual_style, meta.visualStyle, item.visual_style),
1351
+ firstString(meta.time_of_day, meta.timeOfDay, item.time_of_day, item.timeOfDay),
1352
+ firstString(meta.setting, item.setting),
1353
+ firstString(meta.lens_character, meta.lensCharacter, item.lens_character),
1354
+ firstString(item.lighting, item.mood, meta.lightingKey),
1355
+ ].filter(Boolean).slice(0, 4)
1356
+
1357
+ const caption = firstString(
1358
+ item.prompt, meta.sceneDescription, item.sceneDescription, item.scene_description,
1359
+ item.caption, item.description, meta.colorDescription, item.colorDescription,
1360
+ item.logline, spot?.logline,
1361
+ [firstString(meta.shot_type, item.shot_size), item.setting, item.time_of_day].filter(Boolean).join(' · '),
1362
+ ).slice(0, 280)
1363
+
1364
+ if (!thumb && !image) return null
1365
+
1366
+ const slug = firstString(asObject?.slug, item.slug, spot?.slug, spot?.id)
1367
+
1368
+ return {
1369
+ id: String(item.id ?? item.shot_id ?? item.shotId ?? ''),
1370
+ src: 'framethrower',
1371
+ kind,
1372
+ thumb,
1373
+ image,
1374
+ link,
1375
+ title: title.slice(0, 120),
1376
+ year,
1377
+ slug,
1378
+ caption,
1379
+ tags,
1380
+ palette,
1381
+ score: typeof item.score === 'number' ? item.score : null,
1382
+ episode: item.episode ?? item.episodeNumber ?? null,
1383
+ timecode: item.timecode ?? item.timestamp ?? item.shotTime ?? null,
1384
+ }
1385
+ }
1386
+
1387
+ function paletteOf(value) {
1388
+ if (!Array.isArray(value)) return []
1389
+ return value
1390
+ .map((entry) => (typeof entry === 'string' ? entry : entry && (entry.hex ?? entry.value ?? entry.color)))
1391
+ .filter((color) => typeof color === 'string' && HEX.test(color))
1392
+ .slice(0, 6)
1393
+ }
1394
+
1395
+ function warningsOf(json) {
1396
+ const raw = json?.meta?.warnings
1397
+ if (!Array.isArray(raw)) return []
1398
+ return raw.slice(0, 4).map((warning) => String(warning).slice(0, 200))
1399
+ }
1400
+
1401
+ function numberOr(value, fallback) {
1402
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
1403
+ }
1404
+
1405
+ function text(value, max) {
1406
+ return typeof value === 'string' ? value.trim().slice(0, max) : ''
1407
+ }
1408
+
1409
+ function clampInt(value, min, max, fallback) {
1410
+ const number = Number(value)
1411
+ if (!Number.isFinite(number)) return fallback
1412
+ return Math.min(max, Math.max(min, Math.trunc(number)))
1413
+ }
1414
+
1415
+ function parseImageDataUrl(value) {
1416
+ if (typeof value !== 'string') return null
1417
+ const raw = value.trim()
1418
+ if (!raw || raw.length > 2_800_000) return null
1419
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/i.exec(raw)
1420
+ if (!match) return null
1421
+ const data = match[2].replace(/\s+/g, '')
1422
+ if (!data || data.length > 2_800_000) return null
1423
+ return { mediaType: match[1].toLowerCase(), data }
1424
+ }
1425
+
1426
+ function parseImageWebQueries(raw) {
1427
+ const start = raw.indexOf('{')
1428
+ const end = raw.lastIndexOf('}')
1429
+ if (start < 0 || end <= start) return null
1430
+ let parsed
1431
+ try {
1432
+ parsed = JSON.parse(raw.slice(start, end + 1))
1433
+ } catch {
1434
+ return null
1435
+ }
1436
+ const listed = Array.isArray(parsed?.web) ? parsed.web : (typeof parsed?.web === 'string' ? [parsed.web] : [])
1437
+ const web = []
1438
+ for (const item of listed) {
1439
+ let entry = text(item, 120)
1440
+ if (!entry) continue
1441
+ const words = entry.split(/\s+/).filter(Boolean)
1442
+ if (words.length > 8) entry = words.slice(0, 8).join(' ').replace(/[,;:]+$/, '')
1443
+ if (!web.includes(entry)) web.push(entry)
1444
+ if (web.length === 3) break
1445
+ }
1446
+ return web.length ? web : null
1447
+ }
1448
+
1449
+ async function readJson(req) {
1450
+ const chunks = []
1451
+ let size = 0
1452
+ for await (const chunk of req) {
1453
+ size += chunk.length
1454
+ if (size > 3 * 1024 * 1024) throw new Error('too large')
1455
+ chunks.push(chunk)
1456
+ }
1457
+ if (!chunks.length) return {}
1458
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
1459
+ }
1460
+
1461
+ async function safeText(response) {
1462
+ try {
1463
+ return await response.text()
1464
+ } catch {
1465
+ return ''
1466
+ }
1467
+ }
1468
+
1469
+ function send(res, status, body) {
1470
+ res.statusCode = status
1471
+ if (body === null) {
1472
+ res.end()
1473
+ return
1474
+ }
1475
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
1476
+ res.setHeader('Cache-Control', 'no-store')
1477
+ res.end(JSON.stringify(body))
1478
+ }