dsh-solmirror 0.11.0
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/LICENSE +21 -0
- package/README.md +65 -0
- package/client/client.js +594 -0
- package/cordis.patch.yml +8 -0
- package/lib/index.js +754 -0
- package/lib/upload.js +306 -0
- package/package.json +47 -0
package/lib/upload.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 参考素材上传引擎
|
|
3
|
+
*
|
|
4
|
+
* 本地文件 → 平台上传票 → 云存储 → obj_name 引用(填入 generate_image /
|
|
5
|
+
* generate_video 的 reference_images / reference_videos / reference_audios)。
|
|
6
|
+
*
|
|
7
|
+
* 两条路:
|
|
8
|
+
* - 视频:VOD STS 直传(字节不过平台 API)——Apply/Commit 走 vod.volcengineapi.com
|
|
9
|
+
* (V4 签名,GET),上传直连 TOS 节点(PUT + Content-CRC32,Authorization 用
|
|
10
|
+
* Apply 预签的 Auth),≤20MB 单 PUT / >20MB 分片,Commit 返回 Vid 交平台复核。
|
|
11
|
+
* - 图片/音频:multipart POST /data 平台中转落云。
|
|
12
|
+
*
|
|
13
|
+
* 云 API 一律走 node:https 原生栈;平台 REST 用 fetch。
|
|
14
|
+
* 限制:图片/视频/音频 ≤50MB、视频 ≤30s、音频 ≤60s(mp3/wav)。
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, createHmac } from 'node:crypto'
|
|
17
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
18
|
+
import { extname } from 'node:path'
|
|
19
|
+
import https from 'node:https'
|
|
20
|
+
import zlib from 'node:zlib'
|
|
21
|
+
|
|
22
|
+
const VOD_HOST = 'vod.volcengineapi.com'
|
|
23
|
+
const REGION = 'cn-north-1'
|
|
24
|
+
const MAX_BYTES = 50 * 1024 * 1024
|
|
25
|
+
const CHUNK_SIZE = 20 * 1024 * 1024 // SDK MinChunkSize:超过走分片
|
|
26
|
+
|
|
27
|
+
const EXT_KIND = {
|
|
28
|
+
'.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.webp': 'image', '.gif': 'image',
|
|
29
|
+
'.mp4': 'video', '.mov': 'video', '.webm': 'video',
|
|
30
|
+
'.mp3': 'audio', '.wav': 'audio',
|
|
31
|
+
}
|
|
32
|
+
const EXT_MIME = {
|
|
33
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
34
|
+
'.webp': 'image/webp', '.gif': 'image/gif',
|
|
35
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
36
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class UploadError extends Error {}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// 工具
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const hmacBuf = (key, msg) => createHmac('sha256', key).update(msg).digest()
|
|
46
|
+
const sha256hex = msg => createHash('sha256').update(msg).digest('hex')
|
|
47
|
+
const uriEncode = s => encodeURIComponent(String(s))
|
|
48
|
+
const crc32hex = buf => (zlib.crc32(buf) >>> 0).toString(16).padStart(8, '0')
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// node:https 原生请求(云 API 专用;自动解压 gzip/deflate/br)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function nativeRequest(urlStr, { method, headers, body }) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const u = new URL(urlStr)
|
|
57
|
+
const h = { ...headers }
|
|
58
|
+
if (body !== undefined) h['content-length'] = String(body.length)
|
|
59
|
+
const req = https.request(
|
|
60
|
+
{ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers: h },
|
|
61
|
+
res => {
|
|
62
|
+
const chunks = []
|
|
63
|
+
res.on('data', c => chunks.push(c))
|
|
64
|
+
res.on('end', () => {
|
|
65
|
+
let buf = Buffer.concat(chunks)
|
|
66
|
+
const ce = String(res.headers['content-encoding'] ?? '')
|
|
67
|
+
try {
|
|
68
|
+
if (ce.includes('gzip')) buf = zlib.gunzipSync(buf)
|
|
69
|
+
else if (ce.includes('deflate')) buf = zlib.inflateSync(buf)
|
|
70
|
+
else if (ce.includes('br')) buf = zlib.brotliDecompressSync(buf)
|
|
71
|
+
} catch { /* 解压失败按原文处理 */ }
|
|
72
|
+
resolve({ status: res.statusCode, text: buf.toString() })
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
req.on('error', reject)
|
|
76
|
+
if (body !== undefined && body.length) req.write(body)
|
|
77
|
+
req.end()
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// 火山 V4 签名(volcengine 变体)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function volcSignHeaders(sts, { method, host, path, query, body, service }) {
|
|
86
|
+
const bodyStr = body ?? ''
|
|
87
|
+
const bodyHash = sha256hex(bodyStr)
|
|
88
|
+
const long = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
|
|
89
|
+
const short = long.slice(0, 8)
|
|
90
|
+
const canonicalQuery = Object.keys(query).sort()
|
|
91
|
+
.map(k => `${uriEncode(k)}=${uriEncode(query[k])}`).join('&')
|
|
92
|
+
const headers = {
|
|
93
|
+
'content-type': 'application/json',
|
|
94
|
+
host,
|
|
95
|
+
'x-content-sha256': bodyHash,
|
|
96
|
+
'x-date': long,
|
|
97
|
+
}
|
|
98
|
+
if (sts.sessionToken) headers['x-security-token'] = sts.sessionToken
|
|
99
|
+
const names = Object.keys(headers).sort()
|
|
100
|
+
const canonicalHeaders = names.map(n => `${n}:${headers[n]}\n`).join('')
|
|
101
|
+
const signedHeaders = names.join(';')
|
|
102
|
+
const canonicalRequest = [method, path, canonicalQuery, canonicalHeaders, signedHeaders, bodyHash].join('\n')
|
|
103
|
+
const scope = `${short}/${REGION}/${service}/request`
|
|
104
|
+
const stringToSign = ['HMAC-SHA256', long, scope, sha256hex(canonicalRequest)].join('\n')
|
|
105
|
+
let k = hmacBuf(sts.secretAccessKey, short)
|
|
106
|
+
k = hmacBuf(k, REGION)
|
|
107
|
+
k = hmacBuf(k, service)
|
|
108
|
+
k = hmacBuf(k, 'request')
|
|
109
|
+
const signature = createHmac('sha256', k).update(stringToSign).digest('hex')
|
|
110
|
+
return {
|
|
111
|
+
accept: 'application/json',
|
|
112
|
+
'user-agent': 'dsh-solmirror/0.11.0',
|
|
113
|
+
'content-type': 'application/json',
|
|
114
|
+
'x-content-sha256': bodyHash,
|
|
115
|
+
'x-date': long,
|
|
116
|
+
...(sts.sessionToken ? { 'x-security-token': sts.sessionToken } : {}),
|
|
117
|
+
authorization:
|
|
118
|
+
`HMAC-SHA256 Credential=${sts.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 调火山 VOD OpenAPI(Apply/Commit 均 GET + query)。 */
|
|
123
|
+
async function vodApi(sts, { action, params }) {
|
|
124
|
+
const query = { Action: action, Version: '2022-01-01', ...params }
|
|
125
|
+
const headers = volcSignHeaders(sts, { method: 'GET', host: VOD_HOST, path: '/', query, body: '', service: 'vod' })
|
|
126
|
+
const url = `https://${VOD_HOST}/?${Object.keys(query).map(k => `${uriEncode(k)}=${uriEncode(query[k])}`).join('&')}`
|
|
127
|
+
let resp
|
|
128
|
+
try {
|
|
129
|
+
resp = await nativeRequest(url, { method: 'GET', headers, body: undefined })
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new UploadError(`火山 ${action} 网络失败:${String(error?.message ?? error)}`)
|
|
132
|
+
}
|
|
133
|
+
let data = null
|
|
134
|
+
try { data = JSON.parse(resp.text) } catch {
|
|
135
|
+
throw new UploadError(`火山 ${action} 网关异常:HTTP ${resp.status} ${resp.text.slice(0, 150)}`)
|
|
136
|
+
}
|
|
137
|
+
const err = data?.ResponseMetadata?.Error
|
|
138
|
+
if (err) throw new UploadError(`火山 ${action} 失败:${err.CodeN ?? err.Code ?? ''} ${err.Message ?? ''}`)
|
|
139
|
+
return data?.Result?.Data ?? data?.Result ?? {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// TOS 上传节点(PUT;Authorization=Apply 预签的 Auth;Content-CRC32 必带)
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async function tosPut(url, headers, body) {
|
|
147
|
+
let resp
|
|
148
|
+
try {
|
|
149
|
+
resp = await nativeRequest(url, { method: 'PUT', headers, body: body ?? undefined })
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new UploadError(`上传节点网络失败:${String(error?.message ?? error)}`)
|
|
152
|
+
}
|
|
153
|
+
let json = null
|
|
154
|
+
try { json = JSON.parse(resp.text) } catch { /* 直传/分片的成功响应也是 JSON */ }
|
|
155
|
+
if (resp.status !== 200 || !json || json.success !== 0) {
|
|
156
|
+
throw new UploadError(`上传节点失败:HTTP ${resp.status} ${resp.text.slice(0, 150)}`)
|
|
157
|
+
}
|
|
158
|
+
return json
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 视频直传 VOD:Apply → TOS(≤20MB 单 PUT / >20MB 分片,gateway 模式 partNumber
|
|
163
|
+
* 从 1 起)→ Commit(返回 Vid)。
|
|
164
|
+
*/
|
|
165
|
+
async function uploadVod(sts, spaceName, fileName, ext, bytes) {
|
|
166
|
+
const applyRoot = await vodApi(sts, {
|
|
167
|
+
action: 'ApplyUploadInfo',
|
|
168
|
+
params: { SpaceName: spaceName, FileSize: bytes.length, FileName: fileName, FileExtension: ext, NeedFallback: 'True' },
|
|
169
|
+
})
|
|
170
|
+
const addr = applyRoot.UploadAddress ?? applyRoot.CandidateUploadAddresses?.MainUploadAddresses?.[0]
|
|
171
|
+
const info = addr?.StoreInfos?.[0]
|
|
172
|
+
const host = addr?.UploadHosts?.[0]
|
|
173
|
+
if (!info?.StoreUri || !info?.Auth || !host || !addr?.SessionKey) {
|
|
174
|
+
throw new UploadError('VOD 上传地址不完整(StoreUri/Auth/UploadHost/SessionKey 缺失)')
|
|
175
|
+
}
|
|
176
|
+
const base = `https://${host}/${info.StoreUri}`
|
|
177
|
+
const authHeaders = { Authorization: info.Auth }
|
|
178
|
+
|
|
179
|
+
if (bytes.length < CHUNK_SIZE) {
|
|
180
|
+
await tosPut(base, { ...authHeaders, 'Content-CRC32': crc32hex(bytes) }, bytes)
|
|
181
|
+
} else {
|
|
182
|
+
const init = await tosPut(`${base}?uploads`, authHeaders, undefined)
|
|
183
|
+
const uploadId = init?.payload?.uploadID
|
|
184
|
+
if (!uploadId) throw new UploadError('分片初始化返回缺少 uploadID')
|
|
185
|
+
const parts = []
|
|
186
|
+
const count = Math.ceil(bytes.length / CHUNK_SIZE)
|
|
187
|
+
for (let i = 0; i < count; i++) {
|
|
188
|
+
const chunk = bytes.subarray(i * CHUNK_SIZE, Math.min(bytes.length, (i + 1) * CHUNK_SIZE))
|
|
189
|
+
const sum = crc32hex(chunk)
|
|
190
|
+
// gateway 大文件模式 partNumber 从 1 起
|
|
191
|
+
const payload = await tosPut(
|
|
192
|
+
`${base}?partNumber=${i + 1}&uploadID=${uriEncode(uploadId)}`,
|
|
193
|
+
{ ...authHeaders, 'Content-CRC32': sum }, chunk)
|
|
194
|
+
parts.push({ sum, payload })
|
|
195
|
+
}
|
|
196
|
+
// merge body:"序号:crc32" 逗号拼接;带首片返回的 ObjectContentType
|
|
197
|
+
const body = parts.map((p, i) => `${i + 1}:${p.sum}`).join(',')
|
|
198
|
+
let url = `${base}?uploadID=${uriEncode(uploadId)}`
|
|
199
|
+
const meta = parts[0]?.payload?.payload?.meta ?? parts[0]?.payload?.meta
|
|
200
|
+
const oct = meta?.ObjectContentType ?? ''
|
|
201
|
+
if (oct) url += `&ObjectContentType=${uriEncode(oct)}`
|
|
202
|
+
await tosPut(url, authHeaders, Buffer.from(body))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const commit = await vodApi(sts, {
|
|
206
|
+
action: 'CommitUploadInfo',
|
|
207
|
+
params: { SpaceName: spaceName, SessionKey: addr.SessionKey },
|
|
208
|
+
})
|
|
209
|
+
const vid = commit?.Vid ?? commit?.Data?.Vid
|
|
210
|
+
if (!vid) throw new UploadError(`VOD Commit 未返回 Vid:${JSON.stringify(commit).slice(0, 150)}`)
|
|
211
|
+
return String(vid)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// 平台 REST(签发 / 图片中转 / complete)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
async function restCall(origin, key, path, { json, formData } = {}) {
|
|
219
|
+
const headers = { Authorization: `Bearer ${key}` }
|
|
220
|
+
let body
|
|
221
|
+
if (formData) {
|
|
222
|
+
body = formData
|
|
223
|
+
} else {
|
|
224
|
+
headers['Content-Type'] = 'application/json'
|
|
225
|
+
body = JSON.stringify(json ?? {})
|
|
226
|
+
}
|
|
227
|
+
let resp
|
|
228
|
+
try {
|
|
229
|
+
resp = await fetch(`${origin}${path}`, { method: 'POST', headers, body })
|
|
230
|
+
} catch (error) {
|
|
231
|
+
throw new UploadError(`连不上瞬镜服务(${origin}):${String(error?.message ?? error)}`)
|
|
232
|
+
}
|
|
233
|
+
const data = await resp.json().catch(() => ({}))
|
|
234
|
+
if (!resp.ok) throw new UploadError(data?.detail ? String(data.detail) : `平台接口 HTTP ${resp.status}`)
|
|
235
|
+
return data
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// 主入口:本地文件 → obj_name 引用
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** STS 凭证键名归一(VOD 驼峰;兼容下划线风格)。 */
|
|
243
|
+
function normalizeSts(raw) {
|
|
244
|
+
const pick = (...keys) => {
|
|
245
|
+
for (const k of keys) if (raw?.[k]) return raw[k]
|
|
246
|
+
return ''
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
accessKeyId: pick('access_key_id', 'AccessKeyId'),
|
|
250
|
+
secretAccessKey: pick('secret_access_key', 'SecretAccessKey'),
|
|
251
|
+
sessionToken: pick('session_token', 'SessionToken'),
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 上传本地参考素材并领取 obj_name 引用(可复用;类型按扩展名自动识别)。
|
|
257
|
+
* @param {object} opts {filePath, key, mcpUrl} —— key=API 密钥明文,
|
|
258
|
+
* mcpUrl=插件配置的 MCP 地址(origin 推导 REST base)
|
|
259
|
+
* @returns {Promise<{obj_name,kind,meta}>}
|
|
260
|
+
*/
|
|
261
|
+
export async function uploadReference({ filePath, key, mcpUrl }) {
|
|
262
|
+
const ext = extname(filePath).toLowerCase()
|
|
263
|
+
const resolvedKind = EXT_KIND[ext]
|
|
264
|
+
if (!resolvedKind) {
|
|
265
|
+
throw new UploadError(`不支持的文件类型 ${ext || '(无扩展名)'}:仅支持 `
|
|
266
|
+
+ 'jpg/jpeg/png/webp/gif(图片)、mp4/mov/webm(视频)与 mp3/wav(音频)')
|
|
267
|
+
}
|
|
268
|
+
const mime = EXT_MIME[ext]
|
|
269
|
+
if (!mime) throw new UploadError(`无法识别 ${filePath} 的 MIME 类型`)
|
|
270
|
+
|
|
271
|
+
let size
|
|
272
|
+
try {
|
|
273
|
+
size = (await stat(filePath)).size
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw new UploadError(`读不到本地文件 ${filePath}:${String(error?.message ?? error)}`)
|
|
276
|
+
}
|
|
277
|
+
if (size <= 0) throw new UploadError('文件为空')
|
|
278
|
+
if (size > MAX_BYTES) throw new UploadError(`文件超过 50MB 上限(${size} 字节)`)
|
|
279
|
+
|
|
280
|
+
const origin = new URL(mcpUrl).origin
|
|
281
|
+
const ticket = await restCall(origin, key, '/openapi/v1/upload-tickets',
|
|
282
|
+
{ json: { kind: resolvedKind, content_type: mime, size_bytes: size } })
|
|
283
|
+
if (!Number.isInteger(ticket?.ticket_id)) throw new UploadError('签发响应缺少 ticket_id')
|
|
284
|
+
|
|
285
|
+
if (resolvedKind === 'image' || resolvedKind === 'audio') {
|
|
286
|
+
// 图片/音频中转:multipart 由平台落云;图片 /data 响应的 obj_name 是实际
|
|
287
|
+
// 对象名(非预签名),以响应为准
|
|
288
|
+
const bytes = await readFile(filePath)
|
|
289
|
+
const form = new FormData()
|
|
290
|
+
form.append('file', new Blob([bytes], { type: mime }), extname(filePath) || 'file')
|
|
291
|
+
const done = await restCall(origin, key, `/openapi/v1/upload-tickets/${ticket.ticket_id}/data`,
|
|
292
|
+
{ formData: form })
|
|
293
|
+
if (!done?.obj_name) throw new UploadError('平台未返回 obj_name')
|
|
294
|
+
return { obj_name: done.obj_name, kind: resolvedKind, meta: done.meta ?? {} }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 视频直传 VOD
|
|
298
|
+
const sts = normalizeSts(ticket?.credentials)
|
|
299
|
+
if (!sts.accessKeyId || !sts.secretAccessKey) throw new UploadError('签发响应缺少 STS 凭证')
|
|
300
|
+
const bytes = await readFile(filePath)
|
|
301
|
+
const vid = await uploadVod(sts, ticket.space_name, ticket.obj_name, ext, bytes)
|
|
302
|
+
const done = await restCall(origin, key, `/openapi/v1/upload-tickets/${ticket.ticket_id}/complete`,
|
|
303
|
+
{ json: { vid } })
|
|
304
|
+
if (!done?.obj_name) throw new UploadError('平台未返回 obj_name')
|
|
305
|
+
return { obj_name: done.obj_name, kind: resolvedKind, meta: done.meta ?? {} }
|
|
306
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-solmirror",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "瞬镜开放平台(solmirror)MCP 工具桥 + dsh 配置页:设置里粘贴 API 密钥、服务器地址,保存后用「测试连接」验证",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./lib/index.js",
|
|
8
|
+
"./client": "./client/client.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"dsh": ">=0.2.0"
|
|
12
|
+
},
|
|
13
|
+
"dsh": {
|
|
14
|
+
"bundle": {
|
|
15
|
+
"patch": "./cordis.patch.yml"
|
|
16
|
+
},
|
|
17
|
+
"client": {
|
|
18
|
+
"platform": "web"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"dsh-plugin",
|
|
23
|
+
"mcp",
|
|
24
|
+
"aigc",
|
|
25
|
+
"image-generation",
|
|
26
|
+
"video-generation"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/cthulhu-tww/dsh-solmirror.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/cthulhu-tww/dsh-solmirror#readme",
|
|
34
|
+
"files": [
|
|
35
|
+
"lib",
|
|
36
|
+
"client",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@deepseek-ai/dsh-credentials": "0.2.2-alpha.2",
|
|
43
|
+
"@deepseek-ai/dsh-mcp-client": "0.2.2-alpha.2",
|
|
44
|
+
"@deepseek-ai/schemastery": "3.18.6-alpha.1",
|
|
45
|
+
"@modelcontextprotocol/client": "2.2.0"
|
|
46
|
+
}
|
|
47
|
+
}
|