image-to-video.component.ts 17 KB

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