@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/src/index.js ADDED
@@ -0,0 +1,769 @@
1
+ // @exccoder/dsh-media-workbench · host 入口
2
+ // 四个工作台共用一套本机存储:生图 / 视频 / 写作 / 星图
3
+ import { writeFile } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createNode, dependencyClosure, normalizeGraph, RATIOS, templateGraph, topoWaves } from './graph.js'
7
+ import { apply as applyTools } from './tools.js'
8
+ import { generateImage } from './image.js'
9
+ import { modelInfo, streamText } from './llm.js'
10
+ import { imageReady, IMAGE_PRESETS, publicSettings, Settings, VIDEO_PRESETS, videoReady } from './settings.js'
11
+ import { Store, migrateLegacyData } from './store.js'
12
+ import { clampInt, extensionOf, fail, imageSize, json, newId, readBuffer, readJson, safeJoin, sendFile, videoExtensionOf } from './util.js'
13
+ import { generateStoryboard, generateVideo } from './video.js'
14
+ import { cleanOutput, writingPrompts } from './writing.js'
15
+
16
+ const ROOT = dirname(fileURLToPath(import.meta.url))
17
+ const WEB_DIR = join(ROOT, '..', 'web')
18
+ const BASE = '/media-workbench-modern'
19
+ const VERSION = '0.1.2'
20
+
21
+ const STATIC = new Map([
22
+ ['', 'index.html'],
23
+ ['/', 'index.html'],
24
+ ['/index.html', 'index.html'],
25
+ ['/app.js', 'app.js'],
26
+ ['/styles.css', 'styles.css'],
27
+ ['/favicon.svg', 'favicon.svg'],
28
+ ])
29
+
30
+ function sameOrigin(req) {
31
+ const site = req.headers['sec-fetch-site']
32
+ if (site && site !== 'same-origin' && site !== 'none') return false
33
+ const origin = req.headers.origin
34
+ if (!origin) return true
35
+ try {
36
+ return new URL(origin).host === req.headers.host
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ function rejection(ctx, req) {
43
+ const connection = ctx.get('connection')
44
+ if (typeof connection?.requestRejection !== 'function') return 403
45
+ return connection.requestRejection({ headers: req.headers })
46
+ }
47
+
48
+ export const name = '@exccoder/dsh-media-workbench'
49
+ export const inject = ['webServer', 'connection', 'tools']
50
+
51
+ export function apply(ctx) {
52
+ const store = new Store()
53
+ const settings = new Settings(store.root)
54
+ const jobs = new Map()
55
+ const graphJobs = new Map()
56
+ let settled = false
57
+
58
+ // 第一次改名后,把旧目录的数据复制一次;不阻塞页面静态资源
59
+ const ready = migrateLegacyData(store.root).catch(err => {
60
+ console.warn('[media-workbench-modern] legacy data migration failed:', err.message)
61
+ return false
62
+ })
63
+
64
+ const settleOnce = async () => {
65
+ await ready
66
+ if (settled) return
67
+ settled = true
68
+ await store.settle().catch(() => {})
69
+ }
70
+
71
+ const saveImageBuffer = async (id, result) => {
72
+ const file = `${id}${result.ext}`
73
+ await writeFile(join(store.filesDir, file), result.buffer)
74
+ return file
75
+ }
76
+
77
+ const updateImage = (id, patch) => store.mutate(state => {
78
+ const row = state.images.find(item => item.id === id)
79
+ if (!row) return null
80
+ Object.assign(row, patch)
81
+ return row
82
+ })
83
+
84
+ async function runImage(id, request) {
85
+ const current = await settings.read()
86
+ const controller = new AbortController()
87
+ jobs.set(id, controller)
88
+ await updateImage(id, { status: 'running', error: '' })
89
+ try {
90
+ const result = await generateImage(current, request, { signal: controller.signal, filesDir: store.filesDir })
91
+ const file = await saveImageBuffer(id, result)
92
+ await updateImage(id, {
93
+ status: 'done',
94
+ file,
95
+ width: result.width,
96
+ height: result.height,
97
+ model: request.model || current.image.model,
98
+ error: '',
99
+ })
100
+ return { file }
101
+ } catch (err) {
102
+ await updateImage(id, { status: 'error', error: controller.signal.aborted ? '已停止' : err.message })
103
+ throw err
104
+ } finally {
105
+ jobs.delete(id)
106
+ }
107
+ }
108
+
109
+ async function createImages(body) {
110
+ const current = await settings.read()
111
+ if (!imageReady(current)) throw fail('还没有配置生图服务。打开「设置 → 生图服务」,填接口地址、模型和密钥。', 400)
112
+ const prompt = String(body.prompt ?? '').trim()
113
+ if (!prompt) throw fail('先写一句提示词', 400)
114
+ const count = clampInt(body.count, 1, 4, 1)
115
+ const ratio = RATIOS[body.ratio]
116
+ const size = String(body.size ?? '').trim() || ratio?.size || current.image.size
117
+ const reference = typeof body.reference === 'string' ? body.reference.replace(/[^\w.-]/g, '') : ''
118
+ const records = []
119
+ for (let i = 0; i < count; i++) {
120
+ records.push({
121
+ id: newId('img'),
122
+ prompt: prompt.slice(0, 3000),
123
+ negative: String(body.negative ?? '').slice(0, 1000),
124
+ style: String(body.style ?? '').slice(0, 40),
125
+ model: String(body.model ?? current.image.model).slice(0, 120),
126
+ size,
127
+ reference,
128
+ file: '',
129
+ width: 0,
130
+ height: 0,
131
+ status: 'queued',
132
+ error: '',
133
+ favorite: false,
134
+ createdAt: new Date().toISOString(),
135
+ })
136
+ }
137
+ await store.mutate(state => { state.images.unshift(...records); state.images = state.images.slice(0, 300) })
138
+ void Promise.all(records.map(row => runImage(row.id, row).catch(() => {})))
139
+ return records
140
+ }
141
+
142
+ async function createVideo(body) {
143
+ const current = await settings.read()
144
+ const mode = ['text', 'image', 'storyboard'].includes(body.mode) ? body.mode : 'text'
145
+ const prompt = String(body.prompt ?? '').trim()
146
+ if (!prompt) throw fail('先写一句画面描述', 400)
147
+ if (mode === 'storyboard') {
148
+ if (!imageReady(current)) throw fail('分镜短片需要先生图服务。也可以配好视频服务后改成文生视频。', 400)
149
+ } else if (!videoReady(current)) {
150
+ throw fail('还没有配置视频服务。可以先切到「分镜短片」,用生图服务拼出动态预览。', 400)
151
+ }
152
+ const ratio = RATIOS[body.ratio] ? String(body.ratio) : '16:9'
153
+ const record = {
154
+ id: newId('vid'),
155
+ prompt: prompt.slice(0, 3000),
156
+ mode,
157
+ model: String(body.model ?? current.video.model).slice(0, 120),
158
+ aspect: ratio,
159
+ duration: clampInt(body.duration, 2, 20, 5),
160
+ reference: typeof body.reference === 'string' ? body.reference.replace(/[^\w.-]/g, '') : '',
161
+ file: '',
162
+ poster: '',
163
+ frames: [],
164
+ progress: 0,
165
+ status: 'queued',
166
+ error: '',
167
+ createdAt: new Date().toISOString(),
168
+ }
169
+ await store.mutate(state => { state.videos.unshift(record); state.videos = state.videos.slice(0, 150) })
170
+ void runVideoRecord(record.id)
171
+ return record
172
+ }
173
+
174
+ async function runVideoRecord(id) {
175
+ const state = await store.read()
176
+ const row = state.videos.find(item => item.id === id)
177
+ if (!row) return
178
+ const current = await settings.read()
179
+ const controller = new AbortController()
180
+ jobs.set(id, controller)
181
+ const patch = value => store.mutate(draft => {
182
+ const target = draft.videos.find(item => item.id === id)
183
+ if (target) Object.assign(target, value)
184
+ })
185
+ await patch({ status: 'running', progress: 3, error: '' })
186
+ try {
187
+ if (row.mode === 'storyboard') {
188
+ const result = await generateStoryboard(current, row, {
189
+ signal: controller.signal,
190
+ filesDir: store.filesDir,
191
+ onProgress: progress => { void patch({ progress }) },
192
+ })
193
+ const frames = []
194
+ for (const [index, frame] of result.frames.entries()) {
195
+ const name = `vid_${id}_${index + 1}${frame.ext}`
196
+ await writeFile(join(store.filesDir, name), frame.buffer)
197
+ frames.push(name)
198
+ }
199
+ await patch({
200
+ status: 'done',
201
+ progress: 100,
202
+ frames,
203
+ poster: frames[0] ?? '',
204
+ error: result.errors.length ? `部分分镜失败:${result.errors[0]}` : '',
205
+ })
206
+ } else {
207
+ const result = await generateVideo(current, row, {
208
+ signal: controller.signal,
209
+ filesDir: store.filesDir,
210
+ onProgress: progress => { void patch({ progress }) },
211
+ })
212
+ const name = `vid_${id}${result.ext || videoExtensionOf(result.buffer)}`
213
+ await writeFile(join(store.filesDir, name), result.buffer)
214
+ await patch({ status: 'done', progress: 100, file: name })
215
+ }
216
+ } catch (err) {
217
+ await patch({ status: 'error', error: controller.signal.aborted ? '已停止' : err.message })
218
+ } finally {
219
+ jobs.delete(id)
220
+ }
221
+ }
222
+
223
+ async function saveDocument(body) {
224
+ const title = String(body.title ?? '').trim().slice(0, 120) || '未命名作品'
225
+ const content = String(body.content ?? '').slice(0, 400_000)
226
+ const outline = String(body.outline ?? '').slice(0, 20_000)
227
+ return store.mutate(state => {
228
+ const id = typeof body.id === 'string' ? body.id : ''
229
+ let doc = id ? state.docs.find(item => item.id === id) : null
230
+ if (!doc) {
231
+ doc = { id: newId('doc'), title, content, outline, wordGoal: Number(body.wordGoal) || 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
232
+ state.docs.unshift(doc)
233
+ state.docs = state.docs.slice(0, 200)
234
+ } else {
235
+ doc.title = title
236
+ doc.content = content
237
+ doc.outline = outline
238
+ if (body.wordGoal !== undefined) doc.wordGoal = Number(body.wordGoal) || 0
239
+ doc.updatedAt = new Date().toISOString()
240
+ }
241
+ return doc
242
+ })
243
+ }
244
+
245
+ async function updateGraph(graphId, fn) {
246
+ return store.mutate(state => {
247
+ const graph = state.graphs.find(item => item.id === graphId)
248
+ if (!graph) return null
249
+ return fn(graph)
250
+ })
251
+ }
252
+
253
+ async function saveGraph(body) {
254
+ const incoming = body.graph && typeof body.graph === 'object' ? body.graph : body
255
+ const normalized = normalizeGraph(incoming, { name: incoming?.name })
256
+ return store.mutate(state => {
257
+ const id = typeof incoming.id === 'string' ? incoming.id : ''
258
+ const existing = id ? state.graphs.find(item => item.id === id) : null
259
+ if (existing) {
260
+ existing.name = normalized.name
261
+ // 保存布局和参数时保留已有运行结果,避免一次自动保存把产出抹掉
262
+ existing.nodes = normalized.nodes.map(node => {
263
+ const old = existing.nodes.find(item => item.id === node.id)
264
+ return old ? { ...node, status: old.status, output: old.output, error: old.error } : node
265
+ })
266
+ existing.edges = normalized.edges
267
+ existing.updatedAt = new Date().toISOString()
268
+ return existing
269
+ }
270
+ state.graphs.unshift(normalized)
271
+ state.graphs = state.graphs.slice(0, 40)
272
+ return normalized
273
+ })
274
+ }
275
+
276
+ async function runGraph(graphId, targetIds) {
277
+ const state = await store.read()
278
+ const graph = state.graphs.find(item => item.id === graphId)
279
+ if (!graph) throw fail('星图不存在', 404)
280
+ if (graphJobs.has(graphId)) throw fail('这张星图正在运行', 409)
281
+ if (!graph.nodes.length) throw fail('星图还是空的,先加几个节点', 400)
282
+ const targets = Array.isArray(targetIds) && targetIds.length
283
+ ? targetIds.filter(id => graph.nodes.some(node => node.id === id))
284
+ : graph.nodes.filter(node => !graph.edges.some(edge => edge.from.node === node.id)).map(node => node.id)
285
+ if (!targets.length) throw fail('没有可运行的目标节点', 400)
286
+ const subset = dependencyClosure(graph, targets)
287
+ const { waves, ok } = topoWaves(graph, subset)
288
+ if (!ok) throw fail('星图里有环路,先修好再运行', 400)
289
+ const controller = new AbortController()
290
+ graphJobs.set(graphId, controller)
291
+ await updateGraph(graphId, draft => {
292
+ draft.run = { status: 'running', startedAt: new Date().toISOString(), wave: 0, waves: waves.length, targets, error: '' }
293
+ for (const node of draft.nodes) {
294
+ if (subset.has(node.id)) {
295
+ node.status = 'idle'
296
+ node.output = null
297
+ node.error = ''
298
+ }
299
+ }
300
+ })
301
+ void executeGraph(graphId, waves, subset, controller).catch(() => {})
302
+ return { waves: waves.length, targets }
303
+ }
304
+
305
+ async function executeGraph(graphId, waves, subset, controller) {
306
+ const appSettings = await settings.read()
307
+ const failed = new Set()
308
+ try {
309
+ for (const [index, wave] of waves.entries()) {
310
+ if (controller.signal.aborted) throw new Error('已停止')
311
+ await updateGraph(graphId, draft => { if (draft.run) draft.run.wave = index + 1 })
312
+ await Promise.all(wave.map(async nodeId => {
313
+ if (controller.signal.aborted) return
314
+ const state = await store.read()
315
+ const graph = state.graphs.find(item => item.id === graphId)
316
+ const node = graph?.nodes.find(item => item.id === nodeId)
317
+ if (!node) return
318
+ const incoming = graph.edges.filter(edge => edge.to.node === nodeId)
319
+ if (incoming.some(edge => failed.has(edge.from.node))) {
320
+ failed.add(nodeId)
321
+ await updateGraph(graphId, draft => {
322
+ const target = draft.nodes.find(item => item.id === nodeId)
323
+ if (target) { target.status = 'error'; target.error = '上游节点失败,已跳过' }
324
+ })
325
+ return
326
+ }
327
+ await updateGraph(graphId, draft => {
328
+ const target = draft.nodes.find(item => item.id === nodeId)
329
+ if (target) { target.status = 'running'; target.error = '' }
330
+ })
331
+ try {
332
+ const output = await executeNode(graph, node, { appSettings, controller })
333
+ await updateGraph(graphId, draft => {
334
+ const target = draft.nodes.find(item => item.id === nodeId)
335
+ if (target) { target.status = 'done'; target.output = output }
336
+ })
337
+ } catch (err) {
338
+ failed.add(nodeId)
339
+ await updateGraph(graphId, draft => {
340
+ const target = draft.nodes.find(item => item.id === nodeId)
341
+ if (target) { target.status = 'error'; target.error = controller.signal.aborted ? '已停止' : err.message }
342
+ })
343
+ }
344
+ }))
345
+ }
346
+ await updateGraph(graphId, draft => {
347
+ if (draft.run) {
348
+ draft.run.status = failed.size ? 'error' : 'done'
349
+ draft.run.finishedAt = new Date().toISOString()
350
+ if (failed.size) draft.run.error = `${failed.size} 个节点失败`
351
+ }
352
+ })
353
+ } catch (err) {
354
+ await updateGraph(graphId, draft => {
355
+ if (draft.run) {
356
+ draft.run.status = controller.signal.aborted ? 'stopped' : 'error'
357
+ draft.run.error = controller.signal.aborted ? '已停止' : err.message
358
+ draft.run.finishedAt = new Date().toISOString()
359
+ }
360
+ for (const node of draft.nodes) {
361
+ if (node.status === 'running') {
362
+ node.status = 'error'
363
+ node.error = controller.signal.aborted ? '已停止' : '运行中断'
364
+ }
365
+ }
366
+ })
367
+ } finally {
368
+ graphJobs.delete(graphId)
369
+ }
370
+ }
371
+
372
+ function upstreamText(graph, nodeId, port) {
373
+ const parts = []
374
+ for (const edge of graph.edges) {
375
+ if (edge.to.node !== nodeId) continue
376
+ if (port && edge.to.port !== port) continue
377
+ const source = graph.nodes.find(node => node.id === edge.from.node)
378
+ const output = source?.output
379
+ if (output?.kind === 'text' && output.text) parts.push(output.text)
380
+ else if (typeof output?.text === 'string') parts.push(output.text)
381
+ }
382
+ return parts.join('\n\n').trim()
383
+ }
384
+
385
+ function upstreamMedia(graph, nodeId, port) {
386
+ for (const edge of graph.edges) {
387
+ if (edge.to.node !== nodeId) continue
388
+ if (port && edge.to.port !== port) continue
389
+ const source = graph.nodes.find(node => node.id === edge.from.node)
390
+ if (source?.output) return source.output
391
+ }
392
+ return null
393
+ }
394
+
395
+ async function executeNode(graph, node, { appSettings, controller }) {
396
+ const config = node.config ?? {}
397
+ if (node.type === 'note') return null
398
+ if (node.type === 'prompt') return { kind: 'text', text: String(config.text ?? '').trim() }
399
+ if (node.type === 'preview') return upstreamMedia(graph, node.id)
400
+ if (node.type === 'write') {
401
+ const prompts = writingPrompts({
402
+ action: 'graph',
403
+ title: config.title ?? '',
404
+ content: upstreamText(graph, node.id, 'context'),
405
+ instruction: String(config.instruction ?? ''),
406
+ tone: config.tone,
407
+ length: config.length,
408
+ lang: 'zh',
409
+ })
410
+ const { text, model } = await streamText(ctx, { system: prompts.system, user: prompts.user, maxTokens: 1400, signal: controller.signal })
411
+ return { kind: 'text', text: cleanOutput(text, 'graph'), model }
412
+ }
413
+ if (node.type === 'image') {
414
+ if (!imageReady(appSettings)) throw new Error('未配置生图服务')
415
+ const prompt = [upstreamText(graph, node.id, 'prompt'), config.prompt].filter(Boolean).join('\n')
416
+ if (!prompt.trim()) throw new Error('没有提示词')
417
+ const ratio = RATIOS[config.ratio] ?? RATIOS['16:9']
418
+ const reference = upstreamMedia(graph, node.id, 'reference')
419
+ const result = await generateImage(appSettings, {
420
+ prompt,
421
+ style: config.style,
422
+ size: ratio.size,
423
+ reference: reference?.kind === 'image' ? reference.file : undefined,
424
+ }, { signal: controller.signal, filesDir: store.filesDir })
425
+ const name = `node_${node.id}_${Date.now().toString(36)}${result.ext}`
426
+ await writeFile(join(store.filesDir, name), result.buffer)
427
+ return { kind: 'image', file: name, width: result.width, height: result.height }
428
+ }
429
+ if (node.type === 'video') {
430
+ const prompt = [upstreamText(graph, node.id, 'prompt'), config.prompt].filter(Boolean).join('\n').trim()
431
+ if (!prompt) throw new Error('没有画面描述')
432
+ const first = upstreamMedia(graph, node.id, 'first')
433
+ const mode = config.mode === 'storyboard' ? 'storyboard' : first?.kind === 'image' ? 'image' : 'text'
434
+ const ratio = RATIOS[config.ratio] ? config.ratio : '16:9'
435
+ const record = {
436
+ id: newId('gv'),
437
+ prompt,
438
+ mode,
439
+ aspect: ratio,
440
+ duration: clampInt(config.duration, 2, 20, 5),
441
+ reference: first?.kind === 'image' ? first.file : '',
442
+ }
443
+ if (mode === 'storyboard') {
444
+ if (!imageReady(appSettings)) throw new Error('分镜模式需要生图服务')
445
+ const result = await generateStoryboard(appSettings, record, { signal: controller.signal, filesDir: store.filesDir })
446
+ const frames = []
447
+ for (const [index, frame] of result.frames.entries()) {
448
+ const name = `node_${node.id}_${index + 1}${frame.ext}`
449
+ await writeFile(join(store.filesDir, name), frame.buffer)
450
+ frames.push(name)
451
+ }
452
+ return { kind: 'video', file: '', frames, poster: frames[0] ?? '', storyboard: true }
453
+ }
454
+ if (!videoReady(appSettings)) throw new Error('未配置视频服务,可把节点模式改成「分镜」')
455
+ const result = await generateVideo(appSettings, record, { signal: controller.signal, filesDir: store.filesDir })
456
+ const name = `node_${node.id}_${Date.now().toString(36)}${result.ext || videoExtensionOf(result.buffer)}`
457
+ await writeFile(join(store.filesDir, name), result.buffer)
458
+ return { kind: 'video', file: name }
459
+ }
460
+ throw new Error(`不支持的节点类型:${node.type}`)
461
+ }
462
+
463
+ async function streamWriting(req, res, body) {
464
+ const prompts = writingPrompts({ ...body, lang: body.lang === 'en' ? 'en' : 'zh' })
465
+ res.statusCode = 200
466
+ res.setHeader('content-type', 'application/x-ndjson; charset=utf-8')
467
+ res.setHeader('cache-control', 'no-store')
468
+ res.setHeader('x-accel-buffering', 'no')
469
+ const controller = new AbortController()
470
+ req.on('close', () => { if (!res.writableEnded) controller.abort() })
471
+ const send = payload => { if (!res.writableEnded) res.write(`${JSON.stringify(payload)}\n`) }
472
+ try {
473
+ const { text, model } = await streamText(ctx, {
474
+ system: prompts.system,
475
+ user: prompts.user,
476
+ maxTokens: 4000,
477
+ signal: controller.signal,
478
+ onDelta: delta => send({ type: 'delta', text: delta }),
479
+ })
480
+ send({ type: 'done', text: cleanOutput(text, prompts.action), model })
481
+ } catch (err) {
482
+ send({ type: 'error', message: controller.signal.aborted ? '已停止' : err.message })
483
+ } finally {
484
+ res.end()
485
+ }
486
+ }
487
+
488
+ async function handleUpload(req, res) {
489
+ const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase()
490
+ const ext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[type]
491
+ if (!ext) return json(res, 415, { error: '只支持 PNG / JPG / WebP / GIF' })
492
+ const buffer = await readBuffer(req, 25_000_000)
493
+ if (!buffer.length) return json(res, 400, { error: '文件是空的' })
494
+ const sniffed = extensionOf(buffer)
495
+ if (!sniffed) return json(res, 400, { error: '这个文件不是图片' })
496
+ const name = `ref_${newId('f')}${sniffed}`
497
+ await writeFile(join(store.filesDir, name), buffer)
498
+ const size = imageSize(buffer)
499
+ return json(res, 200, { name, url: `${BASE}/files/${name}`, width: size?.width ?? 0, height: size?.height ?? 0 })
500
+ }
501
+
502
+ async function route(req, res) {
503
+ if (req.method === 'POST' && !sameOrigin(req)) return json(res, 403, { error: '拒绝跨站请求' })
504
+ const url = new URL(req.url || '/', 'http://local')
505
+ let path
506
+ try {
507
+ path = decodeURIComponent(url.pathname)
508
+ } catch {
509
+ return json(res, 400, { error: '路径无效' })
510
+ }
511
+ if (path === BASE) path = `${BASE}/`
512
+ if (!path.startsWith(BASE)) return json(res, 404, { error: '接口不存在' })
513
+ const rel = path.slice(BASE.length)
514
+
515
+ if (req.method === 'GET' || req.method === 'HEAD') {
516
+ const mapped = STATIC.get(rel)
517
+ if (mapped) return sendFile(req, res, join(WEB_DIR, mapped), { cache: 'no-cache' })
518
+ if (rel.startsWith('/views/') || rel.startsWith('/lib/') || rel.startsWith('/assets/')) {
519
+ const file = safeJoin(WEB_DIR, rel)
520
+ if (!file) return json(res, 404, { error: '文件不存在' })
521
+ return sendFile(req, res, file, { cache: 'no-cache' })
522
+ }
523
+ if (rel.startsWith('/files/')) {
524
+ const name = rel.slice('/files/'.length)
525
+ if (!/^[\w.-]+$/.test(name)) return json(res, 400, { error: '路径无效' })
526
+ await settleOnce()
527
+ return sendFile(req, res, join(store.filesDir, name))
528
+ }
529
+ }
530
+
531
+ if (!rel.startsWith('/api/')) return json(res, 404, { error: '接口不存在' })
532
+ await settleOnce()
533
+ const api = rel.slice('/api/'.length)
534
+ const method = req.method || 'GET'
535
+
536
+ if (method === 'GET') {
537
+ if (api === 'bootstrap' || api === 'state') {
538
+ const [state, config] = await Promise.all([store.read(), settings.read()])
539
+ return json(res, 200, {
540
+ state,
541
+ settings: publicSettings(config),
542
+ imagePresets: IMAGE_PRESETS,
543
+ videoPresets: VIDEO_PRESETS,
544
+ model: modelInfo(ctx),
545
+ version: VERSION,
546
+ dataDir: store.root,
547
+ })
548
+ }
549
+ if (api === 'export') {
550
+ const state = await store.read()
551
+ res.setHeader('content-disposition', `attachment; filename="media-workbench-modern-${Date.now()}.json"`)
552
+ return json(res, 200, state)
553
+ }
554
+ return json(res, 404, { error: '接口不存在' })
555
+ }
556
+
557
+ if (method !== 'POST') return json(res, 405, { error: '不支持的方法' })
558
+ if (api === 'upload') return handleUpload(req, res)
559
+
560
+ const body = await readJson(req)
561
+
562
+ if (api === 'settings') {
563
+ const next = await settings.update(body)
564
+ return json(res, 200, { settings: publicSettings(next) })
565
+ }
566
+ if (api === 'settings/test-image') {
567
+ const current = await settings.read()
568
+ if (!imageReady(current)) return json(res, 400, { error: '先填好生图服务' })
569
+ const id = newId('test')
570
+ const result = await generateImage(current, {
571
+ prompt: 'A single paper boat on calm warm-grey water, soft morning light, minimalist editorial photograph, no text',
572
+ style: '',
573
+ size: RATIOS['1:1'].size,
574
+ }, { filesDir: store.filesDir })
575
+ const file = await saveImageBuffer(id, result)
576
+ return json(res, 200, { file, url: `${BASE}/files/${file}`, width: result.width, height: result.height })
577
+ }
578
+ if (api === 'image/generate') {
579
+ const records = await createImages(body)
580
+ return json(res, 200, { items: records })
581
+ }
582
+ if (api === 'image/action') {
583
+ const id = String(body.id ?? '')
584
+ if (body.action === 'favorite') {
585
+ const row = await store.mutate(state => {
586
+ const target = state.images.find(item => item.id === id)
587
+ if (target) target.favorite = !target.favorite
588
+ return target
589
+ })
590
+ return json(res, 200, { item: row })
591
+ }
592
+ if (body.action === 'delete' || body.action === 'retry') {
593
+ const state = await store.read()
594
+ const record = state.images.find(item => item.id === id)
595
+ if (!record) return json(res, 404, { error: '记录不存在' })
596
+ if (body.action === 'delete') {
597
+ await store.mutate(draft => { draft.images = draft.images.filter(item => item.id !== id) })
598
+ return json(res, 200, { ok: true })
599
+ }
600
+ await store.mutate(draft => {
601
+ const target = draft.images.find(item => item.id === id)
602
+ if (target) { target.status = 'queued'; target.error = '' }
603
+ })
604
+ void runImage(id, record).catch(() => {})
605
+ return json(res, 200, { ok: true })
606
+ }
607
+ return json(res, 400, { error: '未知操作' })
608
+ }
609
+ if (api === 'video/generate') {
610
+ const record = await createVideo(body)
611
+ return json(res, 200, { item: record })
612
+ }
613
+ if (api === 'video/cancel') {
614
+ jobs.get(String(body.id ?? ''))?.abort()
615
+ return json(res, 200, { ok: true })
616
+ }
617
+ if (api === 'video/action') {
618
+ const id = String(body.id ?? '')
619
+ if (body.action === 'delete') {
620
+ await store.mutate(draft => { draft.videos = draft.videos.filter(item => item.id !== id) })
621
+ return json(res, 200, { ok: true })
622
+ }
623
+ if (body.action === 'retry') {
624
+ const state = await store.read()
625
+ if (!state.videos.some(item => item.id === id)) return json(res, 404, { error: '记录不存在' })
626
+ await store.mutate(draft => {
627
+ const target = draft.videos.find(item => item.id === id)
628
+ if (target) { target.status = 'queued'; target.progress = 0; target.error = ''; target.file = ''; target.frames = [] }
629
+ })
630
+ void runVideoRecord(id)
631
+ return json(res, 200, { ok: true })
632
+ }
633
+ return json(res, 400, { error: '未知操作' })
634
+ }
635
+ if (api === 'document/save') {
636
+ const doc = await saveDocument(body)
637
+ return json(res, 200, { doc })
638
+ }
639
+ if (api === 'document/action') {
640
+ const id = String(body.id ?? '')
641
+ if (body.action === 'delete') {
642
+ await store.mutate(draft => { draft.docs = draft.docs.filter(item => item.id !== id) })
643
+ return json(res, 200, { ok: true })
644
+ }
645
+ if (body.action === 'duplicate') {
646
+ const doc = await store.mutate(draft => {
647
+ const source = draft.docs.find(item => item.id === id)
648
+ if (!source) return null
649
+ const copy = { ...source, id: newId('doc'), title: `${source.title} 副本`, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
650
+ draft.docs.unshift(copy)
651
+ return copy
652
+ })
653
+ return doc ? json(res, 200, { doc }) : json(res, 404, { error: '作品不存在' })
654
+ }
655
+ return json(res, 400, { error: '未知操作' })
656
+ }
657
+ if (api === 'write/stream') return streamWriting(req, res, body)
658
+ if (api === 'graph/save') {
659
+ const graph = await saveGraph(body)
660
+ return json(res, 200, { graph })
661
+ }
662
+ if (api === 'graph/create') {
663
+ const graph = body.template ? templateGraph(String(body.template)) : normalizeGraph({ name: body.name ?? '空白星图' })
664
+ if (!body.template && body.name) graph.name = String(body.name).slice(0, 40)
665
+ await store.mutate(state => { state.graphs.unshift(graph); state.graphs = state.graphs.slice(0, 40) })
666
+ return json(res, 200, { graph })
667
+ }
668
+ if (api === 'graph/action') {
669
+ const id = String(body.id ?? '')
670
+ if (body.action === 'delete') {
671
+ await store.mutate(state => { state.graphs = state.graphs.filter(item => item.id !== id) })
672
+ return json(res, 200, { ok: true })
673
+ }
674
+ if (body.action === 'duplicate') {
675
+ const graph = await store.mutate(state => {
676
+ const source = state.graphs.find(item => item.id === id)
677
+ if (!source) return null
678
+ const copy = normalizeGraph({ ...source, name: `${source.name} 副本` })
679
+ state.graphs.unshift(copy)
680
+ return copy
681
+ })
682
+ return graph ? json(res, 200, { graph }) : json(res, 404, { error: '星图不存在' })
683
+ }
684
+ return json(res, 400, { error: '未知操作' })
685
+ }
686
+ if (api === 'graph/run') {
687
+ const result = await runGraph(String(body.id ?? ''), body.targetIds)
688
+ return json(res, 200, result)
689
+ }
690
+ if (api === 'graph/stop') {
691
+ graphJobs.get(String(body.id ?? ''))?.abort()
692
+ return json(res, 200, { ok: true })
693
+ }
694
+ if (api === 'graph/import') {
695
+ const graph = normalizeGraph(body.payload ?? body.graph ?? body)
696
+ await store.mutate(state => { state.graphs.unshift(graph); state.graphs = state.graphs.slice(0, 40) })
697
+ return json(res, 200, { graph })
698
+ }
699
+ if (api === 'blueprint/save') {
700
+ const graph = body.graph
701
+ if (!graph) return json(res, 400, { error: '缺少星图数据' })
702
+ const blueprint = normalizeGraph(graph, { name: graph.name })
703
+ for (const node of blueprint.nodes) { node.output = null; node.status = 'idle'; node.error = '' }
704
+ delete blueprint.run
705
+ await store.mutate(state => {
706
+ state.blueprints.unshift({ ...blueprint, id: newId('bp'), savedAt: new Date().toISOString() })
707
+ state.blueprints = state.blueprints.slice(0, 60)
708
+ })
709
+ return json(res, 200, { blueprint })
710
+ }
711
+ if (api === 'blueprint/use') {
712
+ const state = await store.read()
713
+ const blueprint = state.blueprints.find(item => item.id === body.id)
714
+ if (!blueprint) return json(res, 404, { error: '蓝图不存在' })
715
+ const graph = normalizeGraph({ ...blueprint, name: blueprint.name })
716
+ const idMap = new Map(graph.nodes.map(node => [node.id, createNode(node.type, node.x, node.y, node.config).id]))
717
+ for (const node of graph.nodes) node.id = idMap.get(node.id)
718
+ for (const edge of graph.edges) {
719
+ edge.from.node = idMap.get(edge.from.node)
720
+ edge.to.node = idMap.get(edge.to.node)
721
+ }
722
+ await store.mutate(draft => { draft.graphs.unshift(graph); draft.graphs = draft.graphs.slice(0, 40) })
723
+ return json(res, 200, { graph })
724
+ }
725
+ if (api === 'preferences') {
726
+ const preferences = await store.mutate(state => {
727
+ for (const key of ['imageStyle', 'imageRatio', 'imageCount', 'videoMode', 'videoRatio', 'videoDuration', 'writingTone', 'writingPov', 'writingLength']) {
728
+ if (body[key] !== undefined) state.preferences[key] = body[key]
729
+ }
730
+ return state.preferences
731
+ })
732
+ return json(res, 200, { preferences })
733
+ }
734
+ if (api === 'clear') {
735
+ const scope = String(body.scope ?? '')
736
+ if (!['images', 'videos', 'all'].includes(scope)) return json(res, 400, { error: '未知范围' })
737
+ await store.mutate(state => {
738
+ if (scope === 'images' || scope === 'all') state.images = []
739
+ if (scope === 'videos' || scope === 'all') state.videos = []
740
+ })
741
+ return json(res, 200, { ok: true })
742
+ }
743
+ return json(res, 404, { error: '接口不存在' })
744
+ }
745
+
746
+ ctx.effect(() => ctx.webServer.register({
747
+ kind: 'prefix',
748
+ path: BASE,
749
+ handler: async (req, res) => {
750
+ try {
751
+ const deny = rejection(ctx, req)
752
+ if (deny) return json(res, deny, { error: '未授权' })
753
+ await route(req, res)
754
+ } catch (err) {
755
+ if (res.headersSent) return res.end()
756
+ json(res, err.status || 500, { error: err.message })
757
+ }
758
+ },
759
+ }), '@exccoder/dsh-media-workbench: routes')
760
+
761
+ ctx.effect(() => () => {
762
+ for (const controller of jobs.values()) controller.abort()
763
+ for (const controller of graphJobs.values()) controller.abort()
764
+ }, '@exccoder/dsh-media-workbench: abort jobs')
765
+
766
+ applyTools(ctx, { store, settings, saveImageBuffer, createVideo, runGraph, saveDocument, templateGraph, BASE, ready })
767
+ }
768
+
769
+ export { BASE }