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