返回 oh-my-ppt
jimeng.ts
1 import crypto from 'crypto'
2 import log from 'electron-log/main.js'
3 import type {
4 ImageGenerationProviderAdapter,
5 ImageGenerationResult,
6 ResolvedImageModelConfig
7 } from '../types'
8 import { collectImageResults, readJsonResponse, readRecord, readString } from './utils'
9 import { resolveConfiguredDefaultImageSize } from './default-size'
10 //https://www.volcengine.com/docs/85621/1616429?lang=zh v3
11 const DEFAULT_ENDPOINT = 'https://visual.volcengineapi.com'
12 const DEFAULT_REQ_KEY = 'jimeng_t2i_v30'
13 const DEFAULT_VERSION = '2022-08-31'
14 const DEFAULT_REGION = 'cn-north-1'
15 const DEFAULT_SERVICE = 'cv'
16
17 const JIMENG_SIZE_MAP: Record<string, { width: number; height: number }> = {
18 '1:1': { width: 1328, height: 1328 },
19 '16:9': { width: 1664, height: 936 },
20 '4:3': { width: 1472, height: 1104 }
21 }
22
23 const encodeQuery = (value: string): string =>
24 encodeURIComponent(value).replace(/[!'()*]/g, (char) =>
25 `%${char.charCodeAt(0).toString(16).toUpperCase()}`
26 )
27
28 const canonicalQuery = (params: Record<string, string>): string =>
29 Object.entries(params)
30 .sort(([a], [b]) => a.localeCompare(b))
31 .map(([key, value]) => `${encodeQuery(key)}=${encodeQuery(value)}`)
32 .join('&')
33
34 const sha256Hex = (value: string): string =>
35 crypto.createHash('sha256').update(value, 'utf8').digest('hex')
36
37 const hmac = (key: Buffer | string, value: string): Buffer =>
38 crypto.createHmac('sha256', key).update(value, 'utf8').digest()
39
40 const hmacHex = (key: Buffer | string, value: string): string =>
41 crypto.createHmac('sha256', key).update(value, 'utf8').digest('hex')
42
43 const utcDate = (): { longDate: string; shortDate: string } => {
44 const iso = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
45 return {
46 longDate: iso,
47 shortDate: iso.slice(0, 8)
48 }
49 }
50
51 const parseCredentials = (
52 config: ResolvedImageModelConfig
53 ): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } => {
54 const accessKeyId = readString(config.modelConfig, 'accessKeyId')
55 const secretAccessKey = readString(config.modelConfig, 'secretKey')
56 const sessionToken = readString(config.modelConfig, 'sessionToken') || undefined
57 if (!accessKeyId || !secretAccessKey) {
58 throw new Error('即梦 3.0 需要 Access Key ID 和 Secret Key。')
59 }
60 return { accessKeyId, secretAccessKey, sessionToken }
61 }
62
63 const signHeaders = ({
64 accessKeyId,
65 secretAccessKey,
66 sessionToken,
67 host,
68 query,
69 body,
70 region,
71 service
72 }: {
73 accessKeyId: string
74 secretAccessKey: string
75 sessionToken?: string
76 host: string
77 query: string
78 body: string
79 region: string
80 service: string
81 }): Record<string, string> => {
82 const { longDate, shortDate } = utcDate()
83 const payloadHash = sha256Hex(body)
84 const signedHeaders = 'content-type;host;x-content-sha256;x-date'
85 const canonicalHeaders = [
86 'content-type:application/json',
87 `host:${host}`,
88 `x-content-sha256:${payloadHash}`,
89 `x-date:${longDate}`
90 ].join('\n')
91 const canonicalRequest = [
92 'POST',
93 '/',
94 query,
95 `${canonicalHeaders}\n`,
96 signedHeaders,
97 payloadHash
98 ].join('\n')
99 const credentialScope = `${shortDate}/${region}/${service}/request`
100 const stringToSign = [
101 'HMAC-SHA256',
102 longDate,
103 credentialScope,
104 sha256Hex(canonicalRequest)
105 ].join('\n')
106 const signingKey = hmac(hmac(hmac(hmac(secretAccessKey, shortDate), region), service), 'request')
107 const signature = hmacHex(signingKey, stringToSign)
108
109 return {
110 authorization: `HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
111 'content-type': 'application/json',
112 'x-content-sha256': payloadHash,
113 'x-date': longDate,
114 ...(sessionToken ? { 'x-security-token': sessionToken } : {})
115 }
116 }
117
118 const resolveEndpoint = (config: ResolvedImageModelConfig): URL => {
119 const endpoint =
120 readString(config.modelConfig, 'endpoint') || DEFAULT_ENDPOINT
121 return new URL(endpoint)
122 }
123
124 const resolveReqKey = (config: ResolvedImageModelConfig): string =>
125 readString(config.modelConfig, 'reqKey') || DEFAULT_REQ_KEY
126
127 const resolveVersion = (config: ResolvedImageModelConfig): string =>
128 readString(config.modelConfig, 'version') || DEFAULT_VERSION
129
130 const parseDimensionSize = (value: string): { width: number; height: number } | null => {
131 const match = /^\s*(\d{2,5})\s*[x*]\s*(\d{2,5})\s*$/i.exec(value)
132 if (!match) return null
133 const width = Number(match[1])
134 const height = Number(match[2])
135 if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null
136 return { width: Math.floor(width), height: Math.floor(height) }
137 }
138
139 const resolveSize = (
140 config: ResolvedImageModelConfig,
141 inputSize: string
142 ): { width?: number; height?: number } => {
143 const width = Number(config.modelConfig.width)
144 const height = Number(config.modelConfig.height)
145 if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
146 return { width: Math.floor(width), height: Math.floor(height) }
147 }
148 const explicitDimension = parseDimensionSize(inputSize)
149 if (explicitDimension) return explicitDimension
150 return JIMENG_SIZE_MAP[inputSize] || JIMENG_SIZE_MAP['1:1']
151 }
152
153 const postSignedJson = async ({
154 config,
155 action,
156 body,
157 signal
158 }: {
159 config: ResolvedImageModelConfig
160 action: string
161 body: Record<string, unknown>
162 signal?: AbortSignal
163 }): Promise<unknown> => {
164 const endpoint = resolveEndpoint(config)
165 const version = resolveVersion(config)
166 const query = canonicalQuery({
167 Action: action,
168 Version: version
169 })
170 endpoint.search = query
171 const bodyText = JSON.stringify(body)
172 const credentials = parseCredentials(config)
173 const headers = signHeaders({
174 ...credentials,
175 host: endpoint.host,
176 query,
177 body: bodyText,
178 region: readString(config.modelConfig, 'region') || DEFAULT_REGION,
179 service: readString(config.modelConfig, 'service') || DEFAULT_SERVICE
180 })
181 const startedAt = Date.now()
182 log.info('[images:jimeng] request start', {
183 action,
184 version,
185 endpoint: endpoint.origin,
186 bodyKeys: Object.keys(body).sort()
187 })
188 const response = await fetch(endpoint, {
189 method: 'POST',
190 signal,
191 headers,
192 body: bodyText
193 })
194 log.info('[images:jimeng] request end', {
195 action,
196 status: response.status,
197 ok: response.ok,
198 elapsedMs: Date.now() - startedAt
199 })
200 return readJsonResponse(response)
201 }
202
203 const assertSuccess = (payload: unknown, context: string): Record<string, unknown> => {
204 const record = readRecord(payload)
205 const code = Number(record.code)
206 if (code !== 10000) {
207 const message = readString(record, 'message') || readString(record, 'msg') || `${context} failed`
208 log.warn('[images:jimeng] api returned non-success', {
209 context,
210 code,
211 message,
212 payloadKeys: Object.keys(record).sort()
213 })
214 throw new Error(message)
215 }
216 return record
217 }
218
219 const collectJimengImages = async (
220 payload: unknown,
221 signal?: AbortSignal
222 ): Promise<ImageGenerationResult[]> => {
223 const data = readRecord(readRecord(payload).data)
224 const normalized: Array<Record<string, string>> = []
225 const imageUrls = Array.isArray(data.image_urls) ? data.image_urls : []
226 for (const url of imageUrls) {
227 if (typeof url === 'string' && url.trim()) normalized.push({ url: url.trim() })
228 }
229 const binaryData = Array.isArray(data.binary_data_base64)
230 ? data.binary_data_base64
231 : typeof data.binary_data_base64 === 'string'
232 ? [data.binary_data_base64]
233 : []
234 log.info('[images:jimeng] collect image payload', {
235 imageUrlCount: imageUrls.filter((url) => typeof url === 'string' && url.trim()).length,
236 binaryDataCount: binaryData.filter((base64) => typeof base64 === 'string' && base64.trim())
237 .length
238 })
239 for (const base64 of binaryData) {
240 if (typeof base64 === 'string' && base64.trim()) normalized.push({ base64: base64.trim() })
241 }
242 return collectImageResults({ data: normalized }, signal)
243 }
244
245 const toErrorMessage = (error: unknown): string =>
246 error instanceof Error ? error.message : String(error)
247
248 export const jimengAdapter: ImageGenerationProviderAdapter = {
249 getDefaultSize(config) {
250 return resolveConfiguredDefaultImageSize(config) || '1664x936'
251 },
252
253 async generate(config, input) {
254 const startedAt = Date.now()
255 const reqKey = resolveReqKey(config)
256 const requestBody = readRecord(config.modelConfig.requestBody)
257 const resultJson = readRecord(config.modelConfig.resultJson)
258 const { width, height } = resolveSize(config, input.size)
259 const results: ImageGenerationResult[] = []
260 const maxPolls = Number(config.modelConfig.maxPolls || 60)
261 const intervalMs = Number(config.modelConfig.pollIntervalMs || 2000)
262 log.info('[images:jimeng] generation start', {
263 configId: config.id,
264 configName: config.name,
265 reqKey,
266 inputSize: input.size,
267 width,
268 height,
269 count: input.count,
270 promptLength: input.prompt.length,
271 hasSeed: typeof input.seed === 'number',
272 maxPolls,
273 intervalMs,
274 requestBodyKeys: Object.keys(requestBody).sort(),
275 resultJsonKeys: Object.keys(resultJson).sort()
276 })
277
278 try {
279 for (let index = 0; index < input.count; index += 1) {
280 const imageStartedAt = Date.now()
281 log.info('[images:jimeng] submit task', {
282 imageIndex: index + 1,
283 total: input.count,
284 reqKey,
285 width,
286 height
287 })
288 const submitPayload = assertSuccess(
289 await postSignedJson({
290 config,
291 action: 'CVSync2AsyncSubmitTask',
292 signal: input.signal,
293 body: {
294 req_key: reqKey,
295 prompt: input.prompt,
296 seed: typeof input.seed === 'number' ? input.seed : -1,
297 ...(width && height ? { width, height } : {}),
298 ...requestBody
299 }
300 }),
301 'Jimeng task submit'
302 )
303 const taskId = readString(readRecord(submitPayload.data), 'task_id')
304 if (!taskId) throw new Error('即梦 3.0 未返回 task_id')
305 log.info('[images:jimeng] task submitted', {
306 imageIndex: index + 1,
307 taskId,
308 elapsedMs: Date.now() - imageStartedAt
309 })
310
311 let lastStatus = ''
312 for (let poll = 0; poll < maxPolls; poll += 1) {
313 if (input.signal?.aborted) throw new Error('Image generation cancelled')
314 await new Promise((resolve) => setTimeout(resolve, intervalMs))
315 const queryPayload = assertSuccess(
316 await postSignedJson({
317 config,
318 action: 'CVSync2AsyncGetResult',
319 signal: input.signal,
320 body: {
321 req_key: reqKey,
322 task_id: taskId,
323 req_json: JSON.stringify({
324 return_url: true,
325 ...resultJson
326 })
327 }
328 }),
329 'Jimeng task query'
330 )
331 const data = readRecord(queryPayload.data)
332 const status = readString(data, 'status')
333 if (status !== lastStatus || poll === 0 || (poll + 1) % 5 === 0) {
334 log.info('[images:jimeng] poll task', {
335 imageIndex: index + 1,
336 taskId,
337 poll: poll + 1,
338 maxPolls,
339 status: status || 'unknown',
340 elapsedMs: Date.now() - imageStartedAt
341 })
342 }
343 lastStatus = status
344 if (status === 'done') {
345 const images = await collectJimengImages(queryPayload, input.signal)
346 if (images.length === 0) throw new Error('即梦 3.0 未返回图片')
347 results.push(...images)
348 log.info('[images:jimeng] task completed', {
349 imageIndex: index + 1,
350 taskId,
351 imageCount: images.length,
352 elapsedMs: Date.now() - imageStartedAt
353 })
354 break
355 }
356 if (status === 'not_found' || status === 'expired') {
357 throw new Error(`即梦 3.0 任务状态异常:${status}`)
358 }
359 }
360
361 if (results.length <= index) {
362 log.warn('[images:jimeng] task timed out', {
363 imageIndex: index + 1,
364 taskId,
365 maxPolls,
366 elapsedMs: Date.now() - imageStartedAt
367 })
368 throw new Error('即梦 3.0 生图超时')
369 }
370 if (results.length >= input.count) break
371 }
372
373 log.info('[images:jimeng] generation completed', {
374 requestedCount: input.count,
375 resultCount: results.length,
376 elapsedMs: Date.now() - startedAt
377 })
378 return results.slice(0, input.count)
379 } catch (error) {
380 log.error('[images:jimeng] generation failed', {
381 message: toErrorMessage(error),
382 resultCount: results.length,
383 elapsedMs: Date.now() - startedAt
384 })
385 throw error
386 }
387 }
388 }
389
389 lines TYPESCRIPT