| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943 |
- 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<any> {
- 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<any> {
- 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<any> {
- 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<any> {
- 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<any> {
- 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<JimengWorkResult> {
- return this.http.get<JimengWorkResult>(
- `${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<string> {
- 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<any>(
- `/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<any>(`${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<any> {
- const body = {
- workId,
- taskId: workId,
- routerName,
- token: this.token
- };
- return this.http.post<any>(`${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<void> {
- return new Observable<void>(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<any> {
- return this.http.post<any>(`${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<JimengWorkResult> {
- return new Observable<JimengWorkResult>(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<string> {
- console.log('🎬 一键成片 - 通过后端代理提交:', { videoUrls: videoUrls.length, options });
- return this.http.post<any>('/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<any[]> {
- return this.http.post<any>('/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<never> => {
- 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);
- };
- }
- }
|