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