dsh-reference-frames 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -0
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/client.js +3685 -0
- package/cordis.patch.yml +10 -0
- package/docs/GUIDE.md +119 -0
- package/docs/GUIDE.zh-CN.md +120 -0
- package/docs/README.zh-CN.md +47 -0
- package/icon.svg +25 -0
- package/index.js +1478 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -4
- package/presets/moodboards.json +361 -0
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(/"/g, '"')
|
|
1217
|
+
.replace(/�?39;|'/g, "'")
|
|
1218
|
+
.replace(/</g, '<')
|
|
1219
|
+
.replace(/>/g, '>')
|
|
1220
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
1221
|
+
.replace(/&/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
|
+
}
|