@exccoder/dsh-media-workbench 0.0.0-stage → 0.1.2
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 +35 -0
- package/LICENSE +21 -0
- package/README.en.md +111 -0
- package/README.md +141 -2
- package/SECURITY.md +29 -0
- package/cordis.patch.yml +4 -0
- package/docs/screenshot-graph.webp +0 -0
- package/docs/screenshot-image.webp +0 -0
- package/docs/screenshot-video.webp +0 -0
- package/docs/screenshot-write.webp +0 -0
- package/lib/client.js +110 -0
- package/package.json +79 -4
- package/src/graph.js +304 -0
- package/src/image.js +137 -0
- package/src/index.js +769 -0
- package/src/llm.js +60 -0
- package/src/settings.js +187 -0
- package/src/store.js +274 -0
- package/src/tools.js +354 -0
- package/src/util.js +217 -0
- package/src/video.js +219 -0
- package/src/writing.js +112 -0
- package/web/app.js +268 -0
- package/web/favicon.svg +12 -0
- package/web/index.html +16 -0
- package/web/lib/api.js +79 -0
- package/web/lib/constants.js +69 -0
- package/web/lib/dom.js +99 -0
- package/web/lib/icons.js +65 -0
- package/web/lib/state.js +42 -0
- package/web/styles.css +1096 -0
- package/web/views/constellation-model.js +63 -0
- package/web/views/constellation.js +1110 -0
- package/web/views/image.js +488 -0
- package/web/views/settings.js +244 -0
- package/web/views/video.js +363 -0
- package/web/views/write.js +513 -0
package/src/tools.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// 注册给模型的四个工具:让 DSH 在对话里也能直接用工作台
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { imageReady, videoReady } from './settings.js'
|
|
4
|
+
import { RATIOS, templateGraph } from './graph.js'
|
|
5
|
+
|
|
6
|
+
const text = value => [{ type: 'text', text: value }]
|
|
7
|
+
|
|
8
|
+
const str = (description, extra = {}) => ({ type: 'string', description, ...extra })
|
|
9
|
+
const num = (description, extra = {}) => ({ type: 'number', description, ...extra })
|
|
10
|
+
|
|
11
|
+
function check(args, schema) {
|
|
12
|
+
const errors = []
|
|
13
|
+
for (const key of schema.required ?? []) {
|
|
14
|
+
if (args?.[key] === undefined || args[key] === '') errors.push(`缺少 ${key}`)
|
|
15
|
+
}
|
|
16
|
+
for (const key of Object.keys(args ?? {})) {
|
|
17
|
+
if (!schema.properties[key]) errors.push(`未知参数 ${key}`)
|
|
18
|
+
}
|
|
19
|
+
if (errors.length) throw new Error(errors.join(';'))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function absolute(filesDir, file) {
|
|
23
|
+
return join(filesDir, file)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function wait(ms) {
|
|
27
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function waitUntil(fn, { timeoutMs = 600_000, interval = 1500 } = {}) {
|
|
31
|
+
const start = Date.now()
|
|
32
|
+
while (Date.now() - start < timeoutMs) {
|
|
33
|
+
const value = await fn()
|
|
34
|
+
if (value) return value
|
|
35
|
+
await wait(interval)
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function apply(ctx, deps) {
|
|
41
|
+
const { store, settings, saveImageBuffer, runImage, createVideo, runVideoRecord, runGraph, saveDocument, ready = Promise.resolve() } = deps
|
|
42
|
+
|
|
43
|
+
const imageTool = {
|
|
44
|
+
name: 'modern_media_generate_image',
|
|
45
|
+
description: '在「摩登-媒体工作室 → 生图工作台」里生成图片,结果会自动存进作品历史。用户要做封面、配图、海报、概念图时用。需要用户先在插件设置里配好生图服务。生成后可以用 present 把文件给用户看。',
|
|
46
|
+
parameters: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
required: ['prompt'],
|
|
50
|
+
properties: {
|
|
51
|
+
prompt: str('画面描述。写清主体、动作、构图、光线、材质和氛围;不要在画面里放文字。'),
|
|
52
|
+
style: str('风格:电影感 / 插画 / 水墨 / 赛博朋克 / 胶片 / 3D / 极简 / 浮世绘 / 概念艺术,或留空'),
|
|
53
|
+
ratio: str('画幅', { enum: Object.keys(RATIOS) }),
|
|
54
|
+
count: num('生成几张,1 到 2', { minimum: 1, maximum: 2 }),
|
|
55
|
+
reference: str('(可选)参考图文件名,来自工作台上传的图;用它会走图生图/编辑'),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
timeoutMs: 300_000,
|
|
59
|
+
output: {
|
|
60
|
+
schema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
required: ['files', 'urls', 'prompt'],
|
|
64
|
+
properties: {
|
|
65
|
+
files: { type: 'array', items: { type: 'string' } },
|
|
66
|
+
urls: { type: 'array', items: { type: 'string' } },
|
|
67
|
+
prompt: { type: 'string' },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
render: (_args, value) => text(`已在工作台生成 ${value.files.length} 张图:\n${value.files.map(file => `- ${file}`).join('\n')}`),
|
|
71
|
+
},
|
|
72
|
+
async execute(args) {
|
|
73
|
+
check(args, { ...imageTool.parameters })
|
|
74
|
+
const current = await settings.read()
|
|
75
|
+
if (!imageReady(current)) throw new Error('还没有配置生图服务,请让用户打开「摩登-媒体工作室 → 设置 → 生图服务」。')
|
|
76
|
+
const count = Math.max(1, Math.min(2, Math.round(Number(args.count) || 1)))
|
|
77
|
+
const ratio = RATIOS[args.ratio] ?? RATIOS['1:1']
|
|
78
|
+
const prompt = String(args.prompt).trim()
|
|
79
|
+
const files = []
|
|
80
|
+
const urls = []
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
const id = `tool_${Date.now().toString(36)}${i}`
|
|
83
|
+
const result = await runImageDirect(id, {
|
|
84
|
+
prompt,
|
|
85
|
+
style: args.style ?? '',
|
|
86
|
+
size: ratio.size,
|
|
87
|
+
reference: args.reference ?? '',
|
|
88
|
+
model: current.image.model,
|
|
89
|
+
})
|
|
90
|
+
files.push(result.path)
|
|
91
|
+
urls.push(result.url)
|
|
92
|
+
}
|
|
93
|
+
return { files, urls, prompt }
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runImageDirect(id, request) {
|
|
98
|
+
const current = await settings.read()
|
|
99
|
+
const { generateImage } = await import('./image.js')
|
|
100
|
+
const result = await generateImage(current, request, { filesDir: store.filesDir })
|
|
101
|
+
const file = await saveImageBuffer(id, result)
|
|
102
|
+
const row = {
|
|
103
|
+
id,
|
|
104
|
+
prompt: request.prompt,
|
|
105
|
+
negative: '',
|
|
106
|
+
style: request.style ?? '',
|
|
107
|
+
model: current.image.model,
|
|
108
|
+
size: request.size,
|
|
109
|
+
reference: request.reference ?? '',
|
|
110
|
+
file,
|
|
111
|
+
width: result.width,
|
|
112
|
+
height: result.height,
|
|
113
|
+
status: 'done',
|
|
114
|
+
error: '',
|
|
115
|
+
favorite: false,
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
}
|
|
118
|
+
await store.mutate(state => { state.images.unshift(row); state.images = state.images.slice(0, 300) })
|
|
119
|
+
return { path: absolute(store.filesDir, file), url: `${deps.BASE}/files/${file}` }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const documentTool = {
|
|
123
|
+
name: 'modern_media_save_document',
|
|
124
|
+
description: '把一篇写好的稿子保存进「摩登-媒体工作室 → 写作工作台」,用户可以在那里继续改。适合交付文章、脚本、章节、报告。',
|
|
125
|
+
parameters: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
required: ['title', 'content'],
|
|
129
|
+
properties: {
|
|
130
|
+
title: str('作品标题'),
|
|
131
|
+
content: str('完整正文,Markdown 或纯文本'),
|
|
132
|
+
outline: str('(可选)大纲,Markdown'),
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
timeoutMs: 30_000,
|
|
136
|
+
output: {
|
|
137
|
+
schema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
required: ['id', 'title', 'chars'],
|
|
141
|
+
properties: { id: { type: 'string' }, title: { type: 'string' }, chars: { type: 'integer' } },
|
|
142
|
+
},
|
|
143
|
+
render: (_args, value) => text(`已保存到写作工作台:${value.title}(${value.chars} 字)。`),
|
|
144
|
+
},
|
|
145
|
+
async execute(args) {
|
|
146
|
+
check(args, documentTool.parameters)
|
|
147
|
+
const doc = await saveDocument({ title: args.title, content: args.content, outline: args.outline ?? '' })
|
|
148
|
+
return { id: doc.id, title: doc.title, chars: [...String(args.content)].length }
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const videoTool = {
|
|
153
|
+
name: 'modern_media_generate_video',
|
|
154
|
+
description: '在「摩登-媒体工作室 → 视频工作台」里生成视频或分镜短片。没有配置视频服务时,用 mode=storyboard 以 4 张关键帧做出动态分镜。任务较长,默认等到完成(最多 9 分钟)。',
|
|
155
|
+
parameters: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
required: ['prompt'],
|
|
159
|
+
properties: {
|
|
160
|
+
prompt: str('画面描述,写清镜头、动作、光线和氛围'),
|
|
161
|
+
mode: str('模式', { enum: ['text', 'image', 'storyboard'] }),
|
|
162
|
+
ratio: str('画幅', { enum: Object.keys(RATIOS) }),
|
|
163
|
+
duration: num('时长(秒),2 到 20', { minimum: 2, maximum: 20 }),
|
|
164
|
+
reference: str('(可选)首帧图片文件名'),
|
|
165
|
+
wait: { type: 'boolean', description: '是否等到完成再返回,默认 true' },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
timeoutMs: 600_000,
|
|
169
|
+
output: {
|
|
170
|
+
schema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
required: ['id', 'status'],
|
|
174
|
+
properties: {
|
|
175
|
+
id: { type: 'string' },
|
|
176
|
+
status: { type: 'string' },
|
|
177
|
+
file: { type: 'string' },
|
|
178
|
+
frames: { type: 'array', items: { type: 'string' } },
|
|
179
|
+
error: { type: 'string' },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
render: (_args, value) => text(value.status === 'done'
|
|
183
|
+
? `视频已完成:${value.file || `${value.frames?.length ?? 0} 张分镜`}\n${value.file ?? ''}`
|
|
184
|
+
: `视频任务 ${value.id} 当前状态:${value.status}${value.error ? `(${value.error})` : ''}`),
|
|
185
|
+
},
|
|
186
|
+
async execute(args) {
|
|
187
|
+
check(args, videoTool.parameters)
|
|
188
|
+
const current = await settings.read()
|
|
189
|
+
const mode = args.mode ?? 'text'
|
|
190
|
+
if (mode !== 'storyboard' && !videoReady(current)) {
|
|
191
|
+
if (mode === 'text' && imageReady(current)) args.mode = 'storyboard'
|
|
192
|
+
else throw new Error('还没有配置视频服务,也没有可用的生图服务。')
|
|
193
|
+
}
|
|
194
|
+
const item = await createVideo({
|
|
195
|
+
prompt: args.prompt,
|
|
196
|
+
mode: args.mode ?? 'text',
|
|
197
|
+
ratio: args.ratio,
|
|
198
|
+
duration: args.duration,
|
|
199
|
+
reference: args.reference,
|
|
200
|
+
})
|
|
201
|
+
if (args.wait === false) return { id: item.id, status: 'queued' }
|
|
202
|
+
const done = await waitUntil(async () => {
|
|
203
|
+
const state = await store.read()
|
|
204
|
+
const row = state.videos.find(v => v.id === item.id)
|
|
205
|
+
if (!row) return { status: 'missing' }
|
|
206
|
+
if (row.status === 'done' || row.status === 'error') return row
|
|
207
|
+
return null
|
|
208
|
+
}, { timeoutMs: 540_000, interval: 2000 })
|
|
209
|
+
if (!done) return { id: item.id, status: 'running' }
|
|
210
|
+
return {
|
|
211
|
+
id: item.id,
|
|
212
|
+
status: done.status,
|
|
213
|
+
...(done.file ? { file: absolute(store.filesDir, done.file) } : {}),
|
|
214
|
+
...(done.frames?.length ? { frames: done.frames.map(file => absolute(store.filesDir, file)) } : {}),
|
|
215
|
+
...(done.error ? { error: done.error } : {}),
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const mapTool = {
|
|
221
|
+
name: 'modern_media_run_star_map',
|
|
222
|
+
description: '运行「摩登-媒体工作室 → 星图工作台」里的一张星图(提示词 → 写作 → 生图 → 视频 → 预览的有向无环图)。可以先用模板 inspiration / card / video 新建一张再运行。返回每个节点产出的文件与文字。',
|
|
223
|
+
parameters: {
|
|
224
|
+
type: 'object',
|
|
225
|
+
additionalProperties: false,
|
|
226
|
+
properties: {
|
|
227
|
+
graphId: str('要运行的星图 id;不填则用 template 新建一张'),
|
|
228
|
+
template: str('新建星图用的模板', { enum: ['inspiration', 'card', 'video'] }),
|
|
229
|
+
name: str('新建星图的名字'),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
timeoutMs: 900_000,
|
|
233
|
+
output: {
|
|
234
|
+
schema: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
additionalProperties: false,
|
|
237
|
+
required: ['graphId', 'status', 'outputs'],
|
|
238
|
+
properties: {
|
|
239
|
+
graphId: { type: 'string' },
|
|
240
|
+
status: { type: 'string' },
|
|
241
|
+
outputs: {
|
|
242
|
+
type: 'array',
|
|
243
|
+
items: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
required: ['nodeId', 'type', 'status'],
|
|
247
|
+
properties: {
|
|
248
|
+
nodeId: { type: 'string' },
|
|
249
|
+
type: { type: 'string' },
|
|
250
|
+
status: { type: 'string' },
|
|
251
|
+
text: { type: 'string' },
|
|
252
|
+
file: { type: 'string' },
|
|
253
|
+
error: { type: 'string' },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
render: (_args, value) => text(`星图 ${value.graphId}:${value.status}\n${value.outputs.map(row => `- [${row.type}] ${row.status}${row.file ? ` → ${row.file}` : ''}${row.text ? ` → ${row.text.slice(0, 80)}` : ''}`).join('\n')}`),
|
|
260
|
+
},
|
|
261
|
+
async execute(args) {
|
|
262
|
+
check(args, mapTool.parameters)
|
|
263
|
+
let graphId = args.graphId
|
|
264
|
+
if (!graphId) {
|
|
265
|
+
if (!args.template) throw new Error('要么给 graphId,要么给 template')
|
|
266
|
+
const graph = templateGraph(args.template)
|
|
267
|
+
if (args.name) graph.name = String(args.name).slice(0, 40)
|
|
268
|
+
await store.mutate(state => { state.graphs.unshift(graph); state.graphs = state.graphs.slice(0, 40) })
|
|
269
|
+
graphId = graph.id
|
|
270
|
+
}
|
|
271
|
+
await runGraph(graphId, [])
|
|
272
|
+
const done = await waitUntil(async () => {
|
|
273
|
+
const state = await store.read()
|
|
274
|
+
const graph = state.graphs.find(g => g.id === graphId)
|
|
275
|
+
if (graph?.run && ['done', 'error', 'stopped'].includes(graph.run.status)) return graph
|
|
276
|
+
return null
|
|
277
|
+
}, { timeoutMs: 840_000, interval: 2000 })
|
|
278
|
+
const state = await store.read()
|
|
279
|
+
const graph = done ?? state.graphs.find(g => g.id === graphId)
|
|
280
|
+
const outputs = (graph?.nodes ?? []).filter(node => node.type !== 'note').map(node => ({
|
|
281
|
+
nodeId: node.id,
|
|
282
|
+
type: node.type,
|
|
283
|
+
status: node.status ?? 'idle',
|
|
284
|
+
...(node.output?.kind === 'text' && node.output.text ? { text: node.output.text } : {}),
|
|
285
|
+
...(node.output?.file ? { file: absolute(store.filesDir, node.output.file) } : {}),
|
|
286
|
+
...(node.error ? { error: node.error } : {}),
|
|
287
|
+
}))
|
|
288
|
+
return { graphId, status: graph?.run?.status ?? 'unknown', outputs }
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const inspectTool = {
|
|
293
|
+
name: 'modern_media_workspace_overview',
|
|
294
|
+
description: '查看「摩登-媒体工作室」里现有的作品:最近的图片、视频、写作文档和星图。想接着用户的上一件作品做时先用它。',
|
|
295
|
+
parameters: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
additionalProperties: false,
|
|
298
|
+
properties: {
|
|
299
|
+
kind: str('要看的类型', { enum: ['all', 'images', 'videos', 'documents', 'graphs'] }),
|
|
300
|
+
limit: num('最多返回几条,默认 8', { minimum: 1, maximum: 30 }),
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
timeoutMs: 30_000,
|
|
304
|
+
output: {
|
|
305
|
+
schema: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
additionalProperties: false,
|
|
308
|
+
required: ['summary'],
|
|
309
|
+
properties: { summary: { type: 'string' }, data: { type: 'object' } },
|
|
310
|
+
},
|
|
311
|
+
render: (_args, value) => text(value.summary),
|
|
312
|
+
},
|
|
313
|
+
async execute(args) {
|
|
314
|
+
check(args, inspectTool.parameters)
|
|
315
|
+
const state = await store.read()
|
|
316
|
+
const limit = Math.max(1, Math.min(30, Number(args.limit) || 8))
|
|
317
|
+
const kind = args.kind ?? 'all'
|
|
318
|
+
const data = {}
|
|
319
|
+
const lines = []
|
|
320
|
+
if (kind === 'all' || kind === 'images') {
|
|
321
|
+
data.images = state.images.slice(0, limit).map(row => ({ id: row.id, prompt: row.prompt, style: row.style, file: row.file, favorite: row.favorite }))
|
|
322
|
+
lines.push(`图片 ${state.images.length} 张`)
|
|
323
|
+
for (const row of data.images) lines.push(`- ${row.prompt.slice(0, 50)}${row.file ? `(${row.file})` : ''}`)
|
|
324
|
+
}
|
|
325
|
+
if (kind === 'all' || kind === 'videos') {
|
|
326
|
+
data.videos = state.videos.slice(0, limit).map(row => ({ id: row.id, prompt: row.prompt, mode: row.mode, status: row.status, file: row.file }))
|
|
327
|
+
lines.push(`视频 ${state.videos.length} 条`)
|
|
328
|
+
}
|
|
329
|
+
if (kind === 'all' || kind === 'documents') {
|
|
330
|
+
data.documents = state.docs.slice(0, limit).map(row => ({ id: row.id, title: row.title, chars: [...row.content].length, updatedAt: row.updatedAt }))
|
|
331
|
+
lines.push(`文档 ${state.docs.length} 篇`)
|
|
332
|
+
for (const row of data.documents) lines.push(`- ${row.title}(${row.chars} 字)`)
|
|
333
|
+
}
|
|
334
|
+
if (kind === 'all' || kind === 'graphs') {
|
|
335
|
+
data.graphs = state.graphs.slice(0, limit).map(row => ({ id: row.id, name: row.name, nodes: row.nodes.length }))
|
|
336
|
+
lines.push(`星图 ${state.graphs.length} 张`)
|
|
337
|
+
}
|
|
338
|
+
return { summary: lines.join('\n'), data }
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const tool of [imageTool, documentTool, videoTool, mapTool, inspectTool]) {
|
|
343
|
+
const execute = tool.execute
|
|
344
|
+
ctx.tools.register({
|
|
345
|
+
...tool,
|
|
346
|
+
execute: async (...args) => {
|
|
347
|
+
await ready
|
|
348
|
+
return execute.apply(tool, args)
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export { text }
|
package/src/util.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// @exccoder/dsh-media-workbench · host 工具函数:HTTP、文件、安全校验
|
|
2
|
+
import { createReadStream } from 'node:fs'
|
|
3
|
+
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { extname, join, normalize, sep } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const MIME = {
|
|
7
|
+
'.html': 'text/html; charset=utf-8',
|
|
8
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
9
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
'.png': 'image/png',
|
|
14
|
+
'.jpg': 'image/jpeg',
|
|
15
|
+
'.jpeg': 'image/jpeg',
|
|
16
|
+
'.webp': 'image/webp',
|
|
17
|
+
'.gif': 'image/gif',
|
|
18
|
+
'.avif': 'image/avif',
|
|
19
|
+
'.mp4': 'video/mp4',
|
|
20
|
+
'.webm': 'video/webm',
|
|
21
|
+
'.mov': 'video/quicktime',
|
|
22
|
+
'.woff2': 'font/woff2',
|
|
23
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
24
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function json(res, status, body) {
|
|
28
|
+
const payload = JSON.stringify(body)
|
|
29
|
+
res.statusCode = status
|
|
30
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
31
|
+
res.setHeader('cache-control', 'no-store')
|
|
32
|
+
res.setHeader('x-content-type-options', 'nosniff')
|
|
33
|
+
res.end(payload)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fail(message, status = 400) {
|
|
37
|
+
const err = new Error(message)
|
|
38
|
+
err.status = status
|
|
39
|
+
return err
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readBuffer(req, limit = 8_000_000) {
|
|
43
|
+
const chunks = []
|
|
44
|
+
let size = 0
|
|
45
|
+
for await (const chunk of req) {
|
|
46
|
+
size += chunk.length
|
|
47
|
+
if (size > limit) throw fail('内容太大', 413)
|
|
48
|
+
chunks.push(chunk)
|
|
49
|
+
}
|
|
50
|
+
return Buffer.concat(chunks)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readJson(req, limit = 2_000_000) {
|
|
54
|
+
const raw = await readBuffer(req, limit)
|
|
55
|
+
if (!raw.length) return {}
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(raw.toString('utf8'))
|
|
58
|
+
} catch {
|
|
59
|
+
throw fail('请求不是有效的 JSON', 400)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 支持 Range,视频才能拖动和循环 */
|
|
64
|
+
export async function sendFile(req, res, file, { cache = 'private, max-age=3600' } = {}) {
|
|
65
|
+
const type = MIME[extname(file).toLowerCase()]
|
|
66
|
+
if (!type) return json(res, 404, { error: '不支持的文件类型' })
|
|
67
|
+
let info
|
|
68
|
+
try {
|
|
69
|
+
info = await stat(file)
|
|
70
|
+
} catch {
|
|
71
|
+
return json(res, 404, { error: '文件不存在' })
|
|
72
|
+
}
|
|
73
|
+
if (!info.isFile()) return json(res, 404, { error: '文件不存在' })
|
|
74
|
+
res.setHeader('content-type', type)
|
|
75
|
+
res.setHeader('accept-ranges', 'bytes')
|
|
76
|
+
res.setHeader('cache-control', cache)
|
|
77
|
+
res.setHeader('x-content-type-options', 'nosniff')
|
|
78
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '')
|
|
79
|
+
if (range && (range[1] || range[2])) {
|
|
80
|
+
const start = range[1] ? Number(range[1]) : Math.max(0, info.size - Number(range[2]))
|
|
81
|
+
const end = range[1] && range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1
|
|
82
|
+
if (start >= info.size || start > end) {
|
|
83
|
+
res.statusCode = 416
|
|
84
|
+
res.setHeader('content-range', `bytes */${info.size}`)
|
|
85
|
+
return res.end()
|
|
86
|
+
}
|
|
87
|
+
res.statusCode = 206
|
|
88
|
+
res.setHeader('content-range', `bytes ${start}-${end}/${info.size}`)
|
|
89
|
+
res.setHeader('content-length', end - start + 1)
|
|
90
|
+
return createReadStream(file, { start, end }).pipe(res)
|
|
91
|
+
}
|
|
92
|
+
res.statusCode = 200
|
|
93
|
+
res.setHeader('content-length', info.size)
|
|
94
|
+
if (req.method === 'HEAD') return res.end()
|
|
95
|
+
return createReadStream(file).pipe(res)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function newId(prefix = 'x') {
|
|
99
|
+
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function sleep(ms, signal) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
if (signal?.aborted) return reject(signal.reason ?? new Error('aborted'))
|
|
105
|
+
const timer = setTimeout(resolve, ms)
|
|
106
|
+
signal?.addEventListener('abort', () => {
|
|
107
|
+
clearTimeout(timer)
|
|
108
|
+
reject(signal.reason ?? new Error('aborted'))
|
|
109
|
+
}, { once: true })
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 从图片字节里读出宽高,用于画廊布局 */
|
|
114
|
+
export function imageSize(buffer) {
|
|
115
|
+
try {
|
|
116
|
+
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50) {
|
|
117
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
|
118
|
+
}
|
|
119
|
+
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
120
|
+
let offset = 2
|
|
121
|
+
while (offset + 9 < buffer.length) {
|
|
122
|
+
if (buffer[offset] !== 0xff) { offset++; continue }
|
|
123
|
+
const marker = buffer[offset + 1]
|
|
124
|
+
const length = buffer.readUInt16BE(offset + 2)
|
|
125
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
126
|
+
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) }
|
|
127
|
+
}
|
|
128
|
+
offset += 2 + length
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (buffer.length > 30 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') {
|
|
132
|
+
const kind = buffer.toString('latin1', 12, 16)
|
|
133
|
+
if (kind === 'VP8X') return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) }
|
|
134
|
+
if (kind === 'VP8 ') return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
|
|
135
|
+
if (kind === 'VP8L') {
|
|
136
|
+
const bits = buffer.readUInt32LE(21)
|
|
137
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch { /* 尺寸只是展示增强 */ }
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function extensionOf(buffer) {
|
|
145
|
+
if (buffer.length > 8 && buffer[0] === 0x89 && buffer[1] === 0x50) return '.png'
|
|
146
|
+
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return '.jpg'
|
|
147
|
+
if (buffer.length > 12 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') return '.webp'
|
|
148
|
+
if (buffer.length > 6 && buffer.toString('latin1', 0, 3) === 'GIF') return '.gif'
|
|
149
|
+
if (buffer.length > 12 && buffer.toString('latin1', 4, 12) === 'ftypavif') return '.avif'
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function videoExtensionOf(buffer) {
|
|
154
|
+
if (buffer.length > 12 && buffer.toString('latin1', 4, 8) === 'ftyp') return '.mp4'
|
|
155
|
+
if (buffer.length > 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) return '.webm'
|
|
156
|
+
return '.mp4'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function writeAtomic(file, data, mode) {
|
|
160
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 6)}.tmp`
|
|
161
|
+
await writeFile(tmp, data, mode ? { mode } : undefined)
|
|
162
|
+
await rename(tmp, file)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function ensureDir(dir) {
|
|
166
|
+
await mkdir(dir, { recursive: true })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 静态目录内取文件:拒绝穿越与点文件 */
|
|
170
|
+
export function safeJoin(root, rel) {
|
|
171
|
+
const clean = normalize(rel).replace(/^([/\\])+/, '')
|
|
172
|
+
if (!clean || clean.split(/[\\/]/).some(part => part.startsWith('.'))) return null
|
|
173
|
+
const base = normalize(root)
|
|
174
|
+
const file = normalize(join(base, clean))
|
|
175
|
+
return file.startsWith(base + sep) ? file : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function readJsonFile(file, fallback) {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(await readFile(file, 'utf8'))
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err.code === 'ENOENT') return fallback
|
|
183
|
+
throw err
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 只允许公网 http(s):挡住本机、内网和云元数据地址 */
|
|
188
|
+
export function assertRemoteUrl(input) {
|
|
189
|
+
let url
|
|
190
|
+
try {
|
|
191
|
+
url = new URL(String(input))
|
|
192
|
+
} catch {
|
|
193
|
+
throw fail('图片地址无效', 400)
|
|
194
|
+
}
|
|
195
|
+
if (!/^https?:$/.test(url.protocol)) throw fail('只支持 http/https 地址', 400)
|
|
196
|
+
const host = url.hostname.toLowerCase()
|
|
197
|
+
const blocked = /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|::1|\[::1\])/.test(host)
|
|
198
|
+
if (blocked) throw fail('出于安全考虑,不能访问本机或内网地址', 400)
|
|
199
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
|
|
200
|
+
if (m) {
|
|
201
|
+
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
202
|
+
if (a === 127 || a === 10 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) {
|
|
203
|
+
throw fail('出于安全考虑,不能访问本机或内网地址', 400)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return url
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function clampInt(value, min, max, fallback) {
|
|
210
|
+
const n = Number(value)
|
|
211
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function redact(text, secret) {
|
|
215
|
+
const clean = String(text ?? '').slice(0, 400)
|
|
216
|
+
return secret ? clean.split(secret).join('***') : clean
|
|
217
|
+
}
|