@exccoder/dsh-media-workbench 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +111 -0
- package/README.md +141 -2
- package/SECURITY.md +29 -0
- package/cordis.patch.yml +4 -0
- package/docs/screenshot-graph.webp +0 -0
- package/docs/screenshot-image.webp +0 -0
- package/docs/screenshot-video.webp +0 -0
- package/docs/screenshot-write.webp +0 -0
- package/lib/client.js +110 -0
- package/package.json +79 -4
- package/src/graph.js +304 -0
- package/src/image.js +137 -0
- package/src/index.js +769 -0
- package/src/llm.js +60 -0
- package/src/settings.js +187 -0
- package/src/store.js +274 -0
- package/src/tools.js +354 -0
- package/src/util.js +217 -0
- package/src/video.js +219 -0
- package/src/writing.js +112 -0
- package/web/app.js +268 -0
- package/web/favicon.svg +12 -0
- package/web/index.html +16 -0
- package/web/lib/api.js +79 -0
- package/web/lib/constants.js +69 -0
- package/web/lib/dom.js +99 -0
- package/web/lib/icons.js +65 -0
- package/web/lib/state.js +42 -0
- package/web/styles.css +1096 -0
- package/web/views/constellation-model.js +63 -0
- package/web/views/constellation.js +1110 -0
- package/web/views/image.js +488 -0
- package/web/views/settings.js +244 -0
- package/web/views/video.js +363 -0
- package/web/views/write.js +513 -0
package/src/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 }
|