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