image-to-video.component.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { Subscription } from 'rxjs';
  5. import { map, switchMap } from 'rxjs/operators';
  6. import { ResultsService } from '../../../services/results.service';
  7. import {
  8. JimengService,
  9. JimengCameraTemplateId,
  10. JimengCameraStrength,
  11. JimengAspectRatio,
  12. JimengVideoQuality,
  13. } from '../../../services/jimeng.service';
  14. import { PipelineSessionService } from '../../../services/pipeline-session.service';
  15. import { QiniuUploadService } from '../../../services/qiniu-upload.service';
  16. import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
  17. import { DraftMeta } from '../../../models/pipeline-draft.model';
  18. import { userFriendlyError } from '../../../services/user-message.util';
  19. import { CostEstimatorService } from '../../../services/cost-estimator.service';
  20. import { GenerationTaskService } from '../../../services/generation-task.service';
  21. import { VideoDurationService } from '../../../services/video-duration.service';
  22. import {
  23. ReferenceVideoPromptPlan,
  24. ReferenceVideoPromptPlannerService,
  25. } from '../../../services/reference-video-prompt-planner.service';
  26. interface CameraTemplateOption {
  27. id: JimengCameraTemplateId;
  28. label: string;
  29. hint: string;
  30. }
  31. const CAMERA_TEMPLATES: CameraTemplateOption[] = [
  32. { id: 'hitchcock_dolly_in', label: '希区柯克推进', hint: '边推进边变焦的紧张感' },
  33. { id: 'hitchcock_dolly_out', label: '希区柯克拉远', hint: '边拉远边变焦的孤立感' },
  34. { id: 'quick_pull_back', label: '超级拉远', hint: '快速向后拉远揭示场景' },
  35. { id: 'crane_push', label: '起重机', hint: '由低到高的提升运镜' },
  36. { id: 'robo_arm', label: '机械臂', hint: '机械臂式精准运动' },
  37. { id: 'dynamic_orbit', label: '动感环绕', hint: '快速环绕主体' },
  38. { id: 'central_orbit', label: '中心环绕', hint: '围绕中心点平稳环绕' },
  39. { id: 'clockwise_swivel', label: '顺时针回旋', hint: '顺时针旋转镜头' },
  40. { id: 'counterclockwise_swivel', label: '逆时针回旋', hint: '逆时针旋转镜头' },
  41. { id: 'handheld', label: '手持运镜', hint: '手持晃动的纪实感' },
  42. { id: 'rapid_push_pull', label: '快速推拉', hint: '快速来回推拉' },
  43. ];
  44. interface I2vUploadedImage {
  45. url: string;
  46. name: string;
  47. size: number;
  48. preview: string; // local object URL for instant preview
  49. }
  50. type I2vQuality = JimengVideoQuality;
  51. type I2vAspect = JimengAspectRatio;
  52. type I2vMode = '2' | '3' | '4' | 'reference'; // 2首帧 3首尾帧 4运镜 reference参考图生成
  53. const I2V_REFERENCE_ASPECT_SIZE: Record<JimengAspectRatio, { width: number; height: number }> = {
  54. '1:1': { width: 2048, height: 2048 },
  55. '4:3': { width: 2048, height: 1536 },
  56. '3:4': { width: 1536, height: 2048 },
  57. '16:9': { width: 2048, height: 1152 },
  58. '9:16': { width: 1152, height: 2048 },
  59. '21:9': { width: 2048, height: 878 },
  60. };
  61. /** 会话快照:只存可序列化字段(File / ObjectURL 不入快照,但七牛云 url 可保留) */
  62. interface I2vSnapshot {
  63. prompt: string;
  64. mode: I2vMode;
  65. quality: I2vQuality;
  66. aspect: I2vAspect;
  67. frames: number;
  68. durationSeconds?: number;
  69. cameraTemplate: JimengCameraTemplateId;
  70. cameraStrength: JimengCameraStrength;
  71. firstFrame: { url: string; name: string; size: number } | null;
  72. lastFrame: { url: string; name: string; size: number } | null;
  73. resultVideoUrl: string;
  74. resultWorkId: string;
  75. referenceGeneratedFrameUrl?: string;
  76. referencePlan?: ReferenceVideoPromptPlan | null;
  77. }
  78. /**
  79. * P1: 图生视频 Pipeline
  80. *
  81. * 复用 JimengService.remixVideo({ method:'2'|'3', imageUrl, quality })。
  82. * 上传走七牛云直传(云函数签发 token)。
  83. */
  84. @Component({
  85. selector: 'app-image-to-video',
  86. standalone: true,
  87. imports: [CommonModule, FormsModule, SessionBarComponent],
  88. templateUrl: './image-to-video.component.html',
  89. styleUrls: ['./image-to-video.component.css'],
  90. })
  91. export class ImageToVideoComponent implements OnInit, OnDestroy {
  92. // ---- 输入 ----
  93. prompt = '';
  94. /** '2' = 单图首帧;'3' = 首尾帧;'4' = 运镜 */
  95. mode: I2vMode = '2';
  96. quality: I2vQuality = '1080p';
  97. aspect: I2vAspect = '16:9';
  98. /** 用户看秒数,提交时转换为 frames */
  99. durationSeconds = 5;
  100. frames = 121;
  101. readonly durationOptions = [5, 8, 10, 15];
  102. // 运镜参数
  103. readonly cameraTemplates = CAMERA_TEMPLATES;
  104. cameraTemplate: JimengCameraTemplateId = 'quick_pull_back';
  105. cameraStrength: JimengCameraStrength = 'medium';
  106. // ---- 上传素材 ----
  107. firstFrame: I2vUploadedImage | null = null;
  108. lastFrame: I2vUploadedImage | null = null;
  109. uploadingSlot: 'first' | 'last' | null = null;
  110. uploadError = '';
  111. // ---- 生成状态 ----
  112. generating = false;
  113. progress = 0;
  114. statusText = '';
  115. resultVideoUrl = '';
  116. resultWorkId = '';
  117. errorMsg = '';
  118. referencePlanning = false;
  119. referenceGeneratedFrameUrl = '';
  120. referencePlan: ReferenceVideoPromptPlan | null = null;
  121. // ---- 预设提示词 ----
  122. readonly promptPresets: { label: string; value: string }[] = [
  123. { label: '镜头缓推', value: '镜头缓慢推进,画面富有电影感' },
  124. { label: '人物动作', value: '主体自然走动,环境光影变化' },
  125. { label: '风吹动感', value: '微风吹拂,画面元素自然摆动' },
  126. { label: '光影流动', value: '光线随时间流动变化,电影质感' },
  127. { label: '运镜环绕', value: '镜头环绕主体缓慢旋转一周' },
  128. ];
  129. private genSub?: Subscription;
  130. private generationTaskId = '';
  131. constructor(
  132. private qiniuUpload: QiniuUploadService,
  133. private jimeng: JimengService,
  134. private cdr: ChangeDetectorRef,
  135. private zone: NgZone,
  136. private results: ResultsService,
  137. public session: PipelineSessionService,
  138. private generationTasks: GenerationTaskService,
  139. private costEstimator: CostEstimatorService,
  140. private videoDuration: VideoDurationService,
  141. private referencePlanner: ReferenceVideoPromptPlannerService,
  142. ) {}
  143. ngOnInit(): void {
  144. void this.session.bootstrap();
  145. const cur = this.session.active();
  146. if (cur && cur.pipelineId === 'image-to-video' && cur.snapshot) {
  147. this.fromSnapshot(cur.snapshot as I2vSnapshot);
  148. }
  149. }
  150. // ====== Session ======
  151. private toSnapshot(): I2vSnapshot {
  152. const stripPreview = (f: I2vUploadedImage | null) =>
  153. f ? { url: f.url, name: f.name, size: f.size } : null;
  154. return {
  155. prompt: this.prompt,
  156. mode: this.mode,
  157. quality: this.quality,
  158. aspect: this.aspect,
  159. frames: this.frames,
  160. durationSeconds: this.durationSeconds,
  161. cameraTemplate: this.cameraTemplate,
  162. cameraStrength: this.cameraStrength,
  163. firstFrame: stripPreview(this.firstFrame),
  164. lastFrame: stripPreview(this.lastFrame),
  165. resultVideoUrl: this.resultVideoUrl,
  166. resultWorkId: this.resultWorkId,
  167. referenceGeneratedFrameUrl: this.referenceGeneratedFrameUrl,
  168. referencePlan: this.referencePlan,
  169. };
  170. }
  171. private fromSnapshot(snap: I2vSnapshot): void {
  172. if (!snap) return;
  173. this.prompt = snap.prompt ?? '';
  174. this.mode = (snap.mode ?? '2') as I2vMode;
  175. this.quality = (snap.quality ?? '1080p') as I2vQuality;
  176. this.aspect = (snap.aspect ?? '16:9') as I2vAspect;
  177. this.durationSeconds = Math.max(3, Math.min(15, Math.round(Number(snap.durationSeconds ?? this.videoDuration.framesToSeconds(snap.frames ?? 121)) || 5)));
  178. this.frames = this.videoDuration.secondsToFrames(this.durationSeconds);
  179. this.cameraTemplate = snap.cameraTemplate ?? 'quick_pull_back';
  180. this.cameraStrength = snap.cameraStrength ?? 'medium';
  181. // 恢复首/尾帧:preview 丢失(本地预览 URL),调用方仅依赖 url 即可继续生成
  182. this.firstFrame = snap.firstFrame ? { ...snap.firstFrame, preview: snap.firstFrame.url } : null;
  183. this.lastFrame = snap.lastFrame ? { ...snap.lastFrame, preview: snap.lastFrame.url } : null;
  184. this.resultVideoUrl = snap.resultVideoUrl ?? '';
  185. this.resultWorkId = snap.resultWorkId ?? '';
  186. this.referenceGeneratedFrameUrl = snap.referenceGeneratedFrameUrl ?? '';
  187. this.referencePlan = snap.referencePlan ?? null;
  188. this.errorMsg = '';
  189. this.statusText = '';
  190. this.progress = 0;
  191. this.cdr.detectChanges();
  192. }
  193. private deriveTitle(): string {
  194. const t = (this.prompt || '').trim();
  195. if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t;
  196. const modeLabel = this.modeLabel();
  197. return `图生视频 · ${modeLabel}`;
  198. }
  199. syncDraft(): void {
  200. this.session.ensureActive('image-to-video', () => this.toSnapshot(), this.deriveTitle());
  201. const cur = this.session.active();
  202. const derived = this.deriveTitle();
  203. const isAutoTitle = !cur || !cur.title || cur.title === '未命名创作' || cur.title === derived;
  204. this.session.patch({
  205. snapshot: this.toSnapshot(),
  206. ...(isAutoTitle ? { title: derived } : {}),
  207. });
  208. }
  209. onNewSession(): void {
  210. if (this.generating) return;
  211. this.cancel();
  212. this.prompt = '';
  213. if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
  214. if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
  215. this.firstFrame = null;
  216. this.lastFrame = null;
  217. this.resultVideoUrl = '';
  218. this.resultWorkId = '';
  219. this.referenceGeneratedFrameUrl = '';
  220. this.referencePlan = null;
  221. this.referencePlanning = false;
  222. this.errorMsg = '';
  223. this.statusText = '';
  224. this.progress = 0;
  225. this.session.close();
  226. this.cdr.detectChanges();
  227. }
  228. async onOpenSession(meta: DraftMeta): Promise<void> {
  229. if (this.generating) {
  230. alert('当前任务运行中,请先取消或等待完成再切换');
  231. return;
  232. }
  233. const draft = await this.session.open(meta.id);
  234. if (draft?.snapshot) this.fromSnapshot(draft.snapshot as I2vSnapshot);
  235. }
  236. ngOnDestroy(): void {
  237. this.genSub?.unsubscribe();
  238. if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
  239. if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
  240. }
  241. // ===== 上传 =====
  242. triggerFilePick(slot: 'first' | 'last', input: HTMLInputElement): void {
  243. input.value = '';
  244. input.click();
  245. }
  246. onFilePicked(event: Event, slot: 'first' | 'last'): void {
  247. const input = event.target as HTMLInputElement;
  248. const file = input.files?.[0];
  249. if (!file) return;
  250. if (!file.type.startsWith('image/')) {
  251. this.uploadError = '请上传图片文件';
  252. return;
  253. }
  254. if (file.size > 10 * 1024 * 1024) {
  255. this.uploadError = '图片大小不能超过 10MB';
  256. return;
  257. }
  258. this.uploadError = '';
  259. this.uploadingSlot = slot;
  260. this.qiniuUpload.uploadFile(file, file.name, file.type, 'image', {
  261. sourceModule: 'image-to-video',
  262. bizType: 'reference-image',
  263. bizId: slot,
  264. }).subscribe({
  265. next: (url: string) => {
  266. if (!url) {
  267. this.uploadError = '上传失败:未返回 URL';
  268. this.uploadingSlot = null;
  269. this.cdr.detectChanges();
  270. return;
  271. }
  272. const previewUrl = URL.createObjectURL(file);
  273. const img: I2vUploadedImage = {
  274. url,
  275. name: file.name,
  276. size: file.size,
  277. preview: previewUrl,
  278. };
  279. if (slot === 'first') {
  280. if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
  281. this.firstFrame = img;
  282. } else {
  283. if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
  284. this.lastFrame = img;
  285. }
  286. this.uploadingSlot = null;
  287. this.cdr.detectChanges();
  288. },
  289. error: (err) => {
  290. console.error('[i2v] upload error', err);
  291. this.uploadError = userFriendlyError(err, '图片上传失败,请检查网络后重试');
  292. this.uploadingSlot = null;
  293. this.cdr.detectChanges();
  294. },
  295. });
  296. }
  297. removeFrame(slot: 'first' | 'last'): void {
  298. if (slot === 'first') {
  299. if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
  300. this.firstFrame = null;
  301. } else {
  302. if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
  303. this.lastFrame = null;
  304. }
  305. }
  306. applyPreset(value: string): void {
  307. this.prompt = this.prompt
  308. ? `${this.prompt.replace(/\s+$/, '')},${value}`
  309. : value;
  310. }
  311. // ===== 生成 =====
  312. /** 当前质量与模式是否兼容 */
  313. get qualityCompatible(): boolean {
  314. if (this.mode === '3' && this.quality === 'pro') return false;
  315. if (this.mode === '4' && this.quality !== '720p') return false;
  316. return true;
  317. }
  318. /** 切换模式时自动修正不兼容的质量 */
  319. setMode(m: I2vMode): void {
  320. this.mode = m;
  321. if (m === '3' && this.quality === 'pro') this.quality = '1080p';
  322. if (m === '4') this.quality = '720p';
  323. if (m === 'reference') {
  324. this.lastFrame = null;
  325. this.referenceGeneratedFrameUrl = '';
  326. this.referencePlan = null;
  327. }
  328. this.syncDraft();
  329. }
  330. setQuality(q: I2vQuality): void {
  331. // 反向保护:如果选择了不兼容的质量,回到首帧模式
  332. if (this.mode === '4' && q !== '720p') return;
  333. if (this.mode === '3' && q === 'pro') return;
  334. this.quality = q;
  335. }
  336. setDurationSeconds(seconds: number): void {
  337. this.durationSeconds = Math.max(3, Math.min(15, Math.round(Number(seconds) || 5)));
  338. this.frames = this.videoDuration.secondsToFrames(this.durationSeconds);
  339. this.syncDraft();
  340. }
  341. get canGenerate(): boolean {
  342. if (this.generating) return false;
  343. // 运镜模式提示词可选;其它模式必填
  344. if (this.mode !== '4' && !this.prompt.trim()) return false;
  345. if (!this.firstFrame) return false;
  346. if (this.mode === '3' && !this.lastFrame) return false;
  347. if (this.mode === '4' && !this.cameraTemplate) return false;
  348. return this.qualityCompatible;
  349. }
  350. generate(): void {
  351. if (!this.canGenerate) return;
  352. this.generating = true;
  353. this.progress = 0;
  354. this.statusText = '正在提交任务...';
  355. this.errorMsg = '';
  356. this.resultVideoUrl = '';
  357. this.resultWorkId = '';
  358. this.referenceGeneratedFrameUrl = '';
  359. this.referencePlan = null;
  360. this.referencePlanning = false;
  361. const estimate = this.costEstimator.estimateImageToVideo(this.quality, this.durationSeconds);
  362. const task = this.generationTasks.create({
  363. title: this.deriveTitle(),
  364. pipelineId: 'image-to-video',
  365. operation: estimate.operation,
  366. estimatedCredits: estimate.totalCredits,
  367. costLines: estimate.lines,
  368. snapshot: this.toSnapshot(),
  369. steps: [
  370. { id: 'prepare', label: '准备素材' },
  371. { id: 'submit', label: '提交任务' },
  372. { id: 'poll', label: '等待生成' },
  373. { id: 'archive', label: '归档结果' },
  374. ],
  375. });
  376. this.generationTaskId = task.id;
  377. this.generationTasks.markRunning(task.id, 'submit', 5);
  378. // 会话:标记运行中
  379. this.session.ensureActive('image-to-video', () => this.toSnapshot(), this.deriveTitle());
  380. this.session.markRunning();
  381. this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
  382. const mode = this.mode;
  383. if (mode === 'reference') {
  384. this.runReferenceGeneration();
  385. return;
  386. }
  387. const imageUrls =
  388. mode === '3'
  389. ? [this.firstFrame!.url, this.lastFrame!.url]
  390. : [this.firstFrame!.url];
  391. this.genSub = this.jimeng
  392. .generateImageToVideo(
  393. {
  394. imageUrls,
  395. prompt: this.prompt.trim(),
  396. method: mode,
  397. quality: this.quality,
  398. frames: this.frames,
  399. aspectRatio: this.aspect,
  400. cameraTemplate: mode === '4' ? this.cameraTemplate : undefined,
  401. cameraStrength: mode === '4' ? this.cameraStrength : undefined,
  402. },
  403. (status: string, progress: number, meta?: Record<string, any>) => {
  404. this.zone.run(() => {
  405. if (meta?.['workId']) {
  406. this.generationTasks.markWaitingExternal(
  407. this.generationTaskId,
  408. { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') },
  409. 'poll',
  410. Math.round(progress),
  411. );
  412. } else {
  413. this.generationTasks.markRunning(this.generationTaskId, progress >= 15 ? 'poll' : 'submit', Math.round(progress));
  414. }
  415. this.statusText = status;
  416. this.progress = Math.round(progress);
  417. this.cdr.detectChanges();
  418. });
  419. },
  420. )
  421. .subscribe({
  422. next: (result) => {
  423. this.zone.run(() => this.handleGenerationSuccess(result.videoUrl, result.workId));
  424. },
  425. error: (err) => {
  426. this.zone.run(() => this.handleGenerationFailure(err));
  427. },
  428. });
  429. }
  430. private runReferenceGeneration(): void {
  431. const referenceUrl = this.firstFrame!.url;
  432. const size = I2V_REFERENCE_ASPECT_SIZE[this.aspect] || I2V_REFERENCE_ASPECT_SIZE['16:9'];
  433. this.referencePlanning = true;
  434. this.referenceGeneratedFrameUrl = '';
  435. this.referencePlan = null;
  436. this.statusText = '正在分析参考图片并准备创意画面...';
  437. this.progress = 5;
  438. this.genSub = this.referencePlanner.plan({
  439. referenceImageUrl: referenceUrl,
  440. userPrompt: this.prompt.trim(),
  441. aspect: this.aspect,
  442. }).pipe(
  443. switchMap((plan) => {
  444. this.referencePlan = plan;
  445. this.referencePlanning = false;
  446. this.generationTasks.markRunning(this.generationTaskId, 'submit', 20);
  447. return this.jimeng.generateImgV4(
  448. plan.imagePrompt,
  449. [referenceUrl],
  450. size,
  451. (status, progress) => this.zone.run(() => {
  452. this.statusText = this.referenceImageStatus(status, progress);
  453. this.progress = Math.min(45, 10 + Math.round(progress * 0.35));
  454. this.cdr.detectChanges();
  455. }),
  456. { scale: 0.55, forceSingle: true },
  457. ).pipe(map((generatedFrameUrl) => ({ plan, generatedFrameUrl })));
  458. }),
  459. switchMap(({ plan, generatedFrameUrl }) => {
  460. this.referenceGeneratedFrameUrl = generatedFrameUrl;
  461. this.session.addArtifact({
  462. type: 'image',
  463. url: generatedFrameUrl,
  464. title: '创意画面',
  465. extras: {
  466. kind: 'reference-generated-frame',
  467. imagePrompt: plan.imagePrompt,
  468. videoPrompt: plan.videoPrompt,
  469. referenceImageUrl: referenceUrl,
  470. source: plan.source,
  471. },
  472. });
  473. this.session.patch({ snapshot: this.toSnapshot(), thumbnail: generatedFrameUrl });
  474. return this.jimeng.generateImageToVideo(
  475. {
  476. imageUrls: [generatedFrameUrl],
  477. prompt: plan.videoPrompt,
  478. method: '2',
  479. quality: this.quality,
  480. frames: this.frames,
  481. aspectRatio: this.aspect,
  482. },
  483. (status: string, progress: number, meta?: Record<string, any>) => {
  484. this.zone.run(() => {
  485. const mappedProgress = Math.min(98, 45 + Math.round(progress * 0.53));
  486. if (meta?.['workId']) {
  487. this.generationTasks.markWaitingExternal(
  488. this.generationTaskId,
  489. { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') },
  490. 'poll',
  491. mappedProgress,
  492. );
  493. } else {
  494. this.generationTasks.markRunning(this.generationTaskId, 'poll', mappedProgress);
  495. }
  496. this.statusText = this.referenceVideoStatus(status);
  497. this.progress = mappedProgress;
  498. this.cdr.detectChanges();
  499. });
  500. },
  501. );
  502. }),
  503. ).subscribe({
  504. next: (result) => this.zone.run(() => this.handleGenerationSuccess(result.videoUrl, result.workId)),
  505. error: (err) => this.zone.run(() => this.handleGenerationFailure(err)),
  506. });
  507. }
  508. private handleGenerationSuccess(videoUrl: string, workId: string): void {
  509. this.resultVideoUrl = videoUrl;
  510. this.resultWorkId = workId;
  511. this.progress = 100;
  512. this.statusText = '生成完成!';
  513. this.generating = false;
  514. this.referencePlanning = false;
  515. this.cdr.detectChanges();
  516. const modeLabel = this.modeLabel();
  517. const extras = {
  518. workId,
  519. mode: this.mode,
  520. aspect: this.aspect,
  521. referenceGeneratedFrameUrl: this.referenceGeneratedFrameUrl,
  522. referencePlan: this.referencePlan,
  523. };
  524. this.session.patch({ snapshot: this.toSnapshot() });
  525. this.session.finalize(videoUrl, extras);
  526. this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100);
  527. this.generationTasks.markCompleted(this.generationTaskId, videoUrl);
  528. this.results.saveResult({
  529. type: 'video',
  530. url: videoUrl,
  531. title: `图生视频-${modeLabel}-${this.quality.toUpperCase()}`,
  532. quality: this.quality,
  533. duration: `${this.durationSeconds}s`,
  534. pipelineId: 'image_to_video',
  535. extras,
  536. }).subscribe();
  537. }
  538. private handleGenerationFailure(err: unknown): void {
  539. console.error('[i2v] generate error', err);
  540. this.errorMsg = userFriendlyError(err, '视频生成失败,请稍后重试');
  541. this.statusText = '';
  542. this.generating = false;
  543. this.referencePlanning = false;
  544. this.cdr.detectChanges();
  545. this.session.fail(this.errorMsg);
  546. this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true });
  547. }
  548. private modeLabel(): string {
  549. return this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : this.mode === '4' ? '运镜' : '参考图生成';
  550. }
  551. private referenceImageStatus(_status: string, progress: number): string {
  552. return progress >= 100 ? '创意画面已准备,正在生成视频...' : '正在生成创意画面,请稍候...';
  553. }
  554. private referenceVideoStatus(status: string): string {
  555. if (status === 'external-task-id') return '视频已进入生成队列...';
  556. if (/提交|任务/i.test(status)) return '视频已进入生成队列,正在生成...';
  557. return status || '正在生成视频...';
  558. }
  559. cancel(): void {
  560. this.genSub?.unsubscribe();
  561. this.generating = false;
  562. this.statusText = '已取消';
  563. this.progress = 0;
  564. }
  565. reset(): void {
  566. this.cancel();
  567. this.resultVideoUrl = '';
  568. this.resultWorkId = '';
  569. this.errorMsg = '';
  570. this.statusText = '';
  571. this.progress = 0;
  572. this.referenceGeneratedFrameUrl = '';
  573. this.referencePlan = null;
  574. this.referencePlanning = false;
  575. }
  576. formatSize(bytes: number): string {
  577. if (bytes < 1024) return `${bytes} B`;
  578. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  579. return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
  580. }
  581. }