jimeng.service.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  1. import { Injectable } from '@angular/core';
  2. import { HttpClient, HttpHeaders } from '@angular/common/http';
  3. import { Observable, Subject, timer, throwError, of } from 'rxjs';
  4. import { switchMap, takeWhile, tap, catchError, map } from 'rxjs/operators';
  5. import { environment } from '../../environments/environment';
  6. // MD5 hash function (lightweight, for signing only)
  7. function md5(input: string): string {
  8. function safeAdd(x: number, y: number) {
  9. const lsw = (x & 0xffff) + (y & 0xffff);
  10. return (((x >> 16) + (y >> 16) + (lsw >> 16)) << 16) | (lsw & 0xffff);
  11. }
  12. function bitRotateLeft(num: number, cnt: number) {
  13. return (num << cnt) | (num >>> (32 - cnt));
  14. }
  15. function md5cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
  16. return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
  17. }
  18. function md5ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
  19. return md5cmn((b & c) | (~b & d), a, b, x, s, t);
  20. }
  21. function md5gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
  22. return md5cmn((b & d) | (c & ~d), a, b, x, s, t);
  23. }
  24. function md5hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
  25. return md5cmn(b ^ c ^ d, a, b, x, s, t);
  26. }
  27. function md5ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
  28. return md5cmn(c ^ (b | ~d), a, b, x, s, t);
  29. }
  30. function binlMD5(x: number[], len: number): number[] {
  31. x[len >> 5] |= 0x80 << (len % 32);
  32. x[((len + 64) >>> 9 << 4) + 14] = len;
  33. let a = 1732584193, b = -271733879, c = -1732584194, d = 271733878;
  34. for (let i = 0; i < x.length; i += 16) {
  35. const olda = a, oldb = b, oldc = c, oldd = d;
  36. a = md5ff(a, b, c, d, x[i], 7, -680876936); d = md5ff(d, a, b, c, x[i + 1], 12, -389564586);
  37. c = md5ff(c, d, a, b, x[i + 2], 17, 606105819); b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330);
  38. a = md5ff(a, b, c, d, x[i + 4], 7, -176418897); d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426);
  39. c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341); b = md5ff(b, c, d, a, x[i + 7], 22, -45705983);
  40. a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416); d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417);
  41. c = md5ff(c, d, a, b, x[i + 10], 17, -42063); b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162);
  42. a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682); d = md5ff(d, a, b, c, x[i + 13], 12, -40341101);
  43. c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290); b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329);
  44. a = md5gg(a, b, c, d, x[i + 1], 5, -165796510); d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632);
  45. c = md5gg(c, d, a, b, x[i + 11], 14, 643717713); b = md5gg(b, c, d, a, x[i], 20, -373897302);
  46. a = md5gg(a, b, c, d, x[i + 5], 5, -701558691); d = md5gg(d, a, b, c, x[i + 10], 9, 38016083);
  47. c = md5gg(c, d, a, b, x[i + 15], 14, -660478335); b = md5gg(b, c, d, a, x[i + 4], 20, -405537848);
  48. a = md5gg(a, b, c, d, x[i + 9], 5, 568446438); d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690);
  49. c = md5gg(c, d, a, b, x[i + 3], 14, -187363961); b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501);
  50. a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467); d = md5gg(d, a, b, c, x[i + 2], 9, -51403784);
  51. c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473); b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734);
  52. a = md5hh(a, b, c, d, x[i + 5], 4, -378558); d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463);
  53. c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562); b = md5hh(b, c, d, a, x[i + 14], 23, -35309556);
  54. a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060); d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353);
  55. c = md5hh(c, d, a, b, x[i + 7], 16, -155497632); b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640);
  56. a = md5hh(a, b, c, d, x[i + 13], 4, 681279174); d = md5hh(d, a, b, c, x[i], 11, -358537222);
  57. c = md5hh(c, d, a, b, x[i + 3], 16, -722521979); b = md5hh(b, c, d, a, x[i + 6], 23, 76029189);
  58. a = md5hh(a, b, c, d, x[i + 9], 4, -640364487); d = md5hh(d, a, b, c, x[i + 12], 11, -421815835);
  59. c = md5hh(c, d, a, b, x[i + 15], 16, 530742520); b = md5hh(b, c, d, a, x[i + 2], 23, -995338651);
  60. a = md5ii(a, b, c, d, x[i], 6, -198630844); d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415);
  61. c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905); b = md5ii(b, c, d, a, x[i + 5], 21, -57434055);
  62. a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571); d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606);
  63. c = md5ii(c, d, a, b, x[i + 10], 15, -1051523); b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799);
  64. a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359); d = md5ii(d, a, b, c, x[i + 15], 10, -30611744);
  65. c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380); b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649);
  66. a = md5ii(a, b, c, d, x[i + 4], 6, -145523070); d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379);
  67. c = md5ii(c, d, a, b, x[i + 2], 15, 718787259); b = md5ii(b, c, d, a, x[i + 9], 21, -343485551);
  68. a = safeAdd(a, olda); b = safeAdd(b, oldb); c = safeAdd(c, oldc); d = safeAdd(d, oldd);
  69. }
  70. return [a, b, c, d];
  71. }
  72. function str2binl(str: string): number[] {
  73. const bin: number[] = [];
  74. const mask = (1 << 8) - 1;
  75. for (let i = 0; i < str.length * 8; i += 8) {
  76. bin[i >> 5] |= (str.charCodeAt(i / 8) & mask) << (i % 32);
  77. }
  78. return bin;
  79. }
  80. function binl2hex(binarray: number[]): string {
  81. const hexTab = '0123456789abcdef';
  82. let str = '';
  83. for (let i = 0; i < binarray.length * 4; i++) {
  84. str += hexTab.charAt((binarray[i >> 2] >> ((i % 4) * 8 + 4)) & 0xf) +
  85. hexTab.charAt((binarray[i >> 2] >> ((i % 4) * 8)) & 0xf);
  86. }
  87. return str;
  88. }
  89. return binl2hex(binlMD5(str2binl(input), input.length * 8));
  90. }
  91. export interface JimengVideoRequest {
  92. prompt: string;
  93. method: '1' | '2' | '3' | '4'; // 1文生视频 2首帧 3首尾帧 4运镜
  94. frames?: number; // 121(5s) 或 241(10s)
  95. config?: {
  96. aspect_ratio?: string;
  97. image_urls?: string[];
  98. template_id?: string;
  99. camera_strength?: string;
  100. };
  101. }
  102. export interface JimengTaskStatus {
  103. tip: string;
  104. isFinish: boolean;
  105. isContinue?: boolean;
  106. }
  107. export interface JimengWorkResult {
  108. objectId: string;
  109. images?: string[];
  110. videos?: string[];
  111. prompt?: string;
  112. progress?: number;
  113. usage?: any;
  114. }
  115. export interface RemixTask {
  116. id: string;
  117. videoTitle: string;
  118. style: string;
  119. prompt: string;
  120. workId?: string;
  121. status: 'submitting' | 'generating' | 'polling' | 'completed' | 'failed';
  122. progress: number;
  123. resultUrl?: string;
  124. error?: string;
  125. createdAt: Date;
  126. }
  127. @Injectable({
  128. providedIn: 'root'
  129. })
  130. export class JimengService {
  131. private readonly digitalHumanSubmitRetryableCodes = new Set([50429, 50430, 50500, 50501]);
  132. private readonly baseUrl = environment.jimengApi.baseUrl;
  133. private readonly parseBaseUrl = environment.jimengApi.parseBaseUrl;
  134. private readonly parseAppId = environment.jimengApi.parseAppId;
  135. private readonly token = environment.jimengApi.token;
  136. private readonly pollInterval = environment.jimengApi.pollIntervalMs;
  137. private readonly pollMaxAttempts = environment.jimengApi.pollMaxAttempts;
  138. private readonly digitalHumanPollMaxAttempts = Math.max(environment.jimengApi.pollMaxAttempts, 120);
  139. private readonly digitalHumanResultCheckInterval = 3;
  140. constructor(private http: HttpClient) {}
  141. // 提交文生视频任务 (720p)
  142. generateVideo720p(request: JimengVideoRequest): Observable<any> {
  143. const body = {
  144. prompt: request.prompt,
  145. method: request.method,
  146. frames: request.frames || 121,
  147. config: request.config || { aspect_ratio: '16:9' },
  148. token: this.token
  149. };
  150. console.log('🎬 即梦API - 提交视频生成任务:', body);
  151. return this.http.post(`${this.baseUrl}/getVideoV3_720p`, body, {
  152. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  153. }).pipe(
  154. tap((res: any) => console.log('🎬 即梦API - 任务提交响应:', res)),
  155. catchError(this.handleError('generateVideo720p'))
  156. );
  157. }
  158. // 提交文生视频任务 (1080p)
  159. generateVideo1080p(request: JimengVideoRequest): Observable<any> {
  160. const body = {
  161. prompt: request.prompt,
  162. method: request.method,
  163. frames: request.frames || 121,
  164. config: request.config || { aspect_ratio: '16:9' },
  165. token: this.token
  166. };
  167. console.log('🎬 即梦API - 提交1080p视频生成任务:', body);
  168. return this.http.post(`${this.baseUrl}/getVideoV3_1080p`, body, {
  169. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  170. }).pipe(
  171. tap((res: any) => console.log('🎬 即梦API - 1080p任务提交响应:', res)),
  172. catchError(this.handleError('generateVideo1080p'))
  173. );
  174. }
  175. // 提交文生视频任务 (Pro)
  176. generateVideoPro(request: JimengVideoRequest): Observable<any> {
  177. const body = {
  178. prompt: request.prompt,
  179. method: request.method,
  180. frames: request.frames || 121,
  181. config: request.config || { aspect_ratio: '16:9' },
  182. token: this.token
  183. };
  184. console.log('🎬 即梦API - 提交Pro视频生成任务:', body);
  185. return this.http.post(`${this.baseUrl}/getVideoV3_Pro`, body, {
  186. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  187. }).pipe(
  188. tap((res: any) => console.log('🎬 即梦API - Pro任务提交响应:', res)),
  189. catchError(this.handleError('generateVideoPro'))
  190. );
  191. }
  192. // 提交文生图任务 (v3.1)
  193. generateImage(prompt: string, width: number = 1920, height: number = 1080): Observable<any> {
  194. const body = {
  195. prompt: prompt,
  196. sizeDate: { width, height },
  197. token: this.token
  198. };
  199. console.log('🖼️ 即梦API - 提交图片生成任务:', body);
  200. return this.http.post(`${this.baseUrl}/getText2ImgV31`, body, {
  201. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  202. }).pipe(
  203. tap((res: any) => console.log('🖼️ 即梦API - 图片任务提交响应:', res)),
  204. catchError(this.handleError('generateImage'))
  205. );
  206. }
  207. // 查询任务状态
  208. queryTask(workId: string, routerName: string): Observable<any> {
  209. const body = {
  210. workId: workId,
  211. routerName: routerName,
  212. token: this.token
  213. };
  214. return this.http.post(`${this.baseUrl}/getDataByTask02`, body, {
  215. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  216. }).pipe(
  217. catchError(this.handleError('queryTask'))
  218. );
  219. }
  220. // 获取作品结果(通过 Parse API 查询 ImagineWork 表)
  221. getWorkResult(workId: string): Observable<JimengWorkResult> {
  222. return this.http.get<JimengWorkResult>(
  223. `${this.parseBaseUrl}/classes/ImagineWork/${workId}`,
  224. {
  225. headers: new HttpHeaders({
  226. 'X-Parse-Application-Id': this.parseAppId
  227. })
  228. }
  229. ).pipe(
  230. tap((res: any) => console.log('📦 即梦API - 作品结果:', res)),
  231. catchError(this.handleError('getWorkResult'))
  232. );
  233. }
  234. uploadFileToParse(file: Blob, filename: string, contentType?: string): Observable<string> {
  235. const formData = new FormData();
  236. const normalizedFilename = String(filename || `asset-${Date.now()}`);
  237. const typedFile = file instanceof File
  238. ? file
  239. : new File([file], normalizedFilename, { type: contentType || 'application/octet-stream' });
  240. formData.append('file', typedFile, normalizedFilename);
  241. return this.http.post<any>(
  242. `/backend/api/remix/upload-asset`,
  243. formData
  244. ).pipe(
  245. map((res: any) => {
  246. const url = res?.url || '';
  247. if (!url) {
  248. throw new Error('上传素材失败,未返回可用 URL');
  249. }
  250. return url;
  251. }),
  252. tap((url: string) => console.log('📤 数字人素材上传成功:', url)),
  253. catchError(this.handleError('uploadFileToParse'))
  254. );
  255. }
  256. identifyDigitalHuman(imageUrl: string): Observable<{ workId: string }> {
  257. const body = {
  258. image_url: imageUrl,
  259. token: this.token
  260. };
  261. return this.http.post<any>(`${this.baseUrl}/getOhIdentifyMain`, body, {
  262. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  263. }).pipe(
  264. map((res: any) => {
  265. const workId = res?.data?.workId || res?.data?.taskId || '';
  266. if (res?.code !== 200 || !workId) {
  267. throw new Error(res?.msg || res?.message || '数字人主体识别任务提交失败');
  268. }
  269. return { workId };
  270. }),
  271. tap((res: { workId: string }) => console.log('🤖 数字人主体识别任务已提交:', res.workId)),
  272. catchError(this.handleError('identifyDigitalHuman'))
  273. );
  274. }
  275. queryDigitalHumanTask(workId: string, routerName: 'getOhIdentifyMain' | 'getOmniHuman'): Observable<any> {
  276. const body = {
  277. workId,
  278. taskId: workId,
  279. routerName,
  280. token: this.token
  281. };
  282. return this.http.post<any>(`${this.baseUrl}/getOhDateByTask`, body, {
  283. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  284. }).pipe(
  285. catchError(this.handleError('queryDigitalHumanTask'))
  286. );
  287. }
  288. pollDigitalHumanIdentifyUntilReady(
  289. workId: string,
  290. onProgress?: (status: string, progress: number) => void
  291. ): Observable<void> {
  292. return new Observable<void>(observer => {
  293. let attempt = 0;
  294. let finished = false;
  295. const poll = () => {
  296. if (finished) return;
  297. attempt += 1;
  298. if (attempt > this.pollMaxAttempts) {
  299. observer.error(new Error(`数字人主体识别超时:已达最大尝试次数 ${this.pollMaxAttempts}`));
  300. return;
  301. }
  302. this.queryDigitalHumanTask(workId, 'getOhIdentifyMain').subscribe({
  303. next: (res: any) => {
  304. if (finished) return;
  305. const tip = res?.data?.tip || '正在识别主体...';
  306. const isContinue = res?.data?.isContinue === true;
  307. const progress = Math.min(10 + (attempt / this.pollMaxAttempts) * 90, 99);
  308. if (onProgress) {
  309. onProgress(tip, progress);
  310. }
  311. if (typeof tip === 'string' && tip.includes('不存在主体')) {
  312. finished = true;
  313. observer.error(new Error('上传图片未识别到可驱动主体,请更换清晰的人像图片'));
  314. return;
  315. }
  316. if (isContinue) {
  317. finished = true;
  318. observer.next();
  319. observer.complete();
  320. return;
  321. }
  322. setTimeout(poll, this.pollInterval);
  323. },
  324. error: (err) => {
  325. if (!finished && attempt < this.pollMaxAttempts) {
  326. setTimeout(poll, this.pollInterval * 2);
  327. } else if (!finished) {
  328. observer.error(err);
  329. }
  330. }
  331. });
  332. };
  333. poll();
  334. return () => { finished = true; };
  335. });
  336. }
  337. pollDigitalHumanGenerateUntilComplete(
  338. workId: string,
  339. resultWorkId: string,
  340. onProgress?: (status: string, progress: number) => void
  341. ): Observable<{ videoUrl: string; workId: string }> {
  342. return new Observable<{ videoUrl: string; workId: string }>(observer => {
  343. let attempt = 0;
  344. let finished = false;
  345. let consecutiveQueryErrors = 0;
  346. const QUERY_ERROR_THRESHOLD = 3; // 连续 N 次状态接口失败后回退到结果接口
  347. const poll = () => {
  348. if (finished) return;
  349. attempt += 1;
  350. if (attempt > this.digitalHumanPollMaxAttempts) {
  351. this.getDigitalHumanWorkResult(resultWorkId).subscribe((result) => {
  352. if (result) {
  353. finished = true;
  354. observer.next(result);
  355. observer.complete();
  356. return;
  357. }
  358. observer.error(new Error(`数字人视频生成超时:已达最大尝试次数 ${this.digitalHumanPollMaxAttempts}`));
  359. });
  360. return;
  361. }
  362. this.queryDigitalHumanTask(workId, 'getOmniHuman').subscribe({
  363. next: (res: any) => {
  364. if (finished) return;
  365. consecutiveQueryErrors = 0;
  366. const data = res?.data || {};
  367. const status = data?.status || '';
  368. const isFinish = data?.isFinish === true || status === 'done';
  369. const tip = data?.tip || (isFinish ? '数字人视频生成完成' : '正在生成数字人视频...');
  370. const progress = Math.min(10 + (attempt / this.digitalHumanPollMaxAttempts) * 90, 99);
  371. if (onProgress) {
  372. onProgress(tip, isFinish ? 100 : progress);
  373. }
  374. if (typeof tip === 'string' && /fail|失败|error|expired|not_found/i.test(tip)) {
  375. finished = true;
  376. observer.error(new Error(tip));
  377. return;
  378. }
  379. if (isFinish) {
  380. finished = true;
  381. this.getDigitalHumanWorkResult(resultWorkId).subscribe({
  382. next: (result) => {
  383. if (!result) {
  384. observer.error(new Error('数字人视频生成完成,但未在作品结果中获取到视频地址'));
  385. return;
  386. }
  387. observer.next(result);
  388. observer.complete();
  389. },
  390. error: (err) => observer.error(err)
  391. });
  392. return;
  393. }
  394. if (status === 'expired' || status === 'not_found') {
  395. finished = true;
  396. observer.error(new Error(`数字人任务状态异常:${status}`));
  397. return;
  398. }
  399. if (attempt % this.digitalHumanResultCheckInterval === 0) {
  400. this.getDigitalHumanWorkResult(resultWorkId).subscribe({
  401. next: (result) => {
  402. if (finished) return;
  403. if (result) {
  404. finished = true;
  405. if (onProgress) {
  406. onProgress('数字人视频生成完成!', 100);
  407. }
  408. observer.next(result);
  409. observer.complete();
  410. return;
  411. }
  412. setTimeout(poll, this.pollInterval);
  413. },
  414. error: () => {
  415. if (!finished) {
  416. setTimeout(poll, this.pollInterval);
  417. }
  418. }
  419. });
  420. return;
  421. }
  422. setTimeout(poll, this.pollInterval);
  423. },
  424. error: (err) => {
  425. if (finished) return;
  426. consecutiveQueryErrors += 1;
  427. const status = err?.status;
  428. // 状态接口连续异常时,回退尝试结果接口,避免被一直 500 卡住
  429. if (consecutiveQueryErrors >= QUERY_ERROR_THRESHOLD) {
  430. if (consecutiveQueryErrors === QUERY_ERROR_THRESHOLD) {
  431. console.warn(`⚠️ 即梦状态接口连续 ${QUERY_ERROR_THRESHOLD} 次异常 (status=${status}),回退到结果接口轮询`);
  432. }
  433. if (onProgress) {
  434. const progress = Math.min(10 + (attempt / this.digitalHumanPollMaxAttempts) * 90, 99);
  435. onProgress(`服务端临时异常,正在尝试直接获取结果...(${consecutiveQueryErrors})`, progress);
  436. }
  437. this.getDigitalHumanWorkResult(resultWorkId).subscribe({
  438. next: (result) => {
  439. if (finished) return;
  440. if (result) {
  441. finished = true;
  442. if (onProgress) onProgress('数字人视频生成完成!', 100);
  443. observer.next(result);
  444. observer.complete();
  445. return;
  446. }
  447. if (attempt < this.digitalHumanPollMaxAttempts) {
  448. setTimeout(poll, this.pollInterval * 2);
  449. } else {
  450. observer.error(err);
  451. }
  452. },
  453. error: () => {
  454. if (finished) return;
  455. if (attempt < this.digitalHumanPollMaxAttempts) {
  456. setTimeout(poll, this.pollInterval * 2);
  457. } else {
  458. observer.error(err);
  459. }
  460. }
  461. });
  462. return;
  463. }
  464. if (attempt < this.digitalHumanPollMaxAttempts) {
  465. setTimeout(poll, this.pollInterval * 2);
  466. } else {
  467. observer.error(err);
  468. }
  469. }
  470. });
  471. };
  472. poll();
  473. return () => { finished = true; };
  474. });
  475. }
  476. private getDigitalHumanWorkResult(resultWorkId: string): Observable<{ videoUrl: string; workId: string } | null> {
  477. return this.getWorkResult(resultWorkId).pipe(
  478. map((result: JimengWorkResult) => {
  479. const videoUrl = result?.videos?.[0] || '';
  480. return videoUrl ? { videoUrl, workId: resultWorkId } : null;
  481. }),
  482. catchError(() => of(null))
  483. );
  484. }
  485. generateDigitalHuman(
  486. workId: string,
  487. audioUrl: string,
  488. options: {
  489. prompt?: string;
  490. peFastMode?: boolean;
  491. outputResolution?: '720p' | '1080p';
  492. maskUrl?: string[];
  493. } = {},
  494. onProgress?: (status: string, progress: number) => void
  495. ): Observable<{ videoUrl: string; workId: string }> {
  496. const outputResolution = options.outputResolution === '720p' ? 720 : 1080;
  497. const body = {
  498. workId,
  499. audio_url: audioUrl,
  500. prompt: options.prompt || '',
  501. pe_fast_mode: !!options.peFastMode,
  502. output_resolution: outputResolution,
  503. mask_url: options.maskUrl || [],
  504. token: this.token
  505. };
  506. if (onProgress) onProgress('正在提交数字人生成任务...', 5);
  507. return this.submitDigitalHumanGenerate(body, onProgress).pipe(
  508. switchMap((res: any) => {
  509. const generateTaskId = res?.data?.workId || res?.data?.taskId || res?.data?.task_id || res?.workId || res?.taskId || '';
  510. const isAccepted = (res?.code === 200 || res?.code === 10000)
  511. && (res?.data?.isContinue === true || typeof res?.data?.tip === 'string' || !!generateTaskId);
  512. if (!isAccepted) {
  513. const requestId = res?.request_id ? ` (request_id: ${res.request_id})` : '';
  514. const fallback = (() => {
  515. try {
  516. return JSON.stringify(res);
  517. } catch {
  518. return '数字人生成任务提交失败';
  519. }
  520. })();
  521. throw new Error(`${res?.msg || res?.message || res?.error || fallback}${requestId}`);
  522. }
  523. if (onProgress) onProgress('数字人任务已提交,正在生成视频...', 15);
  524. const pollWorkId = generateTaskId || workId;
  525. return this.pollDigitalHumanGenerateUntilComplete(pollWorkId, workId, (status, progress) => {
  526. if (onProgress) onProgress(status || '正在生成数字人视频...', progress);
  527. }).pipe(
  528. map((result: { videoUrl: string; workId: string }) => {
  529. if (onProgress) onProgress('数字人视频生成完成!', 100);
  530. return result;
  531. })
  532. );
  533. }),
  534. catchError((err) => {
  535. if (onProgress) onProgress(`生成失败: ${err.message}`, 0);
  536. return throwError(() => err);
  537. })
  538. );
  539. }
  540. private submitDigitalHumanGenerate(
  541. body: any,
  542. onProgress?: (status: string, progress: number) => void,
  543. attempt: number = 1
  544. ): Observable<any> {
  545. return this.http.post<any>(`${this.baseUrl}/getOmniHuman`, body, {
  546. headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  547. }).pipe(
  548. tap((res: any) => console.log('🎭 即梦API - 数字人生成提交响应:', res)),
  549. catchError((err) => {
  550. const retryCode = this.extractJimengErrorCode(err);
  551. const shouldRetry = retryCode !== null && this.digitalHumanSubmitRetryableCodes.has(retryCode) && attempt < 3;
  552. if (!shouldRetry) {
  553. return throwError(() => err);
  554. }
  555. const delayMs = attempt * 4000;
  556. if (onProgress) {
  557. onProgress(`数字人接口繁忙,${Math.round(delayMs / 1000)} 秒后自动重试第 ${attempt + 1} 次...`, 8);
  558. }
  559. return timer(delayMs).pipe(
  560. switchMap(() => this.submitDigitalHumanGenerate(body, onProgress, attempt + 1))
  561. );
  562. })
  563. );
  564. }
  565. private extractJimengErrorCode(error: any): number | null {
  566. const detail = error?.error;
  567. const nested = detail?.message?.errmsg || detail?.errmsg || detail?.error?.errmsg || detail?.message;
  568. const code = nested?.code ?? detail?.code ?? error?.status;
  569. return typeof code === 'number' ? code : Number.isFinite(Number(code)) ? Number(code) : null;
  570. }
  571. // 轮询任务直到完成,返回最终视频/图片URL
  572. pollUntilComplete(
  573. workId: string,
  574. routerName: string,
  575. onProgress?: (status: JimengTaskStatus, attempt: number) => void
  576. ): Observable<JimengWorkResult> {
  577. return new Observable<JimengWorkResult>(observer => {
  578. let attempt = 0;
  579. let finished = false;
  580. const poll = () => {
  581. if (finished) return;
  582. attempt++;
  583. if (attempt > this.pollMaxAttempts) {
  584. observer.error(new Error(`轮询超时:已达最大尝试次数 ${this.pollMaxAttempts}`));
  585. return;
  586. }
  587. this.queryTask(workId, routerName).subscribe({
  588. next: (res: any) => {
  589. if (finished) return;
  590. const status: JimengTaskStatus = {
  591. tip: res?.data?.tip || '处理中...',
  592. isFinish: res?.data?.isFinish === true,
  593. isContinue: res?.data?.isContinue === true
  594. };
  595. if (onProgress) {
  596. onProgress(status, attempt);
  597. }
  598. console.log(`🔄 轮询 #${attempt}: ${status.tip}, isFinish=${status.isFinish}`);
  599. if (status.isFinish) {
  600. finished = true;
  601. // 任务完成,获取最终结果
  602. this.getWorkResult(workId).subscribe({
  603. next: (result) => {
  604. observer.next(result);
  605. observer.complete();
  606. },
  607. error: (err) => observer.error(err)
  608. });
  609. } else {
  610. // 继续轮询
  611. setTimeout(poll, this.pollInterval);
  612. }
  613. },
  614. error: (err) => {
  615. if (!finished) {
  616. console.warn(`轮询出错 #${attempt}:`, err);
  617. // 出错也继续重试
  618. if (attempt < this.pollMaxAttempts) {
  619. setTimeout(poll, this.pollInterval * 2);
  620. } else {
  621. observer.error(err);
  622. }
  623. }
  624. }
  625. });
  626. };
  627. poll();
  628. // 返回清理函数
  629. return () => { finished = true; };
  630. });
  631. }
  632. // 完整的AI重塑流程:提交任务 → 轮询 → 获取结果
  633. remixVideo(
  634. prompt: string,
  635. options: {
  636. method?: '1' | '2';
  637. frames?: number;
  638. aspectRatio?: string;
  639. imageUrl?: string;
  640. quality?: '720p' | '1080p' | 'pro';
  641. } = {},
  642. onProgress?: (status: string, progress: number) => void
  643. ): Observable<{ videoUrl: string; workId: string }> {
  644. const method = options.method || '1';
  645. const quality = options.quality || '1080p';
  646. const request: JimengVideoRequest = {
  647. prompt,
  648. method,
  649. frames: options.frames || 121,
  650. config: method === '1'
  651. ? { aspect_ratio: options.aspectRatio || '16:9' }
  652. : { image_urls: options.imageUrl ? [options.imageUrl] : [] }
  653. };
  654. if (onProgress) onProgress('正在提交生成任务...', 5);
  655. const generateFn = quality === 'pro'
  656. ? this.generateVideoPro(request)
  657. : quality === '720p'
  658. ? this.generateVideo720p(request)
  659. : this.generateVideo1080p(request);
  660. return generateFn.pipe(
  661. switchMap((res: any) => {
  662. if (res?.code !== 200 || !res?.data?.workId) {
  663. throw new Error(res?.msg || '任务提交失败');
  664. }
  665. const workId = res.data.workId;
  666. console.log(`✅ 任务已提交, workId=${workId}`);
  667. if (onProgress) onProgress('任务已提交,正在生成视频...', 15);
  668. const routerName = quality === 'pro' ? 'getVideoV3_Pro' : quality === '720p' ? 'getVideoV3_720p' : 'getVideoV3_1080p';
  669. return this.pollUntilComplete(workId, routerName, (status, attempt) => {
  670. const progressPercent = Math.min(15 + (attempt / this.pollMaxAttempts) * 75, 90);
  671. if (onProgress) onProgress(status.tip || '正在生成视频...', progressPercent);
  672. }).pipe(
  673. map((result: JimengWorkResult) => {
  674. const videoUrl = result.videos?.[0] || '';
  675. if (!videoUrl) {
  676. throw new Error('未获取到视频URL');
  677. }
  678. if (onProgress) onProgress('视频生成完成!', 100);
  679. return { videoUrl, workId };
  680. })
  681. );
  682. }),
  683. catchError((err) => {
  684. if (onProgress) onProgress(`生成失败: ${err.message}`, 0);
  685. return throwError(() => err);
  686. })
  687. );
  688. }
  689. // ==================== 一键成片(视频拼接) ====================
  690. // Quickly 平台凭证
  691. private readonly quicklyAppKey = 'ZmNmOGRhNjYzZTAx';
  692. private readonly quicklyAppSecret = 'eaa12154c248cad9159a9d6ea8bedf46';
  693. private readonly quicklyAccountId = '12859_117409';
  694. private readonly quicklyCallbackUrl = 'https://server.fmode.cn/api/functions/cut/onemerge';
  695. // 生成 Quickly 签名
  696. private generateQuicklySign(): { timestamp: string; sign: string } {
  697. const timestamp = Date.now().toString();
  698. const signStr = timestamp + '#' + this.quicklyAppSecret;
  699. const sign = md5(signStr);
  700. return { timestamp, sign };
  701. }
  702. // 提交一键成片任务(通过本地后端代理,服务端签名)
  703. stitchVideos(
  704. videoUrls: string[],
  705. options: {
  706. tags?: string;
  707. proportion?: '9:16' | '3:4' | '1:1';
  708. videoDuration?: { min: number; max: number };
  709. aiVoice?: number;
  710. aiBgm?: number;
  711. aiSubtitle?: number;
  712. } = {}
  713. ): Observable<string> {
  714. console.log('🎬 一键成片 - 通过后端代理提交:', { videoUrls: videoUrls.length, options });
  715. return this.http.post<any>('/backend/api/quickly/create', {
  716. videoUrls,
  717. options
  718. }).pipe(
  719. map((res: any) => {
  720. console.log('🎬 一键成片 - 响应:', res);
  721. const taskId = res?.data?.task_id || res?.task_id;
  722. if (!taskId) {
  723. throw new Error(res?.message || JSON.stringify(res) || '一键成片任务提交失败');
  724. }
  725. console.log(`✅ 一键成片任务已提交, taskId=${taskId}`);
  726. return taskId;
  727. }),
  728. catchError(this.handleError('stitchVideos'))
  729. );
  730. }
  731. // 查询一键成片结果(通过本地后端代理)
  732. queryStitchResult(taskId: string): Observable<any[]> {
  733. return this.http.post<any>('/backend/api/quickly/query', { taskId }).pipe(
  734. map((res: any) => {
  735. if (res?.data && Array.isArray(res.data)) {
  736. return res.data;
  737. }
  738. return [];
  739. }),
  740. catchError(this.handleError('queryStitchResult'))
  741. );
  742. }
  743. // 轮询一键成片结果直到完成
  744. pollStitchUntilComplete(
  745. taskId: string,
  746. onProgress?: (status: string, attempt: number) => void
  747. ): Observable<{ videoUrl: string; coverUrl: string; duration: number }> {
  748. const STITCH_POLL_INTERVAL = 15000;
  749. const STITCH_POLL_MAX = 40;
  750. return new Observable(observer => {
  751. let attempt = 0;
  752. let finished = false;
  753. const poll = () => {
  754. if (finished) return;
  755. attempt++;
  756. if (attempt > STITCH_POLL_MAX) {
  757. observer.error(new Error(`一键成片轮询超时:已达最大尝试次数 ${STITCH_POLL_MAX}`));
  758. return;
  759. }
  760. if (onProgress) onProgress(`正在合成视频... (${attempt}/${STITCH_POLL_MAX})`, attempt);
  761. this.queryStitchResult(taskId).subscribe({
  762. next: (data) => {
  763. if (finished) return;
  764. console.log(`🔄 一键成片轮询 #${attempt}: ${data.length} 条结果`);
  765. if (data.length > 0) {
  766. finished = true;
  767. const result = data[0];
  768. observer.next({
  769. videoUrl: result.videoUrl,
  770. coverUrl: result.coverUrl || '',
  771. duration: result.videoDuration || 0
  772. });
  773. observer.complete();
  774. } else {
  775. setTimeout(poll, STITCH_POLL_INTERVAL);
  776. }
  777. },
  778. error: (err) => {
  779. if (!finished && attempt < STITCH_POLL_MAX) {
  780. setTimeout(poll, STITCH_POLL_INTERVAL);
  781. } else if (!finished) {
  782. observer.error(err);
  783. }
  784. }
  785. });
  786. };
  787. poll();
  788. return () => { finished = true; };
  789. });
  790. }
  791. private handleError(operation: string) {
  792. return (error: any): Observable<never> => {
  793. console.error(`即梦API [${operation}] 错误:`, error);
  794. const rawDetail = error?.error;
  795. let detail = rawDetail;
  796. if (typeof rawDetail === 'string') {
  797. try {
  798. detail = JSON.parse(rawDetail);
  799. } catch {
  800. detail = rawDetail;
  801. }
  802. }
  803. const nestedErrmsg = typeof detail === 'object'
  804. ? detail?.message?.errmsg || detail?.errmsg || detail?.error?.errmsg || null
  805. : null;
  806. const requestIdValue = nestedErrmsg?.request_id || (typeof detail === 'object' ? detail?.request_id : '');
  807. const requestId = requestIdValue ? ` (request_id: ${requestIdValue})` : '';
  808. const message = typeof detail === 'object'
  809. ? nestedErrmsg?.message || detail?.msg || detail?.message?.tip || detail?.message || detail?.error || error?.message
  810. : typeof detail === 'string' && detail.trim()
  811. ? detail
  812. : error?.message || `即梦API ${operation} 调用失败`;
  813. const fallbackDetail = (() => {
  814. if (!rawDetail) return '';
  815. if (typeof rawDetail === 'string') return rawDetail;
  816. try {
  817. return JSON.stringify(rawDetail);
  818. } catch {
  819. return '';
  820. }
  821. })();
  822. const finalMessage = message && !/^Http failure response/i.test(message)
  823. ? `${message}${requestId}`
  824. : `${message || `即梦API ${operation} 调用失败`}${requestId}${fallbackDetail ? ` | detail: ${fallbackDetail}` : ''}`;
  825. const normalizedError = new Error(finalMessage);
  826. (normalizedError as any).raw = error;
  827. return throwError(() => normalizedError);
  828. };
  829. }
  830. }