import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable, Subject, timer, throwError, of } from 'rxjs'; import { switchMap, takeWhile, tap, catchError, map } from 'rxjs/operators'; import { environment } from '../../environments/environment'; // MD5 hash function (lightweight, for signing only) function md5(input: string): string { function safeAdd(x: number, y: number) { const lsw = (x & 0xffff) + (y & 0xffff); return (((x >> 16) + (y >> 16) + (lsw >> 16)) << 16) | (lsw & 0xffff); } function bitRotateLeft(num: number, cnt: number) { return (num << cnt) | (num >>> (32 - cnt)); } function md5cmn(q: number, a: number, b: number, x: number, s: number, t: number) { return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); } function md5ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return md5cmn((b & c) | (~b & d), a, b, x, s, t); } function md5gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return md5cmn((b & d) | (c & ~d), a, b, x, s, t); } function md5hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return md5cmn(b ^ c ^ d, a, b, x, s, t); } function md5ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return md5cmn(c ^ (b | ~d), a, b, x, s, t); } function binlMD5(x: number[], len: number): number[] { x[len >> 5] |= 0x80 << (len % 32); x[((len + 64) >>> 9 << 4) + 14] = len; let a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; for (let i = 0; i < x.length; i += 16) { const olda = a, oldb = b, oldc = c, oldd = d; a = md5ff(a, b, c, d, x[i], 7, -680876936); d = md5ff(d, a, b, c, x[i + 1], 12, -389564586); c = md5ff(c, d, a, b, x[i + 2], 17, 606105819); b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330); a = md5ff(a, b, c, d, x[i + 4], 7, -176418897); d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426); c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341); b = md5ff(b, c, d, a, x[i + 7], 22, -45705983); a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416); d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417); c = md5ff(c, d, a, b, x[i + 10], 17, -42063); b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162); a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682); d = md5ff(d, a, b, c, x[i + 13], 12, -40341101); c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290); b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329); a = md5gg(a, b, c, d, x[i + 1], 5, -165796510); d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632); c = md5gg(c, d, a, b, x[i + 11], 14, 643717713); b = md5gg(b, c, d, a, x[i], 20, -373897302); a = md5gg(a, b, c, d, x[i + 5], 5, -701558691); d = md5gg(d, a, b, c, x[i + 10], 9, 38016083); c = md5gg(c, d, a, b, x[i + 15], 14, -660478335); b = md5gg(b, c, d, a, x[i + 4], 20, -405537848); a = md5gg(a, b, c, d, x[i + 9], 5, 568446438); d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690); c = md5gg(c, d, a, b, x[i + 3], 14, -187363961); b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501); a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467); d = md5gg(d, a, b, c, x[i + 2], 9, -51403784); c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473); b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734); a = md5hh(a, b, c, d, x[i + 5], 4, -378558); d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463); c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562); b = md5hh(b, c, d, a, x[i + 14], 23, -35309556); a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060); d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353); c = md5hh(c, d, a, b, x[i + 7], 16, -155497632); b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640); a = md5hh(a, b, c, d, x[i + 13], 4, 681279174); d = md5hh(d, a, b, c, x[i], 11, -358537222); c = md5hh(c, d, a, b, x[i + 3], 16, -722521979); b = md5hh(b, c, d, a, x[i + 6], 23, 76029189); a = md5hh(a, b, c, d, x[i + 9], 4, -640364487); d = md5hh(d, a, b, c, x[i + 12], 11, -421815835); c = md5hh(c, d, a, b, x[i + 15], 16, 530742520); b = md5hh(b, c, d, a, x[i + 2], 23, -995338651); a = md5ii(a, b, c, d, x[i], 6, -198630844); d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415); c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905); b = md5ii(b, c, d, a, x[i + 5], 21, -57434055); a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571); d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606); c = md5ii(c, d, a, b, x[i + 10], 15, -1051523); b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799); a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359); d = md5ii(d, a, b, c, x[i + 15], 10, -30611744); c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380); b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649); a = md5ii(a, b, c, d, x[i + 4], 6, -145523070); d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379); c = md5ii(c, d, a, b, x[i + 2], 15, 718787259); b = md5ii(b, c, d, a, x[i + 9], 21, -343485551); a = safeAdd(a, olda); b = safeAdd(b, oldb); c = safeAdd(c, oldc); d = safeAdd(d, oldd); } return [a, b, c, d]; } function str2binl(str: string): number[] { const bin: number[] = []; const mask = (1 << 8) - 1; for (let i = 0; i < str.length * 8; i += 8) { bin[i >> 5] |= (str.charCodeAt(i / 8) & mask) << (i % 32); } return bin; } function binl2hex(binarray: number[]): string { const hexTab = '0123456789abcdef'; let str = ''; for (let i = 0; i < binarray.length * 4; i++) { str += hexTab.charAt((binarray[i >> 2] >> ((i % 4) * 8 + 4)) & 0xf) + hexTab.charAt((binarray[i >> 2] >> ((i % 4) * 8)) & 0xf); } return str; } return binl2hex(binlMD5(str2binl(input), input.length * 8)); } export interface JimengVideoRequest { prompt: string; method: '1' | '2' | '3' | '4'; // 1文生视频 2首帧 3首尾帧 4运镜 frames?: number; // 121(5s) 或 241(10s) config?: { aspect_ratio?: string; image_urls?: string[]; template_id?: string; camera_strength?: string; }; } export interface JimengTaskStatus { tip: string; isFinish: boolean; isContinue?: boolean; } export interface JimengWorkResult { objectId: string; images?: string[]; videos?: string[]; prompt?: string; progress?: number; usage?: any; } export interface RemixTask { id: string; videoTitle: string; style: string; prompt: string; workId?: string; status: 'submitting' | 'generating' | 'polling' | 'completed' | 'failed'; progress: number; resultUrl?: string; error?: string; createdAt: Date; } @Injectable({ providedIn: 'root' }) export class JimengService { private readonly digitalHumanSubmitRetryableCodes = new Set([50429, 50430, 50500, 50501]); private readonly baseUrl = environment.jimengApi.baseUrl; private readonly parseBaseUrl = environment.jimengApi.parseBaseUrl; private readonly parseAppId = environment.jimengApi.parseAppId; private readonly token = environment.jimengApi.token; private readonly pollInterval = environment.jimengApi.pollIntervalMs; private readonly pollMaxAttempts = environment.jimengApi.pollMaxAttempts; private readonly digitalHumanPollMaxAttempts = Math.max(environment.jimengApi.pollMaxAttempts, 120); private readonly digitalHumanResultCheckInterval = 3; constructor(private http: HttpClient) {} // 提交文生视频任务 (720p) generateVideo720p(request: JimengVideoRequest): Observable { const body = { prompt: request.prompt, method: request.method, frames: request.frames || 121, config: request.config || { aspect_ratio: '16:9' }, token: this.token }; console.log('🎬 即梦API - 提交视频生成任务:', body); return this.http.post(`${this.baseUrl}/getVideoV3_720p`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( tap((res: any) => console.log('🎬 即梦API - 任务提交响应:', res)), catchError(this.handleError('generateVideo720p')) ); } // 提交文生视频任务 (1080p) generateVideo1080p(request: JimengVideoRequest): Observable { const body = { prompt: request.prompt, method: request.method, frames: request.frames || 121, config: request.config || { aspect_ratio: '16:9' }, token: this.token }; console.log('🎬 即梦API - 提交1080p视频生成任务:', body); return this.http.post(`${this.baseUrl}/getVideoV3_1080p`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( tap((res: any) => console.log('🎬 即梦API - 1080p任务提交响应:', res)), catchError(this.handleError('generateVideo1080p')) ); } // 提交文生视频任务 (Pro) generateVideoPro(request: JimengVideoRequest): Observable { const body = { prompt: request.prompt, method: request.method, frames: request.frames || 121, config: request.config || { aspect_ratio: '16:9' }, token: this.token }; console.log('🎬 即梦API - 提交Pro视频生成任务:', body); return this.http.post(`${this.baseUrl}/getVideoV3_Pro`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( tap((res: any) => console.log('🎬 即梦API - Pro任务提交响应:', res)), catchError(this.handleError('generateVideoPro')) ); } // 提交文生图任务 (v3.1) generateImage(prompt: string, width: number = 1920, height: number = 1080): Observable { const body = { prompt: prompt, sizeDate: { width, height }, token: this.token }; console.log('🖼️ 即梦API - 提交图片生成任务:', body); return this.http.post(`${this.baseUrl}/getText2ImgV31`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( tap((res: any) => console.log('🖼️ 即梦API - 图片任务提交响应:', res)), catchError(this.handleError('generateImage')) ); } // 查询任务状态 queryTask(workId: string, routerName: string): Observable { const body = { workId: workId, routerName: routerName, token: this.token }; return this.http.post(`${this.baseUrl}/getDataByTask02`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( catchError(this.handleError('queryTask')) ); } // 获取作品结果(通过 Parse API 查询 ImagineWork 表) getWorkResult(workId: string): Observable { return this.http.get( `${this.parseBaseUrl}/classes/ImagineWork/${workId}`, { headers: new HttpHeaders({ 'X-Parse-Application-Id': this.parseAppId }) } ).pipe( tap((res: any) => console.log('📦 即梦API - 作品结果:', res)), catchError(this.handleError('getWorkResult')) ); } uploadFileToParse(file: Blob, filename: string, contentType?: string): Observable { const formData = new FormData(); const normalizedFilename = String(filename || `asset-${Date.now()}`); const typedFile = file instanceof File ? file : new File([file], normalizedFilename, { type: contentType || 'application/octet-stream' }); formData.append('file', typedFile, normalizedFilename); return this.http.post( `/backend/api/remix/upload-asset`, formData ).pipe( map((res: any) => { const url = res?.url || ''; if (!url) { throw new Error('上传素材失败,未返回可用 URL'); } return url; }), tap((url: string) => console.log('📤 数字人素材上传成功:', url)), catchError(this.handleError('uploadFileToParse')) ); } identifyDigitalHuman(imageUrl: string): Observable<{ workId: string }> { const body = { image_url: imageUrl, token: this.token }; return this.http.post(`${this.baseUrl}/getOhIdentifyMain`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( map((res: any) => { const workId = res?.data?.workId || res?.data?.taskId || ''; if (res?.code !== 200 || !workId) { throw new Error(res?.msg || res?.message || '数字人主体识别任务提交失败'); } return { workId }; }), tap((res: { workId: string }) => console.log('🤖 数字人主体识别任务已提交:', res.workId)), catchError(this.handleError('identifyDigitalHuman')) ); } queryDigitalHumanTask(workId: string, routerName: 'getOhIdentifyMain' | 'getOmniHuman'): Observable { const body = { workId, taskId: workId, routerName, token: this.token }; return this.http.post(`${this.baseUrl}/getOhDateByTask`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( catchError(this.handleError('queryDigitalHumanTask')) ); } pollDigitalHumanIdentifyUntilReady( workId: string, onProgress?: (status: string, progress: number) => void ): Observable { return new Observable(observer => { let attempt = 0; let finished = false; const poll = () => { if (finished) return; attempt += 1; if (attempt > this.pollMaxAttempts) { observer.error(new Error(`数字人主体识别超时:已达最大尝试次数 ${this.pollMaxAttempts}`)); return; } this.queryDigitalHumanTask(workId, 'getOhIdentifyMain').subscribe({ next: (res: any) => { if (finished) return; const tip = res?.data?.tip || '正在识别主体...'; const isContinue = res?.data?.isContinue === true; const progress = Math.min(10 + (attempt / this.pollMaxAttempts) * 90, 99); if (onProgress) { onProgress(tip, progress); } if (typeof tip === 'string' && tip.includes('不存在主体')) { finished = true; observer.error(new Error('上传图片未识别到可驱动主体,请更换清晰的人像图片')); return; } if (isContinue) { finished = true; observer.next(); observer.complete(); return; } setTimeout(poll, this.pollInterval); }, error: (err) => { if (!finished && attempt < this.pollMaxAttempts) { setTimeout(poll, this.pollInterval * 2); } else if (!finished) { observer.error(err); } } }); }; poll(); return () => { finished = true; }; }); } pollDigitalHumanGenerateUntilComplete( workId: string, resultWorkId: string, onProgress?: (status: string, progress: number) => void ): Observable<{ videoUrl: string; workId: string }> { return new Observable<{ videoUrl: string; workId: string }>(observer => { let attempt = 0; let finished = false; let consecutiveQueryErrors = 0; const QUERY_ERROR_THRESHOLD = 3; // 连续 N 次状态接口失败后回退到结果接口 const poll = () => { if (finished) return; attempt += 1; if (attempt > this.digitalHumanPollMaxAttempts) { this.getDigitalHumanWorkResult(resultWorkId).subscribe((result) => { if (result) { finished = true; observer.next(result); observer.complete(); return; } observer.error(new Error(`数字人视频生成超时:已达最大尝试次数 ${this.digitalHumanPollMaxAttempts}`)); }); return; } this.queryDigitalHumanTask(workId, 'getOmniHuman').subscribe({ next: (res: any) => { if (finished) return; consecutiveQueryErrors = 0; const data = res?.data || {}; const status = data?.status || ''; const isFinish = data?.isFinish === true || status === 'done'; const tip = data?.tip || (isFinish ? '数字人视频生成完成' : '正在生成数字人视频...'); const progress = Math.min(10 + (attempt / this.digitalHumanPollMaxAttempts) * 90, 99); if (onProgress) { onProgress(tip, isFinish ? 100 : progress); } if (typeof tip === 'string' && /fail|失败|error|expired|not_found/i.test(tip)) { finished = true; observer.error(new Error(tip)); return; } if (isFinish) { finished = true; this.getDigitalHumanWorkResult(resultWorkId).subscribe({ next: (result) => { if (!result) { observer.error(new Error('数字人视频生成完成,但未在作品结果中获取到视频地址')); return; } observer.next(result); observer.complete(); }, error: (err) => observer.error(err) }); return; } if (status === 'expired' || status === 'not_found') { finished = true; observer.error(new Error(`数字人任务状态异常:${status}`)); return; } if (attempt % this.digitalHumanResultCheckInterval === 0) { this.getDigitalHumanWorkResult(resultWorkId).subscribe({ next: (result) => { if (finished) return; if (result) { finished = true; if (onProgress) { onProgress('数字人视频生成完成!', 100); } observer.next(result); observer.complete(); return; } setTimeout(poll, this.pollInterval); }, error: () => { if (!finished) { setTimeout(poll, this.pollInterval); } } }); return; } setTimeout(poll, this.pollInterval); }, error: (err) => { if (finished) return; consecutiveQueryErrors += 1; const status = err?.status; // 状态接口连续异常时,回退尝试结果接口,避免被一直 500 卡住 if (consecutiveQueryErrors >= QUERY_ERROR_THRESHOLD) { if (consecutiveQueryErrors === QUERY_ERROR_THRESHOLD) { console.warn(`⚠️ 即梦状态接口连续 ${QUERY_ERROR_THRESHOLD} 次异常 (status=${status}),回退到结果接口轮询`); } if (onProgress) { const progress = Math.min(10 + (attempt / this.digitalHumanPollMaxAttempts) * 90, 99); onProgress(`服务端临时异常,正在尝试直接获取结果...(${consecutiveQueryErrors})`, progress); } this.getDigitalHumanWorkResult(resultWorkId).subscribe({ next: (result) => { if (finished) return; if (result) { finished = true; if (onProgress) onProgress('数字人视频生成完成!', 100); observer.next(result); observer.complete(); return; } if (attempt < this.digitalHumanPollMaxAttempts) { setTimeout(poll, this.pollInterval * 2); } else { observer.error(err); } }, error: () => { if (finished) return; if (attempt < this.digitalHumanPollMaxAttempts) { setTimeout(poll, this.pollInterval * 2); } else { observer.error(err); } } }); return; } if (attempt < this.digitalHumanPollMaxAttempts) { setTimeout(poll, this.pollInterval * 2); } else { observer.error(err); } } }); }; poll(); return () => { finished = true; }; }); } private getDigitalHumanWorkResult(resultWorkId: string): Observable<{ videoUrl: string; workId: string } | null> { return this.getWorkResult(resultWorkId).pipe( map((result: JimengWorkResult) => { const videoUrl = result?.videos?.[0] || ''; return videoUrl ? { videoUrl, workId: resultWorkId } : null; }), catchError(() => of(null)) ); } generateDigitalHuman( workId: string, audioUrl: string, options: { prompt?: string; peFastMode?: boolean; outputResolution?: '720p' | '1080p'; maskUrl?: string[]; } = {}, onProgress?: (status: string, progress: number) => void ): Observable<{ videoUrl: string; workId: string }> { const outputResolution = options.outputResolution === '720p' ? 720 : 1080; const body = { workId, audio_url: audioUrl, prompt: options.prompt || '', pe_fast_mode: !!options.peFastMode, output_resolution: outputResolution, mask_url: options.maskUrl || [], token: this.token }; if (onProgress) onProgress('正在提交数字人生成任务...', 5); return this.submitDigitalHumanGenerate(body, onProgress).pipe( switchMap((res: any) => { const generateTaskId = res?.data?.workId || res?.data?.taskId || res?.data?.task_id || res?.workId || res?.taskId || ''; const isAccepted = (res?.code === 200 || res?.code === 10000) && (res?.data?.isContinue === true || typeof res?.data?.tip === 'string' || !!generateTaskId); if (!isAccepted) { const requestId = res?.request_id ? ` (request_id: ${res.request_id})` : ''; const fallback = (() => { try { return JSON.stringify(res); } catch { return '数字人生成任务提交失败'; } })(); throw new Error(`${res?.msg || res?.message || res?.error || fallback}${requestId}`); } if (onProgress) onProgress('数字人任务已提交,正在生成视频...', 15); const pollWorkId = generateTaskId || workId; return this.pollDigitalHumanGenerateUntilComplete(pollWorkId, workId, (status, progress) => { if (onProgress) onProgress(status || '正在生成数字人视频...', progress); }).pipe( map((result: { videoUrl: string; workId: string }) => { if (onProgress) onProgress('数字人视频生成完成!', 100); return result; }) ); }), catchError((err) => { if (onProgress) onProgress(`生成失败: ${err.message}`, 0); return throwError(() => err); }) ); } private submitDigitalHumanGenerate( body: any, onProgress?: (status: string, progress: number) => void, attempt: number = 1 ): Observable { return this.http.post(`${this.baseUrl}/getOmniHuman`, body, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }).pipe( tap((res: any) => console.log('🎭 即梦API - 数字人生成提交响应:', res)), catchError((err) => { const retryCode = this.extractJimengErrorCode(err); const shouldRetry = retryCode !== null && this.digitalHumanSubmitRetryableCodes.has(retryCode) && attempt < 3; if (!shouldRetry) { return throwError(() => err); } const delayMs = attempt * 4000; if (onProgress) { onProgress(`数字人接口繁忙,${Math.round(delayMs / 1000)} 秒后自动重试第 ${attempt + 1} 次...`, 8); } return timer(delayMs).pipe( switchMap(() => this.submitDigitalHumanGenerate(body, onProgress, attempt + 1)) ); }) ); } private extractJimengErrorCode(error: any): number | null { const detail = error?.error; const nested = detail?.message?.errmsg || detail?.errmsg || detail?.error?.errmsg || detail?.message; const code = nested?.code ?? detail?.code ?? error?.status; return typeof code === 'number' ? code : Number.isFinite(Number(code)) ? Number(code) : null; } // 轮询任务直到完成,返回最终视频/图片URL pollUntilComplete( workId: string, routerName: string, onProgress?: (status: JimengTaskStatus, attempt: number) => void ): Observable { return new Observable(observer => { let attempt = 0; let finished = false; const poll = () => { if (finished) return; attempt++; if (attempt > this.pollMaxAttempts) { observer.error(new Error(`轮询超时:已达最大尝试次数 ${this.pollMaxAttempts}`)); return; } this.queryTask(workId, routerName).subscribe({ next: (res: any) => { if (finished) return; const status: JimengTaskStatus = { tip: res?.data?.tip || '处理中...', isFinish: res?.data?.isFinish === true, isContinue: res?.data?.isContinue === true }; if (onProgress) { onProgress(status, attempt); } console.log(`🔄 轮询 #${attempt}: ${status.tip}, isFinish=${status.isFinish}`); if (status.isFinish) { finished = true; // 任务完成,获取最终结果 this.getWorkResult(workId).subscribe({ next: (result) => { observer.next(result); observer.complete(); }, error: (err) => observer.error(err) }); } else { // 继续轮询 setTimeout(poll, this.pollInterval); } }, error: (err) => { if (!finished) { console.warn(`轮询出错 #${attempt}:`, err); // 出错也继续重试 if (attempt < this.pollMaxAttempts) { setTimeout(poll, this.pollInterval * 2); } else { observer.error(err); } } } }); }; poll(); // 返回清理函数 return () => { finished = true; }; }); } // 完整的AI重塑流程:提交任务 → 轮询 → 获取结果 remixVideo( prompt: string, options: { method?: '1' | '2'; frames?: number; aspectRatio?: string; imageUrl?: string; quality?: '720p' | '1080p' | 'pro'; } = {}, onProgress?: (status: string, progress: number) => void ): Observable<{ videoUrl: string; workId: string }> { const method = options.method || '1'; const quality = options.quality || '1080p'; const request: JimengVideoRequest = { prompt, method, frames: options.frames || 121, config: method === '1' ? { aspect_ratio: options.aspectRatio || '16:9' } : { image_urls: options.imageUrl ? [options.imageUrl] : [] } }; if (onProgress) onProgress('正在提交生成任务...', 5); const generateFn = quality === 'pro' ? this.generateVideoPro(request) : quality === '720p' ? this.generateVideo720p(request) : this.generateVideo1080p(request); return generateFn.pipe( switchMap((res: any) => { if (res?.code !== 200 || !res?.data?.workId) { throw new Error(res?.msg || '任务提交失败'); } const workId = res.data.workId; console.log(`✅ 任务已提交, workId=${workId}`); if (onProgress) onProgress('任务已提交,正在生成视频...', 15); const routerName = quality === 'pro' ? 'getVideoV3_Pro' : quality === '720p' ? 'getVideoV3_720p' : 'getVideoV3_1080p'; return this.pollUntilComplete(workId, routerName, (status, attempt) => { const progressPercent = Math.min(15 + (attempt / this.pollMaxAttempts) * 75, 90); if (onProgress) onProgress(status.tip || '正在生成视频...', progressPercent); }).pipe( map((result: JimengWorkResult) => { const videoUrl = result.videos?.[0] || ''; if (!videoUrl) { throw new Error('未获取到视频URL'); } if (onProgress) onProgress('视频生成完成!', 100); return { videoUrl, workId }; }) ); }), catchError((err) => { if (onProgress) onProgress(`生成失败: ${err.message}`, 0); return throwError(() => err); }) ); } // ==================== 一键成片(视频拼接) ==================== // Quickly 平台凭证 private readonly quicklyAppKey = 'ZmNmOGRhNjYzZTAx'; private readonly quicklyAppSecret = 'eaa12154c248cad9159a9d6ea8bedf46'; private readonly quicklyAccountId = '12859_117409'; private readonly quicklyCallbackUrl = 'https://server.fmode.cn/api/functions/cut/onemerge'; // 生成 Quickly 签名 private generateQuicklySign(): { timestamp: string; sign: string } { const timestamp = Date.now().toString(); const signStr = timestamp + '#' + this.quicklyAppSecret; const sign = md5(signStr); return { timestamp, sign }; } // 提交一键成片任务(通过本地后端代理,服务端签名) stitchVideos( videoUrls: string[], options: { tags?: string; proportion?: '9:16' | '3:4' | '1:1'; videoDuration?: { min: number; max: number }; aiVoice?: number; aiBgm?: number; aiSubtitle?: number; } = {} ): Observable { console.log('🎬 一键成片 - 通过后端代理提交:', { videoUrls: videoUrls.length, options }); return this.http.post('/backend/api/quickly/create', { videoUrls, options }).pipe( map((res: any) => { console.log('🎬 一键成片 - 响应:', res); const taskId = res?.data?.task_id || res?.task_id; if (!taskId) { throw new Error(res?.message || JSON.stringify(res) || '一键成片任务提交失败'); } console.log(`✅ 一键成片任务已提交, taskId=${taskId}`); return taskId; }), catchError(this.handleError('stitchVideos')) ); } // 查询一键成片结果(通过本地后端代理) queryStitchResult(taskId: string): Observable { return this.http.post('/backend/api/quickly/query', { taskId }).pipe( map((res: any) => { if (res?.data && Array.isArray(res.data)) { return res.data; } return []; }), catchError(this.handleError('queryStitchResult')) ); } // 轮询一键成片结果直到完成 pollStitchUntilComplete( taskId: string, onProgress?: (status: string, attempt: number) => void ): Observable<{ videoUrl: string; coverUrl: string; duration: number }> { const STITCH_POLL_INTERVAL = 15000; const STITCH_POLL_MAX = 40; return new Observable(observer => { let attempt = 0; let finished = false; const poll = () => { if (finished) return; attempt++; if (attempt > STITCH_POLL_MAX) { observer.error(new Error(`一键成片轮询超时:已达最大尝试次数 ${STITCH_POLL_MAX}`)); return; } if (onProgress) onProgress(`正在合成视频... (${attempt}/${STITCH_POLL_MAX})`, attempt); this.queryStitchResult(taskId).subscribe({ next: (data) => { if (finished) return; console.log(`🔄 一键成片轮询 #${attempt}: ${data.length} 条结果`); if (data.length > 0) { finished = true; const result = data[0]; observer.next({ videoUrl: result.videoUrl, coverUrl: result.coverUrl || '', duration: result.videoDuration || 0 }); observer.complete(); } else { setTimeout(poll, STITCH_POLL_INTERVAL); } }, error: (err) => { if (!finished && attempt < STITCH_POLL_MAX) { setTimeout(poll, STITCH_POLL_INTERVAL); } else if (!finished) { observer.error(err); } } }); }; poll(); return () => { finished = true; }; }); } private handleError(operation: string) { return (error: any): Observable => { console.error(`即梦API [${operation}] 错误:`, error); const rawDetail = error?.error; let detail = rawDetail; if (typeof rawDetail === 'string') { try { detail = JSON.parse(rawDetail); } catch { detail = rawDetail; } } const nestedErrmsg = typeof detail === 'object' ? detail?.message?.errmsg || detail?.errmsg || detail?.error?.errmsg || null : null; const requestIdValue = nestedErrmsg?.request_id || (typeof detail === 'object' ? detail?.request_id : ''); const requestId = requestIdValue ? ` (request_id: ${requestIdValue})` : ''; const message = typeof detail === 'object' ? nestedErrmsg?.message || detail?.msg || detail?.message?.tip || detail?.message || detail?.error || error?.message : typeof detail === 'string' && detail.trim() ? detail : error?.message || `即梦API ${operation} 调用失败`; const fallbackDetail = (() => { if (!rawDetail) return ''; if (typeof rawDetail === 'string') return rawDetail; try { return JSON.stringify(rawDetail); } catch { return ''; } })(); const finalMessage = message && !/^Http failure response/i.test(message) ? `${message}${requestId}` : `${message || `即梦API ${operation} 调用失败`}${requestId}${fallbackDetail ? ` | detail: ${fallbackDetail}` : ''}`; const normalizedError = new Error(finalMessage); (normalizedError as any).raw = error; return throwError(() => normalizedError); }; } }