topic-to-video.component.ts 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013
  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, of } from 'rxjs';
  5. import { catchError, concatMap, map, toArray } from 'rxjs/operators';
  6. import { LlmService } from '../../../services/llm.service';
  7. import {
  8. JimengService,
  9. JimengAspectRatio,
  10. JimengVideoQuality,
  11. } from '../../../services/jimeng.service';
  12. import {
  13. composeVideoStream,
  14. CompositeHandle,
  15. CompositeSegmentInput,
  16. } from '../../../pipelines/composite-sse';
  17. import { ResultsService } from '../../../services/results.service';
  18. interface SceneSegment {
  19. /** 画面描述(英文 prompt);快速模式由 LLM 生成,手动模式由用户填入 */
  20. scenePrompt: string;
  21. /** 中文解说 / 备注(可选,TTS 已暂停,仅作未来字幕用途占位) */
  22. narration?: string;
  23. /** 首帧图片(必填) */
  24. imageUrl?: string;
  25. /** 首帧来源:ai 生成 / 用户上传。默认 ai */
  26. imageSource?: 'ai' | 'upload';
  27. /** 尾帧图片(仅手动模式 + 首尾帧 i2v 用) */
  28. tailImageUrl?: string;
  29. /** 尾帧来源 */
  30. tailImageSource?: 'ai' | 'upload';
  31. /**
  32. * 手动模式:视频生成模式
  33. * - 'first' : 仅首帧(即梦 method=2,1 张图)
  34. * - 'first-last' : 首尾帧(即梦 method=3,2 张图,更平滑)
  35. *
  36. * 快速模式恒为 'first'。
  37. */
  38. videoMode?: 'first' | 'first-last';
  39. /** 即梦图生视频结果 */
  40. videoUrl?: string;
  41. state: 'idle'
  42. | 'image-pending' | 'image-done' | 'image-failed'
  43. | 'tail-pending' | 'tail-done' | 'tail-failed'
  44. | 'video-pending' | 'video-done' | 'video-failed';
  45. /** 当前 pending 操作针对的位(用于 UI 显示局部 spinner) */
  46. pendingTarget?: 'head' | 'tail' | 'video';
  47. error?: string;
  48. }
  49. type T2vStyle = 'cinematic' | 'anime' | 'realistic' | 'illustration' | 'documentary';
  50. type T2vAspect = 'landscape' | 'portrait' | 'square';
  51. /** 画面类型:静态图 / 动态视频。仅在快速模式下有意义 */
  52. type T2vMediaMode = 'image' | 'video';
  53. /**
  54. * P4: 主题生视频 Pipeline
  55. *
  56. * 一句话主题 →
  57. * 1. LLM 生成 N 段分镜(scene_prompt + narration)
  58. * 2. 即梦 jimeng-img-v4 为每段生成图片
  59. * 3. 火山 TTS 为每段解说生成音频
  60. * 4. /api/video/composite 合成成片
  61. */
  62. @Component({
  63. selector: 'app-topic-to-video',
  64. standalone: true,
  65. imports: [CommonModule, FormsModule],
  66. templateUrl: './topic-to-video.component.html',
  67. styleUrls: ['./topic-to-video.component.css'],
  68. })
  69. export class TopicToVideoComponent implements OnInit, OnDestroy {
  70. // ====== 输入 ======
  71. topic = '';
  72. nScenes: 3 | 4 | 5 | 6 | 8 = 5;
  73. style: T2vStyle = 'cinematic';
  74. aspect: T2vAspect = 'landscape';
  75. // ===== 模式 =====
  76. /**
  77. * 快速模式(默认开):输入主题 → LLM 拆分镜 → 批量生图→(可选 i2v)→ 拼接。
  78. * 手动模式(OFF):用户逐卡填 prompt、逐卡点「生成图片」「生成视频」。
  79. */
  80. quickMode = true;
  81. // ===== 画面类型(仅快速模式生效)=====
  82. /** 静态图 = 仅文生图;动态视频 = 文生图 + 图生视频 */
  83. mediaMode: T2vMediaMode = 'image';
  84. /** 动态视频每段时长:121 ⁈5s,241 ⁈10s */
  85. clipFrames: 121 | 241 = 121;
  86. /** 动态视频质量:720p / 1080p */
  87. clipQuality: Extract<JimengVideoQuality, '720p' | '1080p'> = '720p';
  88. /** 手动模式下静态图单帧默认停留秒数(D 态) */
  89. private static readonly STILL_IMAGE_DURATION = 3;
  90. // ====== 分镜 ======
  91. scenes: SceneSegment[] = [];
  92. // ====== 阶段状态 ======
  93. stage: 'idle' | 'scripting' | 'imaging' | 'videoing' | 'composing' | 'done' | 'failed' = 'idle';
  94. stageStatus = '';
  95. scriptError = '';
  96. errorMsg = '';
  97. // 进度
  98. imageProgress = 0;
  99. videoProgress = 0;
  100. composeProgress = 0;
  101. resultVideoUrl = '';
  102. private genSub?: Subscription;
  103. readonly stylePresets: { value: T2vStyle; label: string; promptHint: string }[] = [
  104. { value: 'cinematic', label: '电影感', promptHint: 'cinematic lighting, film grain, anamorphic' },
  105. { value: 'realistic', label: '写实', promptHint: 'photorealistic, ultra detailed, 4k' },
  106. { value: 'anime', label: '动漫', promptHint: 'anime style, vibrant colors, studio ghibli' },
  107. { value: 'illustration', label: '插画', promptHint: 'flat illustration, modern design' },
  108. { value: 'documentary', label: '纪录片', promptHint: 'documentary photography, natural light' },
  109. ];
  110. readonly aspectPresets: { value: T2vAspect; label: string; w: number; h: number }[] = [
  111. { value: 'landscape', label: '横屏 16:9', w: 1920, h: 1080 },
  112. { value: 'portrait', label: '竖屏 9:16', w: 1080, h: 1920 },
  113. { value: 'square', label: '方屏 1:1', w: 1440, h: 1440 },
  114. ];
  115. constructor(
  116. private llm: LlmService,
  117. private jimeng: JimengService,
  118. private cdr: ChangeDetectorRef,
  119. private zone: NgZone,
  120. private results: ResultsService,
  121. ) {}
  122. ngOnInit(): void {
  123. // 从首屏跳转:取出预填主题(onHomeNavigate 写入 sessionStorage)
  124. try {
  125. const prefill = sessionStorage.getItem('t2v.prefillTopic');
  126. if (prefill) {
  127. this.topic = prefill;
  128. sessionStorage.removeItem('t2v.prefillTopic');
  129. }
  130. } catch {}
  131. }
  132. ngOnDestroy(): void {
  133. this.genSub?.unsubscribe();
  134. }
  135. // ============== 工具 ==============
  136. get isRunning(): boolean {
  137. return this.stage !== 'idle' && this.stage !== 'done' && this.stage !== 'failed';
  138. }
  139. /**
  140. * 能否启动(右栏 CTA)。
  141. * - 快速模式:主题至少 2 个字
  142. * - 手动模式:至少 1 个分镜,且所有分镜都出了画面(image-done / video-done)
  143. */
  144. get canStart(): boolean {
  145. if (this.isRunning) return false;
  146. if (this.quickMode) {
  147. return this.topic.trim().length >= 2;
  148. }
  149. if (this.scenes.length === 0) return false;
  150. return this.scenes.every((s) =>
  151. s.state === 'image-done' || s.state === 'video-done'
  152. );
  153. }
  154. /** 是否有任何单段任务运行中(手动模式逐卡按钮锁定用) */
  155. get hasInflightScene(): boolean {
  156. return this.scenes.some((s) =>
  157. s.state === 'image-pending' ||
  158. s.state === 'tail-pending' ||
  159. s.state === 'video-pending'
  160. );
  161. }
  162. /**
  163. * 总进度(右栏带状动画进度条)。
  164. * - 快速:script 5 / image 30 / (可选)video 25 / compose 40,跳过阶段权重并入 compose
  165. * - 手动:仅合成阶段有意义(前面是逐卡手点)
  166. */
  167. get totalProgress(): number {
  168. if (!this.quickMode) {
  169. return this.stage === 'composing' ? this.composeProgress : (this.stage === 'done' ? 100 : 0);
  170. }
  171. const SCRIPT_W = 5;
  172. const IMAGE_W = 30;
  173. const COMPOSE_W = 40;
  174. const videoW = this.mediaMode === 'video' ? 25 : 0;
  175. const remain = 100 - SCRIPT_W - IMAGE_W - videoW - COMPOSE_W;
  176. const composeW = COMPOSE_W + remain;
  177. const script = this.stage === 'idle' ? 0 : (this.stage === 'scripting' ? 2 : SCRIPT_W);
  178. const image = Math.round((this.imageProgress / 100) * IMAGE_W);
  179. const video = Math.round((this.videoProgress / 100) * videoW);
  180. const compose = Math.round((this.composeProgress / 100) * composeW);
  181. return Math.min(script + image + video + compose, 100);
  182. }
  183. private currentAspect() {
  184. return this.aspectPresets.find((a) => a.value === this.aspect)!;
  185. }
  186. // ============== 一键启动 ==============
  187. /**
  188. * 右栏 CTA 入口。按 quickMode 分两条路径:
  189. * - 快速:LLM 分镜 → 文生图 → (可选)图生视频 → ffmpeg
  190. * - 手动:用户已逐卡准备好画面/视频,只走 ffmpeg
  191. */
  192. start(): void {
  193. if (!this.canStart) return;
  194. if (this.quickMode) {
  195. this.reset(false);
  196. this.stage = 'scripting';
  197. this.stageStatus = '正在让 AI 拆分分镜...';
  198. this.scriptError = '';
  199. this.errorMsg = '';
  200. this.cdr.detectChanges();
  201. this.generateStoryboard()
  202. .then((scenes) => {
  203. this.zone.run(() => {
  204. this.scenes = scenes;
  205. this.cdr.detectChanges();
  206. });
  207. return this.generateImages();
  208. })
  209. .then(() => (this.mediaMode === 'video' ? this.generateClips() : Promise.resolve()))
  210. .then(() => this.composeFinalVideo())
  211. .catch((err) => this.handlePipelineError(err));
  212. } else {
  213. // 手动模式:跳过所有 AI 阶段,直接拼接
  214. this.errorMsg = '';
  215. this.composeProgress = 0;
  216. this.resultVideoUrl = '';
  217. this.cdr.detectChanges();
  218. this.composeFinalVideo().catch((err) => this.handlePipelineError(err));
  219. }
  220. }
  221. private handlePipelineError(err: any): void {
  222. console.error('[t2v] pipeline error', err);
  223. this.zone.run(() => {
  224. this.stage = 'failed';
  225. this.stageStatus = '';
  226. this.errorMsg = err?.message || '生成失败';
  227. this.cdr.detectChanges();
  228. });
  229. }
  230. // ============== Step 1: LLM 分镜 ==============
  231. private async generateStoryboard(): Promise<SceneSegment[]> {
  232. const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || '';
  233. // TTS 已暂停,narration 仅作未来字幕占位。仍按画面类型给一个字数范围提示,
  234. // 避免 LLM 输出过长影响分镜质量。
  235. let targetSecLow: number, targetSecHigh: number;
  236. if (this.mediaMode === 'video') {
  237. const clipSec = this.clipFrames === 121 ? 5 : 10;
  238. targetSecLow = Math.max(1, clipSec - 1.5);
  239. targetSecHigh = clipSec;
  240. } else {
  241. targetSecLow = 4;
  242. targetSecHigh = 7;
  243. }
  244. const charLow = Math.round(targetSecLow * 4.5);
  245. const charHigh = Math.round(targetSecHigh * 4.5);
  246. const narrationRule = `narration: 中文备注 ${charLow}-${charHigh} 字(临时用作字幕/记录,本版本暂不配音)`;
  247. // 按段数选叙事骨架(起承转合 / 钩子-主体-高潮-收束)
  248. const arc = this.buildNarrativeArc(this.nScenes);
  249. const prompt = `你是一名短视频导演兼分镜师。请根据主题输出 ${this.nScenes} 段分镜,
  250. **整支视频必须具有强连贯性**:单一主体贯穿,色调/光线/地点稳定,画面与解说前后承接。
  251. == 主题 ==
  252. ${this.topic.trim()}
  253. == 全局视觉锚点(每段都必须复用)==
  254. - 视觉风格:${styleHint}
  255. - 你需要先在脑内确定「主角/主体(subject)」+「主色调(palette,如 warm amber / cool teal)」+「场景地点(location)」+「光线时段(lighting,如 golden hour / overcast)」
  256. - 上述 4 项一旦确定,**全部 ${this.nScenes} 段的 scene_prompt 中都要明确包含同样的 subject / palette / lighting / location 关键词**,仅变化镜头与动作
  257. == 叙事骨架(按顺序对应每段位置)==
  258. ${arc}
  259. == 解说承接规则(仅 narration)==
  260. - 第 1 段:开场钩子,引出主体,不剧透结论
  261. - 中段:使用承接词「随后/紧接着/与此同时/直到/而」,保持时间或因果链
  262. - 末段:收束/反差/升华,回扣开场
  263. - **严禁段落独立**:禁止每段自成一个小故事;解说必须前后呼应,把整支视频读成一段完整文章
  264. == 字段格式 ==
  265. - scene_prompt: 英文 ≤30 词,必含 subject / palette / lighting / location 锚点 + 镜头语言(wide / close-up / tracking / low angle 等)${
  266. this.mediaMode === 'video' ? ',并加入轻微动作(slow zoom in / drift / wind / rising steam 等)以驱动 i2v' : ''
  267. }
  268. - ${narrationRule}
  269. == 输出 ==
  270. 严格 JSON 数组,仅 JSON 无 markdown 包裹:
  271. [{"scene_prompt": "...", "narration": "..."}, ...]
  272. 共 ${this.nScenes} 段。`;
  273. return new Promise<SceneSegment[]>((resolve, reject) => {
  274. this.genSub = this.llm.chat([{ role: 'user', content: prompt }]).subscribe({
  275. next: (res: any) => {
  276. const text = res?.choices?.[0]?.message?.content || '';
  277. const arr = this.extractJsonArray(text);
  278. if (!Array.isArray(arr) || arr.length === 0) {
  279. reject(new Error('AI 分镜解析失败:未识别到 JSON 数组'));
  280. return;
  281. }
  282. const scenes: SceneSegment[] = arr.slice(0, this.nScenes).map((item: any) => ({
  283. scenePrompt: String(item.scene_prompt || item.scenePrompt || '').trim(),
  284. narration: String(item.narration || '').trim(),
  285. state: 'idle',
  286. }));
  287. if (scenes.some((s) => !s.scenePrompt || !s.narration)) {
  288. reject(new Error('AI 分镜数据不完整,请重试'));
  289. return;
  290. }
  291. resolve(scenes);
  292. },
  293. error: (err) => reject(new Error(err?.message || 'AI 分镜失败')),
  294. });
  295. });
  296. }
  297. /**
  298. * 按段数返回叙事骨架文本,给 LLM 当结构提示。
  299. * 短视频常用三/四段式:钩子 → 推进 → 反转/收束。
  300. */
  301. private buildNarrativeArc(n: number): string {
  302. if (n === 3) {
  303. return [
  304. '段 1(钩子 Hook):抛出悬念或场景痛点,引出主体;不给答案',
  305. '段 2(推进 Build):主体行动 / 矛盾发展 / 细节放大',
  306. '段 3(收束 Payoff):揭晓结果、升华或反转,回扣开场',
  307. ].join('\n');
  308. }
  309. if (n === 4) {
  310. return [
  311. '段 1(钩子 Hook):开场抓人,引出主体与悬念',
  312. '段 2(铺垫 Setup):交代背景 / 引入矛盾或目标',
  313. '段 3(高潮 Climax):冲突或转折达到顶点',
  314. '段 4(收束 Payoff):反差 / 答案 / 主题升华,回扣开场',
  315. ].join('\n');
  316. }
  317. if (n === 5) {
  318. return [
  319. '段 1(钩子 Hook):3 秒内抓住注意力',
  320. '段 2(介绍 Intro):交代主体与场景',
  321. '段 3(铺垫 Build):层层递进的细节或动作',
  322. '段 4(高潮 Climax):最具张力的画面或转折',
  323. '段 5(收束 Payoff):余韵 / 升华,回扣开场',
  324. ].join('\n');
  325. }
  326. if (n === 6) {
  327. return [
  328. '段 1(钩子):开门见山的悬念',
  329. '段 2(介绍):明确主体与场景',
  330. '段 3-4(推进):两层递进;段 3 引矛盾,段 4 加码',
  331. '段 5(高潮):决定性瞬间',
  332. '段 6(收束):余韵 / 反差 / 主题点题',
  333. ].join('\n');
  334. }
  335. // n=8
  336. return [
  337. '段 1(钩子):极强的开场画面',
  338. '段 2(介绍):主体与世界观',
  339. '段 3-4(铺垫):两层细节或人物动机',
  340. '段 5-6(推进与高潮):从冲突升级到决定性瞬间',
  341. '段 7(转折):反差或结果揭晓',
  342. '段 8(收束):升华 / 余韵镜头,回扣开场',
  343. ].join('\n');
  344. }
  345. private extractJsonArray(text: string): any {
  346. if (!text) return null;
  347. const match = text.match(/\[[\s\S]*\]/);
  348. if (!match) return null;
  349. try {
  350. return JSON.parse(match[0]);
  351. } catch {
  352. return null;
  353. }
  354. }
  355. // ============== Step 2: 即梦文生图 ==============
  356. private generateImages(): Promise<void> {
  357. this.zone.run(() => {
  358. this.stage = 'imaging';
  359. this.stageStatus = '正在生成画面...';
  360. this.imageProgress = 0;
  361. this.cdr.detectChanges();
  362. });
  363. const { w, h } = this.currentAspect();
  364. const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || '';
  365. const total = this.scenes.length;
  366. let done = 0;
  367. return new Promise<void>((resolve, reject) => {
  368. this.genSub = from(this.scenes)
  369. .pipe(
  370. concatMap((scene, idx) => {
  371. scene.state = 'image-pending';
  372. this.zone.run(() => this.cdr.detectChanges());
  373. const fullPrompt = `${scene.scenePrompt}, ${styleHint}`;
  374. return this.jimeng.generateImgV4(fullPrompt, [], { width: w, height: h }).pipe(
  375. map((url: string) => {
  376. scene.imageUrl = url;
  377. scene.state = 'image-done';
  378. done += 1;
  379. this.zone.run(() => {
  380. this.imageProgress = Math.round((done / total) * 100);
  381. this.stageStatus = `已生成 ${done}/${total} 张画面`;
  382. this.cdr.detectChanges();
  383. });
  384. return scene;
  385. }),
  386. catchError((err) => {
  387. scene.state = 'image-failed';
  388. scene.error = err?.message || '图片生成失败';
  389. done += 1;
  390. this.zone.run(() => {
  391. this.imageProgress = Math.round((done / total) * 100);
  392. this.cdr.detectChanges();
  393. });
  394. return of(scene);
  395. }),
  396. );
  397. }),
  398. toArray(),
  399. )
  400. .subscribe({
  401. next: () => {
  402. const failed = this.scenes.filter((s) => s.state === 'image-failed').length;
  403. if (failed === total) {
  404. reject(new Error('所有画面生成失败,请检查即梦 token 余额或稍后重试'));
  405. return;
  406. }
  407. if (failed > 0) {
  408. console.warn(`[t2v] ${failed}/${total} 张画面失败,跳过失败段`);
  409. }
  410. this.scenes = this.scenes.filter((s) => s.state === 'image-done');
  411. resolve();
  412. },
  413. error: (err) => reject(err),
  414. });
  415. });
  416. }
  417. // ============== Step 2.5: 即梦图生视频(仅 mediaMode='video')==============
  418. /**
  419. * 为每一帧已生成的图片调用即梦 i2v,得到一段动态视频。
  420. * 采用「首帧」模式(method=2),带场景 prompt 驱动动态。
  421. * 任何一段失败 → 该段降级为静态图(保留 imageUrl,不设 videoUrl),
  422. * 不中断整个流程。
  423. */
  424. private generateClips(): Promise<void> {
  425. this.zone.run(() => {
  426. this.stage = 'videoing';
  427. this.stageStatus = '正在将画面生成动态视频...';
  428. this.videoProgress = 0;
  429. this.cdr.detectChanges();
  430. });
  431. const aspectRatio: JimengAspectRatio =
  432. this.aspect === 'landscape' ? '16:9' : this.aspect === 'portrait' ? '9:16' : '1:1';
  433. const total = this.scenes.length;
  434. let done = 0;
  435. return new Promise<void>((resolve, reject) => {
  436. this.genSub = from(this.scenes)
  437. .pipe(
  438. concatMap((scene) => {
  439. scene.state = 'video-pending';
  440. this.zone.run(() => this.cdr.detectChanges());
  441. return this.jimeng
  442. .generateImageToVideo({
  443. imageUrls: [scene.imageUrl!],
  444. prompt: scene.scenePrompt,
  445. method: '2', // 首帧
  446. quality: this.clipQuality,
  447. frames: this.clipFrames,
  448. aspectRatio,
  449. })
  450. .pipe(
  451. map((result) => {
  452. scene.videoUrl = result.videoUrl;
  453. scene.state = 'video-done';
  454. done += 1;
  455. this.zone.run(() => {
  456. this.videoProgress = Math.round((done / total) * 100);
  457. this.stageStatus = `已生成 ${done}/${total} 段动态视频`;
  458. this.cdr.detectChanges();
  459. });
  460. return scene;
  461. }),
  462. catchError((err) => {
  463. // 降级:该段仍用静态图,不报错
  464. scene.state = 'video-failed';
  465. scene.error = err?.message || '图生视频失败';
  466. done += 1;
  467. console.warn('[t2v] 该段图生视频失败,降级为静态图:', err);
  468. this.zone.run(() => {
  469. this.videoProgress = Math.round((done / total) * 100);
  470. this.cdr.detectChanges();
  471. });
  472. return of(scene);
  473. }),
  474. );
  475. }),
  476. toArray(),
  477. )
  478. .subscribe({
  479. next: () => {
  480. const ok = this.scenes.filter((s) => s.state === 'video-done').length;
  481. const fail = this.scenes.filter((s) => s.state === 'video-failed').length;
  482. if (ok === 0) {
  483. reject(new Error('所有分镜的图生视频均失败,请检查即梦额度或稍后重试'));
  484. return;
  485. }
  486. if (fail > 0) {
  487. console.warn(`[t2v] ${fail}/${total} 段图生视频失败,该段将使用静态图代替`);
  488. }
  489. resolve();
  490. },
  491. error: (err) => reject(err),
  492. });
  493. });
  494. }
  495. // ============== Step 4: ffmpeg 合成(SSE 实时进度)==============
  496. // (TTS 阶段已暂停,这里直接拼接)
  497. private compositeHandle?: CompositeHandle;
  498. private composeFinalVideo(): Promise<void> {
  499. this.zone.run(() => {
  500. this.stage = 'composing';
  501. this.stageStatus = '正在合成最终视频...';
  502. this.composeProgress = 0;
  503. this.cdr.detectChanges();
  504. });
  505. // TTS 已暂停,本版本仅产出两种后端态:
  506. // videoUrl 存在 → C 态(纯动态视频无声,按视频原长)
  507. // 仅 imageUrl → D 态(静态画面,按 STILL_IMAGE_DURATION 秒)
  508. const segments: CompositeSegmentInput[] = this.scenes
  509. .map((s, i) => {
  510. const seg: CompositeSegmentInput = { id: `t2v-${i}` };
  511. if (s.videoUrl) {
  512. seg.videoUrl = s.videoUrl;
  513. } else if (s.imageUrl) {
  514. seg.imageUrl = s.imageUrl;
  515. seg.duration = TopicToVideoComponent.STILL_IMAGE_DURATION;
  516. }
  517. return seg;
  518. })
  519. .filter((seg) => !!seg.imageUrl || !!seg.videoUrl);
  520. if (!segments.length) {
  521. return Promise.reject(new Error('没有可用的分镜段'));
  522. }
  523. return new Promise<void>((resolve, reject) => {
  524. this.compositeHandle = composeVideoStream({
  525. segments,
  526. title: this.topic.trim() || 'topic-to-video',
  527. onStage: (e) => {
  528. this.zone.run(() => {
  529. if (e.message) this.stageStatus = e.message;
  530. this.cdr.detectChanges();
  531. });
  532. },
  533. onProgress: (e) => {
  534. this.zone.run(() => {
  535. if (typeof e.percent === 'number') this.composeProgress = e.percent;
  536. if (e.message) this.stageStatus = e.message;
  537. this.cdr.detectChanges();
  538. });
  539. },
  540. });
  541. this.compositeHandle.promise
  542. .then((res) => {
  543. this.zone.run(() => {
  544. if (res?.success && res?.videoUrl) {
  545. this.resultVideoUrl = '/backend' + res.videoUrl;
  546. this.composeProgress = 100;
  547. this.stage = 'done';
  548. this.stageStatus = `生成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
  549. this.cdr.detectChanges();
  550. // 持久化
  551. this.results.saveResult({
  552. type: 'video',
  553. url: this.resultVideoUrl,
  554. title: this.topic.trim() || '主题生视频',
  555. pipelineId: 'topic_to_video',
  556. extras: {
  557. segments: res.segments,
  558. filename: res.filename,
  559. style: this.style,
  560. aspect: this.aspect,
  561. nScenes: this.nScenes,
  562. quickMode: this.quickMode,
  563. mediaMode: this.quickMode ? this.mediaMode : undefined,
  564. clipFrames: this.quickMode && this.mediaMode === 'video' ? this.clipFrames : undefined,
  565. clipQuality: this.quickMode && this.mediaMode === 'video' ? this.clipQuality : undefined,
  566. },
  567. }).subscribe();
  568. this.results.saveHistory({
  569. keyword: this.topic.trim() || '主题生视频',
  570. status: 'completed',
  571. resultUrl: this.resultVideoUrl,
  572. pipelineId: 'topic_to_video',
  573. styleName: this.style,
  574. }).subscribe();
  575. resolve();
  576. } else {
  577. reject(new Error('合成失败:服务端未返回视频'));
  578. }
  579. });
  580. })
  581. .catch((err) => {
  582. reject(new Error(err?.message || '合成失败'));
  583. });
  584. });
  585. }
  586. cancel(): void {
  587. this.genSub?.unsubscribe();
  588. this.compositeHandle?.abort();
  589. this.stage = 'idle';
  590. this.stageStatus = '已取消';
  591. }
  592. /**
  593. * 重置流水线状态。
  594. *
  595. * @param clearAll 是否清除结果视频 URL(快速模式 start 时为 false 保留上一次预览)
  596. * @param clearScenes 是否清空 scenes 数组。默认 true;手动模式下使用 reset(false, false)
  597. * 以保留用户逐卡准备的分镜。
  598. */
  599. reset(clearAll = true, clearScenes = true): void {
  600. this.genSub?.unsubscribe();
  601. if (clearScenes) this.scenes = [];
  602. this.stage = 'idle';
  603. this.stageStatus = '';
  604. this.scriptError = '';
  605. this.errorMsg = '';
  606. this.imageProgress = 0;
  607. this.videoProgress = 0;
  608. this.composeProgress = 0;
  609. if (clearAll) {
  610. this.resultVideoUrl = '';
  611. }
  612. }
  613. // ============== 手动模式:逐卡操作 ==============
  614. /** 添加一张空白分镜卡。手动模式可不限数量。 */
  615. addManualScene(): void {
  616. this.scenes = [
  617. ...this.scenes,
  618. {
  619. scenePrompt: '',
  620. narration: '',
  621. state: 'idle',
  622. videoMode: 'first', // 默认仅首帧
  623. imageSource: 'ai',
  624. tailImageSource: 'ai',
  625. },
  626. ];
  627. // 添加后清除上一次的完成态,重新进入可编辑状态
  628. if (this.stage === 'done') {
  629. this.stage = 'idle';
  630. this.resultVideoUrl = '';
  631. }
  632. this.cdr.detectChanges();
  633. }
  634. /**
  635. * 手动模式:切换该卡的视频生成模式(首帧 / 首尾帧)。
  636. * 切换会保留已生成的图,但清掉视频结果(避免与新模式不一致)。
  637. */
  638. setSceneVideoMode(index: number, mode: 'first' | 'first-last'): void {
  639. const scene = this.scenes[index];
  640. if (!scene) return;
  641. if (scene.videoMode === mode) return;
  642. if (scene.state === 'image-pending' || scene.state === 'tail-pending' || scene.state === 'video-pending') return;
  643. scene.videoMode = mode;
  644. // 清掉旧视频,回退到「已出图」状态(如果有图)
  645. if (scene.videoUrl) {
  646. scene.videoUrl = undefined;
  647. scene.state = scene.imageUrl ? 'image-done' : 'idle';
  648. }
  649. if (this.stage === 'done') {
  650. this.stage = 'idle';
  651. this.resultVideoUrl = '';
  652. }
  653. this.cdr.detectChanges();
  654. }
  655. /**
  656. * 手动模式:上传本地图片作为首帧或尾帧。
  657. *
  658. * @param target 'head' | 'tail'
  659. */
  660. uploadSceneImage(index: number, target: 'head' | 'tail', file: File): void {
  661. const scene = this.scenes[index];
  662. if (!scene || !file) return;
  663. if (!file.type.startsWith('image/')) {
  664. scene.error = '请选择图片文件(jpg / png / webp 等)';
  665. this.cdr.detectChanges();
  666. return;
  667. }
  668. scene.error = undefined;
  669. scene.pendingTarget = target;
  670. if (target === 'head') {
  671. scene.state = 'image-pending';
  672. scene.imageUrl = undefined;
  673. scene.videoUrl = undefined; // 首帧变了,清掉旧视频
  674. } else {
  675. scene.state = 'tail-pending';
  676. scene.tailImageUrl = undefined;
  677. scene.videoUrl = undefined;
  678. }
  679. if (this.stage === 'done') {
  680. this.stage = 'idle';
  681. this.resultVideoUrl = '';
  682. }
  683. this.cdr.detectChanges();
  684. this.jimeng.uploadFileToParse(
  685. file,
  686. `t2v-${target}-${Date.now()}-${file.name}`,
  687. file.type || 'image/jpeg',
  688. ).subscribe({
  689. next: (url: string) => {
  690. this.zone.run(() => {
  691. scene.pendingTarget = undefined;
  692. if (target === 'head') {
  693. scene.imageUrl = url;
  694. scene.imageSource = 'upload';
  695. scene.state = 'image-done';
  696. } else {
  697. scene.tailImageUrl = url;
  698. scene.tailImageSource = 'upload';
  699. scene.state = 'tail-done';
  700. }
  701. this.cdr.detectChanges();
  702. });
  703. },
  704. error: (err) => {
  705. this.zone.run(() => {
  706. scene.pendingTarget = undefined;
  707. scene.state = target === 'head' ? 'image-failed' : 'tail-failed';
  708. scene.error = err?.message || '上传失败';
  709. this.cdr.detectChanges();
  710. });
  711. },
  712. });
  713. }
  714. /**
  715. * 手动模式:AI 生成尾帧(即梦文生图)。
  716. * 使用 scene.scenePrompt 作为输入,加 "ending frame, final shot of scene" 提示,
  717. * 让画面与首帧保持一致风格但作为「场景尾」。
  718. */
  719. regenerateTailImage(index: number): void {
  720. const scene = this.scenes[index];
  721. if (!scene) return;
  722. const prompt = scene.scenePrompt.trim();
  723. if (!prompt) {
  724. scene.error = '请先填写画面描述';
  725. this.cdr.detectChanges();
  726. return;
  727. }
  728. scene.state = 'tail-pending';
  729. scene.pendingTarget = 'tail';
  730. scene.tailImageUrl = undefined;
  731. scene.videoUrl = undefined;
  732. scene.error = undefined;
  733. if (this.stage === 'done') {
  734. this.stage = 'idle';
  735. this.resultVideoUrl = '';
  736. }
  737. this.cdr.detectChanges();
  738. const { w, h } = this.currentAspect();
  739. const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || '';
  740. const fullPrompt = `${prompt}, ${styleHint}, ending frame composition, final beat of scene`;
  741. this.jimeng.generateImgV4(fullPrompt, [], { width: w, height: h }).subscribe({
  742. next: (url: string) => {
  743. this.zone.run(() => {
  744. scene.tailImageUrl = url;
  745. scene.tailImageSource = 'ai';
  746. scene.state = 'tail-done';
  747. scene.pendingTarget = undefined;
  748. this.cdr.detectChanges();
  749. });
  750. },
  751. error: (err) => {
  752. this.zone.run(() => {
  753. scene.state = 'tail-failed';
  754. scene.pendingTarget = undefined;
  755. scene.error = err?.message || '尾帧生成失败';
  756. this.cdr.detectChanges();
  757. });
  758. },
  759. });
  760. }
  761. /** 清掉首/尾帧(让用户重新选择来源) */
  762. clearSceneImage(index: number, target: 'head' | 'tail'): void {
  763. const scene = this.scenes[index];
  764. if (!scene) return;
  765. if (target === 'head') {
  766. scene.imageUrl = undefined;
  767. scene.imageSource = 'ai';
  768. scene.videoUrl = undefined;
  769. scene.state = scene.tailImageUrl ? 'tail-done' : 'idle';
  770. } else {
  771. scene.tailImageUrl = undefined;
  772. scene.tailImageSource = 'ai';
  773. scene.videoUrl = undefined;
  774. // 状态回退到首帧的状态
  775. if (scene.imageUrl) scene.state = 'image-done';
  776. }
  777. scene.error = undefined;
  778. this.cdr.detectChanges();
  779. }
  780. /** 删除指定段(任何状态都可删)。 */
  781. removeScene(index: number): void {
  782. if (index < 0 || index >= this.scenes.length) return;
  783. if (this.hasInflightScene) return; // 有运行中任务时禁删,避免状态错乱
  784. this.scenes = this.scenes.filter((_, i) => i !== index);
  785. if (this.stage === 'done') {
  786. this.stage = 'idle';
  787. this.resultVideoUrl = '';
  788. }
  789. this.cdr.detectChanges();
  790. }
  791. /**
  792. * 手动模式:单段生成 / 重新生成图片。
  793. * 使用该卡当前 scenePrompt + 全局 style/aspect。
  794. * 重新生成时会清掉旧 videoUrl(避免首帧不一致)。
  795. */
  796. regenerateImage(index: number): void {
  797. const scene = this.scenes[index];
  798. if (!scene) return;
  799. const prompt = scene.scenePrompt.trim();
  800. if (!prompt) {
  801. scene.error = '请先填写画面描述';
  802. return;
  803. }
  804. // 重置该段状态
  805. scene.state = 'image-pending';
  806. scene.imageUrl = undefined;
  807. scene.videoUrl = undefined;
  808. scene.error = undefined;
  809. if (this.stage === 'done') {
  810. this.stage = 'idle';
  811. this.resultVideoUrl = '';
  812. }
  813. this.cdr.detectChanges();
  814. const { w, h } = this.currentAspect();
  815. const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || '';
  816. const fullPrompt = `${prompt}, ${styleHint}`;
  817. this.jimeng.generateImgV4(fullPrompt, [], { width: w, height: h }).subscribe({
  818. next: (url: string) => {
  819. this.zone.run(() => {
  820. scene.imageUrl = url;
  821. scene.state = 'image-done';
  822. this.cdr.detectChanges();
  823. });
  824. },
  825. error: (err) => {
  826. this.zone.run(() => {
  827. scene.state = 'image-failed';
  828. scene.error = err?.message || '图片生成失败';
  829. this.cdr.detectChanges();
  830. });
  831. },
  832. });
  833. }
  834. /**
  835. * 手动模式:单段生成 / 重新生成动态视频。
  836. *
  837. * 路由:
  838. * - videoMode='first' → method='2',仅 imageUrl 一张
  839. * - videoMode='first-last' → method='3',imageUrl + tailImageUrl 两张
  840. *
  841. * 失败不降级为静态图(手动模式下用户可自行选择不要视频)。
  842. */
  843. regenerateVideo(index: number): void {
  844. const scene = this.scenes[index];
  845. if (!scene || !scene.imageUrl) return;
  846. const useFirstLast = scene.videoMode === 'first-last';
  847. if (useFirstLast && !scene.tailImageUrl) {
  848. scene.error = '首尾帧模式需先生成或上传尾帧';
  849. this.cdr.detectChanges();
  850. return;
  851. }
  852. scene.state = 'video-pending';
  853. scene.pendingTarget = 'video';
  854. scene.videoUrl = undefined;
  855. scene.error = undefined;
  856. if (this.stage === 'done') {
  857. this.stage = 'idle';
  858. this.resultVideoUrl = '';
  859. }
  860. this.cdr.detectChanges();
  861. const aspectRatio: JimengAspectRatio =
  862. this.aspect === 'landscape' ? '16:9' : this.aspect === 'portrait' ? '9:16' : '1:1';
  863. const imageUrls = useFirstLast
  864. ? [scene.imageUrl, scene.tailImageUrl!]
  865. : [scene.imageUrl];
  866. const method: '2' | '3' = useFirstLast ? '3' : '2';
  867. this.jimeng
  868. .generateImageToVideo({
  869. imageUrls,
  870. prompt: scene.scenePrompt,
  871. method,
  872. quality: this.clipQuality,
  873. frames: this.clipFrames,
  874. aspectRatio,
  875. })
  876. .subscribe({
  877. next: (result) => {
  878. this.zone.run(() => {
  879. scene.videoUrl = result.videoUrl;
  880. scene.state = 'video-done';
  881. scene.pendingTarget = undefined;
  882. this.cdr.detectChanges();
  883. });
  884. },
  885. error: (err) => {
  886. this.zone.run(() => {
  887. // 失败回退到「已出图」状态,让用户可重试
  888. scene.state = 'image-done';
  889. scene.pendingTarget = undefined;
  890. scene.error = err?.message || '图生视频失败';
  891. this.cdr.detectChanges();
  892. });
  893. },
  894. });
  895. }
  896. /**
  897. * 模板辅助:从文件 input 事件取出 File 并交给 uploadSceneImage。
  898. * 模板里直接绑定 (change)="onSceneFileChange(i, 'head', $event)"。
  899. */
  900. onSceneFileChange(index: number, target: 'head' | 'tail', ev: Event): void {
  901. const input = ev.target as HTMLInputElement;
  902. const file = input?.files?.[0];
  903. if (file) this.uploadSceneImage(index, target, file);
  904. // 重置 input value 以便重复选择同一文件
  905. if (input) input.value = '';
  906. }
  907. /** 手动模式右栏:已就绪的分镜数(image-done 或 video-done) */
  908. get readyCount(): number {
  909. return this.scenes.filter((s) => s.state === 'image-done' || s.state === 'video-done').length;
  910. }
  911. /** 手动模式右栏:已带动态视频的分镜数 */
  912. get videoReadyCount(): number {
  913. return this.scenes.filter((s) => !!s.videoUrl).length;
  914. }
  915. /**
  916. * 输出片段分布统计(TTS 暂停后只留 C / D 态):
  917. * C 动态视频 / D 静态图
  918. * 仅在 stage === 'done' 时有意义。
  919. */
  920. get caseSummary(): { C: number; D: number; total: number } {
  921. const s = { C: 0, D: 0, total: 0 };
  922. for (const sc of this.scenes) {
  923. if (sc.videoUrl) s.C++;
  924. else if (sc.imageUrl) s.D++;
  925. }
  926. s.total = s.C + s.D;
  927. return s;
  928. }
  929. formatSize(bytes: number): string {
  930. if (bytes < 1024) return `${bytes} B`;
  931. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  932. return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
  933. }
  934. }