action-transfer.component.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  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 { JimengService } from '../../../services/jimeng.service';
  6. import { ResultsService } from '../../../services/results.service';
  7. import { PipelineSessionService } from '../../../services/pipeline-session.service';
  8. import { QiniuUploadService } from '../../../services/qiniu-upload.service';
  9. import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
  10. import { DraftMeta } from '../../../models/pipeline-draft.model';
  11. import { userFriendlyError } from '../../../services/user-message.util';
  12. import { CostEstimatorService } from '../../../services/cost-estimator.service';
  13. import { GenerationTaskService } from '../../../services/generation-task.service';
  14. interface UploadedAsset {
  15. url: string;
  16. name: string;
  17. size: number;
  18. preview: string;
  19. type: 'image' | 'video';
  20. }
  21. /** 会话快照:上传后的七牛云 url 可永久化,本地 preview 不存 */
  22. interface AtSnapshot {
  23. characterImage: { url: string; name: string; size: number } | null;
  24. referenceVideo: { url: string; name: string; size: number } | null;
  25. cutFirstSecond: boolean;
  26. resultVideoUrl: string;
  27. resultWorkId: string;
  28. }
  29. /**
  30. * P2: 动作迁移 Pipeline
  31. *
  32. * 调用即梦 getActorV2:
  33. * image_url + video_url → 角色图按参考视频动作驱动起来。
  34. *
  35. * 上传走七牛云直传(云函数签发 token,支持图片/视频)。
  36. */
  37. @Component({
  38. selector: 'app-action-transfer',
  39. standalone: true,
  40. imports: [CommonModule, FormsModule, SessionBarComponent],
  41. templateUrl: './action-transfer.component.html',
  42. styleUrls: ['./action-transfer.component.css'],
  43. })
  44. export class ActionTransferComponent implements OnInit, OnDestroy {
  45. characterImage: UploadedAsset | null = null;
  46. referenceVideo: UploadedAsset | null = null;
  47. /** 是否裁剪结果视频的第 1 秒(即梦默认 true,可关闭) */
  48. cutFirstSecond = true;
  49. uploadingSlot: 'image' | 'video' | null = null;
  50. uploadError = '';
  51. uploadProgress = 0;
  52. generating = false;
  53. progress = 0;
  54. statusText = '';
  55. resultVideoUrl = '';
  56. resultWorkId = '';
  57. errorMsg = '';
  58. private genSub?: Subscription;
  59. private generationTaskId = '';
  60. constructor(
  61. private qiniuUpload: QiniuUploadService,
  62. private jimeng: JimengService,
  63. private cdr: ChangeDetectorRef,
  64. private zone: NgZone,
  65. private results: ResultsService,
  66. public session: PipelineSessionService,
  67. private generationTasks: GenerationTaskService,
  68. private costEstimator: CostEstimatorService,
  69. ) {}
  70. ngOnInit(): void {
  71. void this.session.bootstrap();
  72. const cur = this.session.active();
  73. if (cur && cur.pipelineId === 'action-transfer' && cur.snapshot) {
  74. this.fromSnapshot(cur.snapshot as AtSnapshot);
  75. }
  76. }
  77. // ====== Session ======
  78. private toSnapshot(): AtSnapshot {
  79. const stripPreview = (a: UploadedAsset | null) =>
  80. a ? { url: a.url, name: a.name, size: a.size } : null;
  81. return {
  82. characterImage: stripPreview(this.characterImage),
  83. referenceVideo: stripPreview(this.referenceVideo),
  84. cutFirstSecond: this.cutFirstSecond,
  85. resultVideoUrl: this.resultVideoUrl,
  86. resultWorkId: this.resultWorkId,
  87. };
  88. }
  89. private fromSnapshot(snap: AtSnapshot): void {
  90. if (!snap) return;
  91. this.characterImage = snap.characterImage
  92. ? { ...snap.characterImage, preview: snap.characterImage.url, type: 'image' as const }
  93. : null;
  94. this.referenceVideo = snap.referenceVideo
  95. ? { ...snap.referenceVideo, preview: snap.referenceVideo.url, type: 'video' as const }
  96. : null;
  97. this.cutFirstSecond = snap.cutFirstSecond ?? true;
  98. this.resultVideoUrl = snap.resultVideoUrl ?? '';
  99. this.resultWorkId = snap.resultWorkId ?? '';
  100. this.errorMsg = '';
  101. this.statusText = '';
  102. this.progress = 0;
  103. this.cdr.detectChanges();
  104. }
  105. private deriveTitle(): string {
  106. if (this.resultWorkId) return `动作迁移-${this.resultWorkId}`;
  107. if (this.characterImage?.name) return `动作迁移 · ${this.characterImage.name}`;
  108. return '动作迁移 · 未命名';
  109. }
  110. syncDraft(): void {
  111. this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle());
  112. const cur = this.session.active();
  113. const derived = this.deriveTitle();
  114. const isAutoTitle = !cur || !cur.title || cur.title === '未命名创作' || cur.title === derived;
  115. this.session.patch({
  116. snapshot: this.toSnapshot(),
  117. ...(isAutoTitle ? { title: derived } : {}),
  118. });
  119. }
  120. onNewSession(): void {
  121. if (this.generating) return;
  122. this.cancel();
  123. if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
  124. if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview);
  125. this.characterImage = null;
  126. this.referenceVideo = null;
  127. this.cutFirstSecond = true;
  128. this.resultVideoUrl = '';
  129. this.resultWorkId = '';
  130. this.errorMsg = '';
  131. this.statusText = '';
  132. this.progress = 0;
  133. this.session.close();
  134. this.cdr.detectChanges();
  135. }
  136. async onOpenSession(meta: DraftMeta): Promise<void> {
  137. if (this.generating) {
  138. alert('当前任务运行中,请先取消或等待完成再切换');
  139. return;
  140. }
  141. const draft = await this.session.open(meta.id);
  142. if (draft?.snapshot) this.fromSnapshot(draft.snapshot as AtSnapshot);
  143. }
  144. ngOnDestroy(): void {
  145. this.genSub?.unsubscribe();
  146. if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
  147. if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview);
  148. }
  149. triggerPick(slot: 'image' | 'video', input: HTMLInputElement): void {
  150. input.value = '';
  151. input.click();
  152. }
  153. onFilePicked(event: Event, slot: 'image' | 'video'): void {
  154. const input = event.target as HTMLInputElement;
  155. const file = input.files?.[0];
  156. if (!file) return;
  157. if (slot === 'image' && !file.type.startsWith('image/')) {
  158. this.uploadError = '角色素材需要图片文件';
  159. return;
  160. }
  161. if (slot === 'video' && !file.type.startsWith('video/')) {
  162. this.uploadError = '参考素材需要视频文件';
  163. return;
  164. }
  165. const limitMb = slot === 'video' ? 200 : 10;
  166. if (file.size > limitMb * 1024 * 1024) {
  167. this.uploadError = `${slot === 'video' ? '视频' : '图片'}大小不能超过 ${limitMb}MB`;
  168. return;
  169. }
  170. this.uploadError = '';
  171. this.uploadingSlot = slot;
  172. this.uploadProgress = 0;
  173. this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, slot === 'image' ? 'image' : 'video').subscribe({
  174. next: (event) => {
  175. if (event.state === 'progress') {
  176. this.uploadProgress = event.progress;
  177. this.cdr.detectChanges();
  178. return;
  179. }
  180. const url = event.url || '';
  181. if (!url) {
  182. this.uploadError = '上传失败,请检查网络后重试';
  183. this.uploadingSlot = null;
  184. this.cdr.detectChanges();
  185. return;
  186. }
  187. const previewUrl = URL.createObjectURL(file);
  188. const asset: UploadedAsset = {
  189. url,
  190. name: file.name,
  191. size: file.size,
  192. preview: previewUrl,
  193. type: slot === 'image' ? 'image' : 'video',
  194. };
  195. if (slot === 'image') {
  196. if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
  197. this.characterImage = asset;
  198. } else {
  199. if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview);
  200. this.referenceVideo = asset;
  201. }
  202. this.uploadingSlot = null;
  203. this.uploadProgress = 0;
  204. this.cdr.detectChanges();
  205. this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle());
  206. this.session.upsertArtifact(
  207. (a) => a.extras?.['slot'] === slot,
  208. {
  209. type: slot === 'image' ? 'image' : 'video',
  210. url: asset.url,
  211. title: slot === 'image' ? '角色图片(上传)' : '参考动作视频(上传)',
  212. extras: { slot },
  213. },
  214. );
  215. this.session.patch({ snapshot: this.toSnapshot() });
  216. },
  217. error: (err) => {
  218. this.uploadError = userFriendlyError(err, '素材上传失败,请检查网络后重试');
  219. this.uploadingSlot = null;
  220. this.cdr.detectChanges();
  221. },
  222. });
  223. }
  224. removeAsset(slot: 'image' | 'video'): void {
  225. if (slot === 'image') {
  226. if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
  227. this.characterImage = null;
  228. } else {
  229. if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview);
  230. this.referenceVideo = null;
  231. }
  232. }
  233. get canGenerate(): boolean {
  234. return !!this.characterImage && !!this.referenceVideo && !this.generating;
  235. }
  236. generate(): void {
  237. if (!this.canGenerate) return;
  238. this.generating = true;
  239. this.progress = 0;
  240. this.statusText = '正在提交任务...';
  241. this.errorMsg = '';
  242. this.resultVideoUrl = '';
  243. this.resultWorkId = '';
  244. const estimate = this.costEstimator.estimateActionTransfer();
  245. const task = this.generationTasks.create({
  246. title: this.deriveTitle(),
  247. pipelineId: 'action-transfer',
  248. operation: estimate.operation,
  249. estimatedCredits: estimate.totalCredits,
  250. costLines: estimate.lines,
  251. snapshot: this.toSnapshot(),
  252. steps: [
  253. { id: 'prepare', label: '准备素材' },
  254. { id: 'submit', label: '提交任务' },
  255. { id: 'poll', label: '等待生成' },
  256. { id: 'archive', label: '归档结果' },
  257. ],
  258. });
  259. this.generationTaskId = task.id;
  260. this.generationTasks.markRunning(task.id, 'submit', 5);
  261. this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle());
  262. this.session.markRunning();
  263. this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
  264. this.genSub = this.jimeng
  265. .actionTransfer(
  266. this.characterImage!.url,
  267. this.referenceVideo!.url,
  268. { cutFirstSecond: this.cutFirstSecond },
  269. (status: string, p: number, meta?: Record<string, any>) => {
  270. this.zone.run(() => {
  271. if (meta?.['workId']) {
  272. this.generationTasks.markWaitingExternal(
  273. this.generationTaskId,
  274. { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') },
  275. 'poll',
  276. Math.round(p),
  277. );
  278. } else {
  279. this.generationTasks.markRunning(this.generationTaskId, p >= 15 ? 'poll' : 'submit', Math.round(p));
  280. }
  281. this.statusText = status;
  282. this.progress = Math.round(p);
  283. this.cdr.detectChanges();
  284. });
  285. },
  286. )
  287. .subscribe({
  288. next: (result) => {
  289. this.zone.run(() => {
  290. this.resultVideoUrl = result.videoUrl;
  291. this.resultWorkId = result.workId;
  292. this.progress = 100;
  293. this.statusText = '动作迁移完成!';
  294. this.generating = false;
  295. this.cdr.detectChanges();
  296. const extras = { workId: result.workId, cutFirstSecond: this.cutFirstSecond };
  297. this.session.patch({ snapshot: this.toSnapshot() });
  298. this.session.finalize(result.videoUrl, extras);
  299. this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100);
  300. this.generationTasks.markCompleted(this.generationTaskId, result.videoUrl);
  301. this.results.saveResult({
  302. type: 'video',
  303. url: result.videoUrl,
  304. title: `动作迁移-${result.workId}`,
  305. pipelineId: 'action_transfer',
  306. extras,
  307. }).subscribe();
  308. });
  309. },
  310. error: (err) => {
  311. this.zone.run(() => {
  312. console.error('[action-transfer] error', err);
  313. this.errorMsg = userFriendlyError(err, '动作视频生成失败,请稍后重试');
  314. this.statusText = '';
  315. this.generating = false;
  316. this.cdr.detectChanges();
  317. this.session.fail(this.errorMsg);
  318. this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true });
  319. });
  320. },
  321. });
  322. }
  323. cancel(): void {
  324. this.genSub?.unsubscribe();
  325. this.generating = false;
  326. this.statusText = '已取消';
  327. this.progress = 0;
  328. }
  329. reset(): void {
  330. this.cancel();
  331. this.resultVideoUrl = '';
  332. this.resultWorkId = '';
  333. this.errorMsg = '';
  334. this.statusText = '';
  335. this.progress = 0;
  336. }
  337. formatSize(bytes: number): string {
  338. if (bytes < 1024) return `${bytes} B`;
  339. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  340. return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
  341. }
  342. }