@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/graph.js ADDED
@@ -0,0 +1,304 @@
1
+ // 星图:节点/端口契约、DAG 校验、拓扑波次、内置蓝图
2
+ export const NODE_TYPES = {
3
+ prompt: {
4
+ label: '提示词',
5
+ icon: 'spark',
6
+ color: '#d97757',
7
+ inputs: [],
8
+ outputs: [{ id: 'text', type: 'text' }],
9
+ defaults: { text: '' },
10
+ },
11
+ write: {
12
+ label: '灵感写作',
13
+ icon: 'pen',
14
+ color: '#b4833e',
15
+ inputs: [{ id: 'context', type: 'text', multi: true }],
16
+ outputs: [{ id: 'text', type: 'text' }],
17
+ defaults: { instruction: '根据上面的提示,写一段有画面感的文字', tone: '自然叙述', length: 'short' },
18
+ },
19
+ image: {
20
+ label: '图像生成',
21
+ icon: 'image',
22
+ color: '#8a6fd1',
23
+ inputs: [
24
+ { id: 'prompt', type: 'text', multi: true },
25
+ { id: 'reference', type: 'image' },
26
+ ],
27
+ outputs: [{ id: 'image', type: 'image' }],
28
+ defaults: { style: '电影感', ratio: '16:9', prompt: '' },
29
+ },
30
+ video: {
31
+ label: '视频生成',
32
+ icon: 'film',
33
+ color: '#3f8f92',
34
+ inputs: [
35
+ { id: 'prompt', type: 'text', multi: true },
36
+ { id: 'first', type: 'image' },
37
+ ],
38
+ outputs: [{ id: 'video', type: 'video' }],
39
+ defaults: { duration: 5, ratio: '16:9', mode: 'auto', prompt: '' },
40
+ },
41
+ preview: {
42
+ label: '作品预览',
43
+ icon: 'eye',
44
+ color: '#6b7280',
45
+ inputs: [{ id: 'media', type: 'media', multi: true }],
46
+ outputs: [],
47
+ defaults: {},
48
+ },
49
+ note: {
50
+ label: '便签',
51
+ icon: 'note',
52
+ color: '#a8935b',
53
+ inputs: [],
54
+ outputs: [],
55
+ defaults: { text: '' },
56
+ },
57
+ }
58
+
59
+ export const PORT_LABEL = { text: '文本', image: '图像', video: '视频', media: '作品' }
60
+
61
+ export const STYLES = {
62
+ 电影感: 'cinematic lighting, film still, shallow depth of field, muted color grade, highly detailed',
63
+ 插画: 'editorial illustration, refined linework, textured brush strokes, warm limited palette',
64
+ 水墨: 'traditional Chinese ink painting, rice paper texture, negative space, elegant brushwork',
65
+ 赛博朋克: 'cyberpunk city at night, neon reflections, volumetric fog, cinematic composition',
66
+ 胶片: 'analog film photograph, grain, soft halation, natural light, Kodak Portra palette',
67
+ '3D': 'stylized 3D render, soft studio lighting, clay and glass materials, clean composition',
68
+ 极简: 'minimalist composition, generous negative space, calm geometry, subtle gradients',
69
+ 浮世绘: 'ukiyo-e woodblock print, flat color fields, bold outlines, patterned waves',
70
+ 概念艺术: 'concept art, matte painting, epic scale, atmospheric perspective, production design',
71
+ }
72
+
73
+ export const RATIOS = {
74
+ '1:1': { size: '1024x1024', w: 1, h: 1 },
75
+ '4:3': { size: '1152x864', w: 4, h: 3 },
76
+ '3:2': { size: '1216x832', w: 3, h: 2 },
77
+ '16:9': { size: '1344x768', w: 16, h: 9 },
78
+ '9:16': { size: '768x1344', w: 9, h: 16 },
79
+ '2:3': { size: '832x1216', w: 2, h: 3 },
80
+ }
81
+
82
+ export function nodeDef(type) {
83
+ return NODE_TYPES[type] ?? null
84
+ }
85
+
86
+ export function portType(node, portId, direction) {
87
+ const def = nodeDef(node?.type)
88
+ if (!def) return null
89
+ const list = direction === 'in' ? def.inputs : def.outputs
90
+ return list.find(p => p.id === portId) ?? null
91
+ }
92
+
93
+ export function emptyGraph(name = '未命名星图') {
94
+ return { id: `g_${Date.now().toString(36)}`, name, nodes: [], edges: [], updatedAt: new Date().toISOString() }
95
+ }
96
+
97
+ export function createNode(type, x, y, config = {}) {
98
+ const def = nodeDef(type)
99
+ if (!def) throw new Error(`未知节点类型:${type}`)
100
+ return {
101
+ id: `n_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
102
+ type,
103
+ x: Math.round(x),
104
+ y: Math.round(y),
105
+ config: { ...def.defaults, ...config },
106
+ status: 'idle',
107
+ output: null,
108
+ error: '',
109
+ }
110
+ }
111
+
112
+ export function edgeId() {
113
+ return `e_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
114
+ }
115
+
116
+ /** 连接是否合法:端口存在、类型兼容、单输入不重复、不成环 */
117
+ export function canConnect(graph, from, to) {
118
+ if (!from || !to) return { ok: false, reason: '连接不完整' }
119
+ if (from.node === to.node) return { ok: false, reason: '不能连接自己' }
120
+ const source = graph.nodes.find(n => n.id === from.node)
121
+ const target = graph.nodes.find(n => n.id === to.node)
122
+ if (!source || !target) return { ok: false, reason: '节点不存在' }
123
+ const out = portType(source, from.port, 'out')
124
+ const input = portType(target, to.port, 'in')
125
+ if (!out || !input) return { ok: false, reason: '端口不存在' }
126
+ if (out.type !== input.type && input.type !== 'media') return { ok: false, reason: `端口类型不匹配:${PORT_LABEL[out.type]} → ${PORT_LABEL[input.type]}` }
127
+ if (graph.edges.some(e => e.from.node === from.node && e.from.port === from.port && e.to.node === to.node && e.to.port === to.port)) {
128
+ return { ok: false, reason: '这两个端口已经连过了' }
129
+ }
130
+ if (!input.multi && graph.edges.some(e => e.to.node === to.node && e.to.port === to.port)) {
131
+ return { ok: false, reason: '这个输入只能接一条线' }
132
+ }
133
+ if (wouldCycle(graph, from.node, to.node)) return { ok: false, reason: '会形成环路,星图必须是 DAG' }
134
+ return { ok: true }
135
+ }
136
+
137
+ export function wouldCycle(graph, fromNode, toNode) {
138
+ // 加边 fromNode -> toNode 后是否成环:从 toNode 能否回到 fromNode
139
+ const adj = new Map()
140
+ for (const e of graph.edges) {
141
+ if (!adj.has(e.from.node)) adj.set(e.from.node, [])
142
+ adj.get(e.from.node).push(e.to.node)
143
+ }
144
+ if (!adj.has(fromNode)) adj.set(fromNode, [])
145
+ adj.get(fromNode).push(toNode)
146
+ const seen = new Set()
147
+ const stack = [toNode]
148
+ while (stack.length) {
149
+ const cur = stack.pop()
150
+ if (cur === fromNode) return true
151
+ if (seen.has(cur)) continue
152
+ seen.add(cur)
153
+ for (const next of adj.get(cur) ?? []) stack.push(next)
154
+ }
155
+ return false
156
+ }
157
+
158
+ /** 目标节点及其全部祖先 */
159
+ export function dependencyClosure(graph, targets) {
160
+ const keep = new Set()
161
+ const byId = new Map(graph.nodes.map(n => [n.id, n]))
162
+ const incoming = new Map()
163
+ for (const e of graph.edges) {
164
+ if (!incoming.has(e.to.node)) incoming.set(e.to.node, [])
165
+ incoming.get(e.to.node).push(e.from.node)
166
+ }
167
+ const walk = id => {
168
+ if (keep.has(id) || !byId.has(id)) return
169
+ keep.add(id)
170
+ for (const parent of incoming.get(id) ?? []) walk(parent)
171
+ }
172
+ for (const id of targets) walk(id)
173
+ return keep
174
+ }
175
+
176
+ /** Kahn 拓扑排序,返回并行波次 */
177
+ export function topoWaves(graph, subset) {
178
+ const ids = new Set(subset ?? graph.nodes.map(n => n.id))
179
+ const nodes = graph.nodes.filter(n => ids.has(n.id))
180
+ const indeg = new Map(nodes.map(n => [n.id, 0]))
181
+ const out = new Map(nodes.map(n => [n.id, []]))
182
+ for (const e of graph.edges) {
183
+ if (!ids.has(e.from.node) || !ids.has(e.to.node)) continue
184
+ indeg.set(e.to.node, (indeg.get(e.to.node) ?? 0) + 1)
185
+ if (!out.has(e.from.node)) out.set(e.from.node, [])
186
+ out.get(e.from.node).push(e.to.node)
187
+ }
188
+ const waves = []
189
+ let ready = nodes.filter(n => indeg.get(n.id) === 0).map(n => n.id)
190
+ const done = new Set()
191
+ while (ready.length) {
192
+ waves.push([...ready])
193
+ const next = []
194
+ for (const id of ready) {
195
+ done.add(id)
196
+ for (const child of out.get(id) ?? []) {
197
+ indeg.set(child, indeg.get(child) - 1)
198
+ if (indeg.get(child) === 0) next.push(child)
199
+ }
200
+ }
201
+ ready = next
202
+ }
203
+ return { waves, ok: done.size === nodes.length }
204
+ }
205
+
206
+ /** 校验一张外部导入的星图,修复可修复的,丢弃危险连线 */
207
+ export function normalizeGraph(input, { name } = {}) {
208
+ const graph = emptyGraph(name ?? '导入的星图')
209
+ if (!input || typeof input !== 'object') return graph
210
+ const rawNodes = Array.isArray(input.nodes) ? input.nodes.slice(0, 80) : []
211
+ const seen = new Set()
212
+ for (const raw of rawNodes) {
213
+ if (!raw || typeof raw !== 'object') continue
214
+ const def = nodeDef(raw.type)
215
+ if (!def) continue
216
+ let id = typeof raw.id === 'string' ? raw.id.slice(0, 64) : ''
217
+ if (!id) id = createNode(raw.type, 0, 0).id
218
+ if (seen.has(id)) continue
219
+ seen.add(id)
220
+ graph.nodes.push({
221
+ id,
222
+ type: raw.type,
223
+ x: Number.isFinite(Number(raw.x)) ? Math.max(-4000, Math.min(8000, Math.round(Number(raw.x)))) : 80,
224
+ y: Number.isFinite(Number(raw.y)) ? Math.max(-4000, Math.min(8000, Math.round(Number(raw.y)))) : 80,
225
+ config: typeof raw.config === 'object' && raw.config ? { ...def.defaults, ...raw.config } : { ...def.defaults },
226
+ status: 'idle',
227
+ output: null,
228
+ error: '',
229
+ })
230
+ }
231
+ const ids = new Set(graph.nodes.map(n => n.id))
232
+ const edgeKeys = new Set()
233
+ for (const raw of Array.isArray(input.edges) ? input.edges.slice(0, 160) : []) {
234
+ if (!raw || typeof raw !== 'object') continue
235
+ const from = raw.from ?? {}
236
+ const to = raw.to ?? {}
237
+ if (!ids.has(from.node) || !ids.has(to.node)) continue
238
+ const candidate = graph
239
+ const check = canConnect(candidate, from, to)
240
+ if (!check.ok) continue
241
+ const key = `${from.node}:${from.port}>${to.node}:${to.port}`
242
+ if (edgeKeys.has(key)) continue
243
+ edgeKeys.add(key)
244
+ graph.edges.push({ id: edgeId(), from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } })
245
+ }
246
+ graph.name = typeof input.name === 'string' && input.name.trim() ? input.name.trim().slice(0, 40) : graph.name
247
+ return graph
248
+ }
249
+
250
+ const N = (type, id, x, y, config) => ({ id: `n_${id}`, type, x, y, config, status: 'idle', output: null, error: '' })
251
+ const E = (id, from, fromPort, to, toPort) => ({ id: `e_${id}`, from: { node: `n_${from}`, port: fromPort }, to: { node: `n_${to}`, port: toPort } })
252
+
253
+ export function templateGraph(key) {
254
+ if (key === 'inspiration') {
255
+ return {
256
+ id: `g_${Date.now().toString(36)}`,
257
+ name: '灵感成片',
258
+ nodes: [
259
+ N('prompt', 'p1', 60, 170, { text: '雨停后的城市天台,一个人把耳机摘下,听见远处的车流。' }),
260
+ N('write', 'w1', 372, 120, { instruction: '把提示扩写成一段 80 字以内的旁白,克制、有画面感,不要解释。', tone: '克制冷静', length: 'short' }),
261
+ N('image', 'i1', 684, 80, { style: '电影感', ratio: '16:9', prompt: '' }),
262
+ N('preview', 'v1', 996, 145, {}),
263
+ ],
264
+ edges: [E('1', 'p1', 'text', 'w1', 'context'), E('2', 'w1', 'text', 'i1', 'prompt'), E('3', 'i1', 'image', 'v1', 'media')],
265
+ updatedAt: new Date().toISOString(),
266
+ }
267
+ }
268
+ if (key === 'video') {
269
+ return {
270
+ id: `g_${Date.now().toString(36)}`,
271
+ name: '短片分镜',
272
+ nodes: [
273
+ N('prompt', 'p1', 50, 170, { text: '清晨的海边公路,一辆旧巴士驶过,车窗里有人望向海面。' }),
274
+ N('write', 'w1', 362, 120, { instruction: '写一段适合做短片旁白的文字,三句话,有起承转合。', tone: '自然叙述', length: 'short' }),
275
+ N('image', 'i1', 674, 70, { style: '胶片', ratio: '16:9', prompt: '' }),
276
+ N('video', 'm1', 986, 60, { duration: 5, ratio: '16:9', mode: 'image', prompt: '' }),
277
+ N('preview', 'v1', 1298, 125, {}),
278
+ ],
279
+ edges: [E('1', 'p1', 'text', 'w1', 'context'), E('2', 'w1', 'text', 'i1', 'prompt'), E('3', 'w1', 'text', 'm1', 'prompt'), E('4', 'i1', 'image', 'm1', 'first'), E('5', 'm1', 'video', 'v1', 'media')],
280
+ updatedAt: new Date().toISOString(),
281
+ }
282
+ }
283
+ if (key === 'card') {
284
+ return {
285
+ id: `g_${Date.now().toString(36)}`,
286
+ name: '图文卡片',
287
+ nodes: [
288
+ N('prompt', 'p1', 70, 175, { text: '写一句关于「慢下来」的短句,像卡片上印的那样。' }),
289
+ N('write', 'w1', 382, 130, { instruction: '扩写成 60 字以内的短文,适合放在图片下方。', tone: '诗意', length: 'short' }),
290
+ N('image', 'i1', 694, 80, { style: '极简', ratio: '4:3', prompt: '' }),
291
+ N('preview', 'v1', 1006, 150, {}),
292
+ ],
293
+ edges: [E('1', 'p1', 'text', 'w1', 'context'), E('2', 'w1', 'text', 'i1', 'prompt'), E('3', 'i1', 'image', 'v1', 'media')],
294
+ updatedAt: new Date().toISOString(),
295
+ }
296
+ }
297
+ return emptyGraph('空白星图')
298
+ }
299
+
300
+ export const TEMPLATES = [
301
+ { key: 'inspiration', name: '灵感成片', desc: '一句灵感 → 写作 → 画面' },
302
+ { key: 'card', name: '图文卡片', desc: '短句 + 极简配图' },
303
+ { key: 'video', name: '短片分镜', desc: '旁白 → 首帧 → 视频' },
304
+ ]
package/src/image.js ADDED
@@ -0,0 +1,137 @@
1
+ // 生图:OpenAI Images 兼容接口(generations / edits),也接受 images[0] 这种国内常见返回
2
+ import { readFile } from 'node:fs/promises'
3
+ import { extname, join } from 'node:path'
4
+ import { STYLES } from './graph.js'
5
+ import { imageKey } from './settings.js'
6
+ import { assertRemoteUrl, extensionOf, imageSize, redact } from './util.js'
7
+
8
+ const TIMEOUT_MS = 300_000
9
+ const MAX_BYTES = 40_000_000
10
+
11
+ function explain(status, detail) {
12
+ if (status === 401 || status === 403) return `密钥无效或没有该模型的权限(${status})`
13
+ if (status === 404) return '接口地址或模型不存在(404),检查设置里的地址要不要加 /v1'
14
+ if (status === 429) return '请求太频繁或额度不足(429)'
15
+ if (status === 400 && /size|resolution|dimension/i.test(detail)) return `这个模型不支持当前尺寸:${detail}`
16
+ return detail ? `生图服务返回 ${status}:${detail}` : `生图服务返回 ${status}`
17
+ }
18
+
19
+ function styledPrompt(prompt, style) {
20
+ const text = String(prompt ?? '').trim()
21
+ const suffix = STYLES[style] ?? (typeof style === 'string' && style.trim() ? style.trim() : '')
22
+ return suffix ? `${text}${text ? '。' : ''}${suffix}` : text
23
+ }
24
+
25
+ async function postJson(url, key, body, signal) {
26
+ const res = await fetch(url, {
27
+ method: 'POST',
28
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
29
+ body: JSON.stringify(body),
30
+ signal,
31
+ })
32
+ const raw = await res.text()
33
+ let data = null
34
+ try { data = JSON.parse(raw) } catch { /* 网关偶尔返回 HTML */ }
35
+ if (!res.ok) {
36
+ const message = data?.error?.message ?? data?.message ?? raw ?? ''
37
+ const err = new Error(explain(res.status, redact(message, key)))
38
+ err.status = res.status
39
+ throw err
40
+ }
41
+ return data
42
+ }
43
+
44
+ async function readEntry(entry, key, signal) {
45
+ if (entry?.b64_json) return { buffer: Buffer.from(entry.b64_json, 'base64'), revisedPrompt: entry.revised_prompt }
46
+ if (entry?.url) {
47
+ const url = assertRemoteUrl(entry.url)
48
+ const download = await fetch(url, { signal })
49
+ if (!download.ok) throw new Error(`下载生成的图片失败(${download.status})`)
50
+ return { buffer: Buffer.from(await download.arrayBuffer()), revisedPrompt: entry.revised_prompt }
51
+ }
52
+ const err = new Error('生图服务没有返回图片')
53
+ err.status = 502
54
+ throw err
55
+ }
56
+
57
+ function extraOf(settings) {
58
+ try {
59
+ const parsed = JSON.parse(settings.image.extra || '{}')
60
+ return parsed && typeof parsed === 'object' ? parsed : {}
61
+ } catch {
62
+ return {}
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 生成一张图。
68
+ * @param {object} settings 插件设置
69
+ * @param {object} request {prompt, style, negative, size, quality, reference, model}
70
+ * @param {{signal?: AbortSignal, filesDir?: string}} options
71
+ * @returns {Promise<{buffer: Buffer, ext: string, width: number, height: number, revisedPrompt?: string}>}
72
+ */
73
+ export async function generateImage(settings, request, { signal, filesDir } = {}) {
74
+ const key = imageKey(settings)
75
+ if (!key) throw Object.assign(new Error('还没有配置生图密钥,去「设置 → 生图服务」填一个'), { status: 400 })
76
+ if (!settings.image.baseUrl || !settings.image.model) throw Object.assign(new Error('先填好生图接口地址和模型'), { status: 400 })
77
+ const prompt = styledPrompt(request.prompt, request.style)
78
+ if (!prompt) throw Object.assign(new Error('提示词为空'), { status: 400 })
79
+ const extra = { ...extraOf(settings), ...(request.extra && typeof request.extra === 'object' ? request.extra : {}) }
80
+ if (request.negative) extra.negative_prompt = request.negative
81
+ const model = request.model || settings.image.model
82
+ const timeout = AbortSignal.timeout(TIMEOUT_MS)
83
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
84
+ let result
85
+
86
+ if (request.reference && filesDir) {
87
+ const file = join(filesDir, String(request.reference).replace(/[^\w.-]/g, ''))
88
+ let buffer
89
+ try {
90
+ buffer = await readFile(file)
91
+ } catch {
92
+ throw Object.assign(new Error('参考图不存在,重新上传一次'), { status: 400 })
93
+ }
94
+ const type = extname(file).toLowerCase() === '.jpg' ? 'image/jpeg' : extname(file).toLowerCase() === '.webp' ? 'image/webp' : 'image/png'
95
+ const form = new FormData()
96
+ form.set('model', model)
97
+ form.set('prompt', prompt)
98
+ form.set('n', '1')
99
+ if (request.size) form.set('size', request.size)
100
+ if (request.quality) form.set('quality', request.quality)
101
+ for (const [k, v] of Object.entries(extra)) form.set(k, typeof v === 'string' ? v : JSON.stringify(v))
102
+ form.set('image', new Blob([buffer], { type }), `reference${extname(file) || '.png'}`)
103
+ const res = await fetch(`${settings.image.baseUrl}/images/edits`, {
104
+ method: 'POST',
105
+ headers: { authorization: `Bearer ${key}` },
106
+ body: form,
107
+ signal: combined,
108
+ })
109
+ const raw = await res.text()
110
+ let data = null
111
+ try { data = JSON.parse(raw) } catch { /* ignore */ }
112
+ if (!res.ok) throw Object.assign(new Error(explain(res.status, redact(data?.error?.message ?? data?.message ?? raw, key))), { status: res.status })
113
+ const entry = data?.data?.[0] ?? data?.images?.[0]
114
+ result = await readEntry(entry, key, combined)
115
+ } else {
116
+ const body = {
117
+ model,
118
+ prompt,
119
+ n: 1,
120
+ ...(request.size ? { size: request.size } : {}),
121
+ ...(request.quality ? { quality: request.quality } : {}),
122
+ ...extra,
123
+ }
124
+ const data = await postJson(`${settings.image.baseUrl}/images/generations`, key, body, combined)
125
+ const entry = data?.data?.[0] ?? data?.images?.[0]
126
+ result = await readEntry(entry, key, combined)
127
+ }
128
+
129
+ const { buffer } = result
130
+ if (buffer.length > MAX_BYTES) throw new Error('返回的图片太大')
131
+ const ext = extensionOf(buffer)
132
+ if (!ext) throw new Error('接口返回的内容不是图片')
133
+ const size = imageSize(buffer) ?? { width: 0, height: 0 }
134
+ return { buffer, ext, width: size.width, height: size.height, revisedPrompt: result.revisedPrompt }
135
+ }
136
+
137
+ export { styledPrompt }