| 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 |