import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Subscription, from, of } from 'rxjs'; import { catchError, concatMap, map, toArray } from 'rxjs/operators'; import { LlmService } from '../../../services/llm.service'; import { JimengService, JimengAspectRatio, JimengVideoQuality, } from '../../../services/jimeng.service'; import { composeVideoStream, CompositeHandle, CompositeSegmentInput, } from '../../../pipelines/composite-sse'; import { ResultsService } from '../../../services/results.service'; interface SceneSegment { /** 画面描述(英文 prompt);快速模式由 LLM 生成,手动模式由用户填入 */ scenePrompt: string; /** 中文解说 / 备注(可选,TTS 已暂停,仅作未来字幕用途占位) */ narration?: string; /** 首帧图片(必填) */ imageUrl?: string; /** 首帧来源:ai 生成 / 用户上传。默认 ai */ imageSource?: 'ai' | 'upload'; /** 尾帧图片(仅手动模式 + 首尾帧 i2v 用) */ tailImageUrl?: string; /** 尾帧来源 */ tailImageSource?: 'ai' | 'upload'; /** * 手动模式:视频生成模式 * - 'first' : 仅首帧(即梦 method=2,1 张图) * - 'first-last' : 首尾帧(即梦 method=3,2 张图,更平滑) * * 快速模式恒为 'first'。 */ videoMode?: 'first' | 'first-last'; /** 即梦图生视频结果 */ videoUrl?: string; state: 'idle' | 'image-pending' | 'image-done' | 'image-failed' | 'tail-pending' | 'tail-done' | 'tail-failed' | 'video-pending' | 'video-done' | 'video-failed'; /** 当前 pending 操作针对的位(用于 UI 显示局部 spinner) */ pendingTarget?: 'head' | 'tail' | 'video'; error?: string; } type T2vStyle = 'cinematic' | 'anime' | 'realistic' | 'illustration' | 'documentary'; type T2vAspect = 'landscape' | 'portrait' | 'square'; /** 画面类型:静态图 / 动态视频。仅在快速模式下有意义 */ type T2vMediaMode = 'image' | 'video'; /** * P4: 主题生视频 Pipeline * * 一句话主题 → * 1. LLM 生成 N 段分镜(scene_prompt + narration) * 2. 即梦 jimeng-img-v4 为每段生成图片 * 3. 火山 TTS 为每段解说生成音频 * 4. /api/video/composite 合成成片 */ @Component({ selector: 'app-topic-to-video', standalone: true, imports: [CommonModule, FormsModule], templateUrl: './topic-to-video.component.html', styleUrls: ['./topic-to-video.component.css'], }) export class TopicToVideoComponent implements OnInit, OnDestroy { // ====== 输入 ====== topic = ''; nScenes: 3 | 4 | 5 | 6 | 8 = 5; style: T2vStyle = 'cinematic'; aspect: T2vAspect = 'landscape'; // ===== 模式 ===== /** * 快速模式(默认开):输入主题 → LLM 拆分镜 → 批量生图→(可选 i2v)→ 拼接。 * 手动模式(OFF):用户逐卡填 prompt、逐卡点「生成图片」「生成视频」。 */ quickMode = true; // ===== 画面类型(仅快速模式生效)===== /** 静态图 = 仅文生图;动态视频 = 文生图 + 图生视频 */ mediaMode: T2vMediaMode = 'image'; /** 动态视频每段时长:121 ⁈5s,241 ⁈10s */ clipFrames: 121 | 241 = 121; /** 动态视频质量:720p / 1080p */ clipQuality: Extract = '720p'; /** 手动模式下静态图单帧默认停留秒数(D 态) */ private static readonly STILL_IMAGE_DURATION = 3; // ====== 分镜 ====== scenes: SceneSegment[] = []; // ====== 阶段状态 ====== stage: 'idle' | 'scripting' | 'imaging' | 'videoing' | 'composing' | 'done' | 'failed' = 'idle'; stageStatus = ''; scriptError = ''; errorMsg = ''; // 进度 imageProgress = 0; videoProgress = 0; composeProgress = 0; resultVideoUrl = ''; private genSub?: Subscription; readonly stylePresets: { value: T2vStyle; label: string; promptHint: string }[] = [ { value: 'cinematic', label: '电影感', promptHint: 'cinematic lighting, film grain, anamorphic' }, { value: 'realistic', label: '写实', promptHint: 'photorealistic, ultra detailed, 4k' }, { value: 'anime', label: '动漫', promptHint: 'anime style, vibrant colors, studio ghibli' }, { value: 'illustration', label: '插画', promptHint: 'flat illustration, modern design' }, { value: 'documentary', label: '纪录片', promptHint: 'documentary photography, natural light' }, ]; readonly aspectPresets: { value: T2vAspect; label: string; w: number; h: number }[] = [ { value: 'landscape', label: '横屏 16:9', w: 1920, h: 1080 }, { value: 'portrait', label: '竖屏 9:16', w: 1080, h: 1920 }, { value: 'square', label: '方屏 1:1', w: 1440, h: 1440 }, ]; constructor( private llm: LlmService, private jimeng: JimengService, private cdr: ChangeDetectorRef, private zone: NgZone, private results: ResultsService, ) {} ngOnInit(): void { // 从首屏跳转:取出预填主题(onHomeNavigate 写入 sessionStorage) try { const prefill = sessionStorage.getItem('t2v.prefillTopic'); if (prefill) { this.topic = prefill; sessionStorage.removeItem('t2v.prefillTopic'); } } catch {} } ngOnDestroy(): void { this.genSub?.unsubscribe(); } // ============== 工具 ============== get isRunning(): boolean { return this.stage !== 'idle' && this.stage !== 'done' && this.stage !== 'failed'; } /** * 能否启动(右栏 CTA)。 * - 快速模式:主题至少 2 个字 * - 手动模式:至少 1 个分镜,且所有分镜都出了画面(image-done / video-done) */ get canStart(): boolean { if (this.isRunning) return false; if (this.quickMode) { return this.topic.trim().length >= 2; } if (this.scenes.length === 0) return false; return this.scenes.every((s) => s.state === 'image-done' || s.state === 'video-done' ); } /** 是否有任何单段任务运行中(手动模式逐卡按钮锁定用) */ get hasInflightScene(): boolean { return this.scenes.some((s) => s.state === 'image-pending' || s.state === 'tail-pending' || s.state === 'video-pending' ); } /** * 总进度(右栏带状动画进度条)。 * - 快速:script 5 / image 30 / (可选)video 25 / compose 40,跳过阶段权重并入 compose * - 手动:仅合成阶段有意义(前面是逐卡手点) */ get totalProgress(): number { if (!this.quickMode) { return this.stage === 'composing' ? this.composeProgress : (this.stage === 'done' ? 100 : 0); } const SCRIPT_W = 5; const IMAGE_W = 30; const COMPOSE_W = 40; const videoW = this.mediaMode === 'video' ? 25 : 0; const remain = 100 - SCRIPT_W - IMAGE_W - videoW - COMPOSE_W; const composeW = COMPOSE_W + remain; const script = this.stage === 'idle' ? 0 : (this.stage === 'scripting' ? 2 : SCRIPT_W); const image = Math.round((this.imageProgress / 100) * IMAGE_W); const video = Math.round((this.videoProgress / 100) * videoW); const compose = Math.round((this.composeProgress / 100) * composeW); return Math.min(script + image + video + compose, 100); } private currentAspect() { return this.aspectPresets.find((a) => a.value === this.aspect)!; } // ============== 一键启动 ============== /** * 右栏 CTA 入口。按 quickMode 分两条路径: * - 快速:LLM 分镜 → 文生图 → (可选)图生视频 → ffmpeg * - 手动:用户已逐卡准备好画面/视频,只走 ffmpeg */ start(): void { if (!this.canStart) return; if (this.quickMode) { this.reset(false); this.stage = 'scripting'; this.stageStatus = '正在让 AI 拆分分镜...'; this.scriptError = ''; this.errorMsg = ''; this.cdr.detectChanges(); this.generateStoryboard() .then((scenes) => { this.zone.run(() => { this.scenes = scenes; this.cdr.detectChanges(); }); return this.generateImages(); }) .then(() => (this.mediaMode === 'video' ? this.generateClips() : Promise.resolve())) .then(() => this.composeFinalVideo()) .catch((err) => this.handlePipelineError(err)); } else { // 手动模式:跳过所有 AI 阶段,直接拼接 this.errorMsg = ''; this.composeProgress = 0; this.resultVideoUrl = ''; this.cdr.detectChanges(); this.composeFinalVideo().catch((err) => this.handlePipelineError(err)); } } private handlePipelineError(err: any): void { console.error('[t2v] pipeline error', err); this.zone.run(() => { this.stage = 'failed'; this.stageStatus = ''; this.errorMsg = err?.message || '生成失败'; this.cdr.detectChanges(); }); } // ============== Step 1: LLM 分镜 ============== private async generateStoryboard(): Promise { const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || ''; // TTS 已暂停,narration 仅作未来字幕占位。仍按画面类型给一个字数范围提示, // 避免 LLM 输出过长影响分镜质量。 let targetSecLow: number, targetSecHigh: number; if (this.mediaMode === 'video') { const clipSec = this.clipFrames === 121 ? 5 : 10; targetSecLow = Math.max(1, clipSec - 1.5); targetSecHigh = clipSec; } else { targetSecLow = 4; targetSecHigh = 7; } const charLow = Math.round(targetSecLow * 4.5); const charHigh = Math.round(targetSecHigh * 4.5); const narrationRule = `narration: 中文备注 ${charLow}-${charHigh} 字(临时用作字幕/记录,本版本暂不配音)`; // 按段数选叙事骨架(起承转合 / 钩子-主体-高潮-收束) const arc = this.buildNarrativeArc(this.nScenes); const prompt = `你是一名短视频导演兼分镜师。请根据主题输出 ${this.nScenes} 段分镜, **整支视频必须具有强连贯性**:单一主体贯穿,色调/光线/地点稳定,画面与解说前后承接。 == 主题 == ${this.topic.trim()} == 全局视觉锚点(每段都必须复用)== - 视觉风格:${styleHint} - 你需要先在脑内确定「主角/主体(subject)」+「主色调(palette,如 warm amber / cool teal)」+「场景地点(location)」+「光线时段(lighting,如 golden hour / overcast)」 - 上述 4 项一旦确定,**全部 ${this.nScenes} 段的 scene_prompt 中都要明确包含同样的 subject / palette / lighting / location 关键词**,仅变化镜头与动作 == 叙事骨架(按顺序对应每段位置)== ${arc} == 解说承接规则(仅 narration)== - 第 1 段:开场钩子,引出主体,不剧透结论 - 中段:使用承接词「随后/紧接着/与此同时/直到/而」,保持时间或因果链 - 末段:收束/反差/升华,回扣开场 - **严禁段落独立**:禁止每段自成一个小故事;解说必须前后呼应,把整支视频读成一段完整文章 == 字段格式 == - scene_prompt: 英文 ≤30 词,必含 subject / palette / lighting / location 锚点 + 镜头语言(wide / close-up / tracking / low angle 等)${ this.mediaMode === 'video' ? ',并加入轻微动作(slow zoom in / drift / wind / rising steam 等)以驱动 i2v' : '' } - ${narrationRule} == 输出 == 严格 JSON 数组,仅 JSON 无 markdown 包裹: [{"scene_prompt": "...", "narration": "..."}, ...] 共 ${this.nScenes} 段。`; return new Promise((resolve, reject) => { this.genSub = this.llm.chat([{ role: 'user', content: prompt }]).subscribe({ next: (res: any) => { const text = res?.choices?.[0]?.message?.content || ''; const arr = this.extractJsonArray(text); if (!Array.isArray(arr) || arr.length === 0) { reject(new Error('AI 分镜解析失败:未识别到 JSON 数组')); return; } const scenes: SceneSegment[] = arr.slice(0, this.nScenes).map((item: any) => ({ scenePrompt: String(item.scene_prompt || item.scenePrompt || '').trim(), narration: String(item.narration || '').trim(), state: 'idle', })); if (scenes.some((s) => !s.scenePrompt || !s.narration)) { reject(new Error('AI 分镜数据不完整,请重试')); return; } resolve(scenes); }, error: (err) => reject(new Error(err?.message || 'AI 分镜失败')), }); }); } /** * 按段数返回叙事骨架文本,给 LLM 当结构提示。 * 短视频常用三/四段式:钩子 → 推进 → 反转/收束。 */ private buildNarrativeArc(n: number): string { if (n === 3) { return [ '段 1(钩子 Hook):抛出悬念或场景痛点,引出主体;不给答案', '段 2(推进 Build):主体行动 / 矛盾发展 / 细节放大', '段 3(收束 Payoff):揭晓结果、升华或反转,回扣开场', ].join('\n'); } if (n === 4) { return [ '段 1(钩子 Hook):开场抓人,引出主体与悬念', '段 2(铺垫 Setup):交代背景 / 引入矛盾或目标', '段 3(高潮 Climax):冲突或转折达到顶点', '段 4(收束 Payoff):反差 / 答案 / 主题升华,回扣开场', ].join('\n'); } if (n === 5) { return [ '段 1(钩子 Hook):3 秒内抓住注意力', '段 2(介绍 Intro):交代主体与场景', '段 3(铺垫 Build):层层递进的细节或动作', '段 4(高潮 Climax):最具张力的画面或转折', '段 5(收束 Payoff):余韵 / 升华,回扣开场', ].join('\n'); } if (n === 6) { return [ '段 1(钩子):开门见山的悬念', '段 2(介绍):明确主体与场景', '段 3-4(推进):两层递进;段 3 引矛盾,段 4 加码', '段 5(高潮):决定性瞬间', '段 6(收束):余韵 / 反差 / 主题点题', ].join('\n'); } // n=8 return [ '段 1(钩子):极强的开场画面', '段 2(介绍):主体与世界观', '段 3-4(铺垫):两层细节或人物动机', '段 5-6(推进与高潮):从冲突升级到决定性瞬间', '段 7(转折):反差或结果揭晓', '段 8(收束):升华 / 余韵镜头,回扣开场', ].join('\n'); } private extractJsonArray(text: string): any { if (!text) return null; const match = text.match(/\[[\s\S]*\]/); if (!match) return null; try { return JSON.parse(match[0]); } catch { return null; } } // ============== Step 2: 即梦文生图 ============== private generateImages(): Promise { this.zone.run(() => { this.stage = 'imaging'; this.stageStatus = '正在生成画面...'; this.imageProgress = 0; this.cdr.detectChanges(); }); const { w, h } = this.currentAspect(); const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || ''; const total = this.scenes.length; let done = 0; return new Promise((resolve, reject) => { this.genSub = from(this.scenes) .pipe( concatMap((scene, idx) => { scene.state = 'image-pending'; this.zone.run(() => this.cdr.detectChanges()); const fullPrompt = `${scene.scenePrompt}, ${styleHint}`; return this.jimeng.generateImgV4(fullPrompt, [], { width: w, height: h }).pipe( map((url: string) => { scene.imageUrl = url; scene.state = 'image-done'; done += 1; this.zone.run(() => { this.imageProgress = Math.round((done / total) * 100); this.stageStatus = `已生成 ${done}/${total} 张画面`; this.cdr.detectChanges(); }); return scene; }), catchError((err) => { scene.state = 'image-failed'; scene.error = err?.message || '图片生成失败'; done += 1; this.zone.run(() => { this.imageProgress = Math.round((done / total) * 100); this.cdr.detectChanges(); }); return of(scene); }), ); }), toArray(), ) .subscribe({ next: () => { const failed = this.scenes.filter((s) => s.state === 'image-failed').length; if (failed === total) { reject(new Error('所有画面生成失败,请检查即梦 token 余额或稍后重试')); return; } if (failed > 0) { console.warn(`[t2v] ${failed}/${total} 张画面失败,跳过失败段`); } this.scenes = this.scenes.filter((s) => s.state === 'image-done'); resolve(); }, error: (err) => reject(err), }); }); } // ============== Step 2.5: 即梦图生视频(仅 mediaMode='video')============== /** * 为每一帧已生成的图片调用即梦 i2v,得到一段动态视频。 * 采用「首帧」模式(method=2),带场景 prompt 驱动动态。 * 任何一段失败 → 该段降级为静态图(保留 imageUrl,不设 videoUrl), * 不中断整个流程。 */ private generateClips(): Promise { this.zone.run(() => { this.stage = 'videoing'; this.stageStatus = '正在将画面生成动态视频...'; this.videoProgress = 0; this.cdr.detectChanges(); }); const aspectRatio: JimengAspectRatio = this.aspect === 'landscape' ? '16:9' : this.aspect === 'portrait' ? '9:16' : '1:1'; const total = this.scenes.length; let done = 0; return new Promise((resolve, reject) => { this.genSub = from(this.scenes) .pipe( concatMap((scene) => { scene.state = 'video-pending'; this.zone.run(() => this.cdr.detectChanges()); return this.jimeng .generateImageToVideo({ imageUrls: [scene.imageUrl!], prompt: scene.scenePrompt, method: '2', // 首帧 quality: this.clipQuality, frames: this.clipFrames, aspectRatio, }) .pipe( map((result) => { scene.videoUrl = result.videoUrl; scene.state = 'video-done'; done += 1; this.zone.run(() => { this.videoProgress = Math.round((done / total) * 100); this.stageStatus = `已生成 ${done}/${total} 段动态视频`; this.cdr.detectChanges(); }); return scene; }), catchError((err) => { // 降级:该段仍用静态图,不报错 scene.state = 'video-failed'; scene.error = err?.message || '图生视频失败'; done += 1; console.warn('[t2v] 该段图生视频失败,降级为静态图:', err); this.zone.run(() => { this.videoProgress = Math.round((done / total) * 100); this.cdr.detectChanges(); }); return of(scene); }), ); }), toArray(), ) .subscribe({ next: () => { const ok = this.scenes.filter((s) => s.state === 'video-done').length; const fail = this.scenes.filter((s) => s.state === 'video-failed').length; if (ok === 0) { reject(new Error('所有分镜的图生视频均失败,请检查即梦额度或稍后重试')); return; } if (fail > 0) { console.warn(`[t2v] ${fail}/${total} 段图生视频失败,该段将使用静态图代替`); } resolve(); }, error: (err) => reject(err), }); }); } // ============== Step 4: ffmpeg 合成(SSE 实时进度)============== // (TTS 阶段已暂停,这里直接拼接) private compositeHandle?: CompositeHandle; private composeFinalVideo(): Promise { this.zone.run(() => { this.stage = 'composing'; this.stageStatus = '正在合成最终视频...'; this.composeProgress = 0; this.cdr.detectChanges(); }); // TTS 已暂停,本版本仅产出两种后端态: // videoUrl 存在 → C 态(纯动态视频无声,按视频原长) // 仅 imageUrl → D 态(静态画面,按 STILL_IMAGE_DURATION 秒) const segments: CompositeSegmentInput[] = this.scenes .map((s, i) => { const seg: CompositeSegmentInput = { id: `t2v-${i}` }; if (s.videoUrl) { seg.videoUrl = s.videoUrl; } else if (s.imageUrl) { seg.imageUrl = s.imageUrl; seg.duration = TopicToVideoComponent.STILL_IMAGE_DURATION; } return seg; }) .filter((seg) => !!seg.imageUrl || !!seg.videoUrl); if (!segments.length) { return Promise.reject(new Error('没有可用的分镜段')); } return new Promise((resolve, reject) => { this.compositeHandle = composeVideoStream({ segments, title: this.topic.trim() || 'topic-to-video', onStage: (e) => { this.zone.run(() => { if (e.message) this.stageStatus = e.message; this.cdr.detectChanges(); }); }, onProgress: (e) => { this.zone.run(() => { if (typeof e.percent === 'number') this.composeProgress = e.percent; if (e.message) this.stageStatus = e.message; this.cdr.detectChanges(); }); }, }); this.compositeHandle.promise .then((res) => { this.zone.run(() => { if (res?.success && res?.videoUrl) { this.resultVideoUrl = '/backend' + res.videoUrl; this.composeProgress = 100; this.stage = 'done'; this.stageStatus = `生成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`; this.cdr.detectChanges(); // 持久化 this.results.saveResult({ type: 'video', url: this.resultVideoUrl, title: this.topic.trim() || '主题生视频', pipelineId: 'topic_to_video', extras: { segments: res.segments, filename: res.filename, style: this.style, aspect: this.aspect, nScenes: this.nScenes, quickMode: this.quickMode, mediaMode: this.quickMode ? this.mediaMode : undefined, clipFrames: this.quickMode && this.mediaMode === 'video' ? this.clipFrames : undefined, clipQuality: this.quickMode && this.mediaMode === 'video' ? this.clipQuality : undefined, }, }).subscribe(); this.results.saveHistory({ keyword: this.topic.trim() || '主题生视频', status: 'completed', resultUrl: this.resultVideoUrl, pipelineId: 'topic_to_video', styleName: this.style, }).subscribe(); resolve(); } else { reject(new Error('合成失败:服务端未返回视频')); } }); }) .catch((err) => { reject(new Error(err?.message || '合成失败')); }); }); } cancel(): void { this.genSub?.unsubscribe(); this.compositeHandle?.abort(); this.stage = 'idle'; this.stageStatus = '已取消'; } /** * 重置流水线状态。 * * @param clearAll 是否清除结果视频 URL(快速模式 start 时为 false 保留上一次预览) * @param clearScenes 是否清空 scenes 数组。默认 true;手动模式下使用 reset(false, false) * 以保留用户逐卡准备的分镜。 */ reset(clearAll = true, clearScenes = true): void { this.genSub?.unsubscribe(); if (clearScenes) this.scenes = []; this.stage = 'idle'; this.stageStatus = ''; this.scriptError = ''; this.errorMsg = ''; this.imageProgress = 0; this.videoProgress = 0; this.composeProgress = 0; if (clearAll) { this.resultVideoUrl = ''; } } // ============== 手动模式:逐卡操作 ============== /** 添加一张空白分镜卡。手动模式可不限数量。 */ addManualScene(): void { this.scenes = [ ...this.scenes, { scenePrompt: '', narration: '', state: 'idle', videoMode: 'first', // 默认仅首帧 imageSource: 'ai', tailImageSource: 'ai', }, ]; // 添加后清除上一次的完成态,重新进入可编辑状态 if (this.stage === 'done') { this.stage = 'idle'; this.resultVideoUrl = ''; } this.cdr.detectChanges(); } /** * 手动模式:切换该卡的视频生成模式(首帧 / 首尾帧)。 * 切换会保留已生成的图,但清掉视频结果(避免与新模式不一致)。 */ setSceneVideoMode(index: number, mode: 'first' | 'first-last'): void { const scene = this.scenes[index]; if (!scene) return; if (scene.videoMode === mode) return; if (scene.state === 'image-pending' || scene.state === 'tail-pending' || scene.state === 'video-pending') return; scene.videoMode = mode; // 清掉旧视频,回退到「已出图」状态(如果有图) if (scene.videoUrl) { scene.videoUrl = undefined; scene.state = scene.imageUrl ? 'image-done' : 'idle'; } if (this.stage === 'done') { this.stage = 'idle'; this.resultVideoUrl = ''; } this.cdr.detectChanges(); } /** * 手动模式:上传本地图片作为首帧或尾帧。 * * @param target 'head' | 'tail' */ uploadSceneImage(index: number, target: 'head' | 'tail', file: File): void { const scene = this.scenes[index]; if (!scene || !file) return; if (!file.type.startsWith('image/')) { scene.error = '请选择图片文件(jpg / png / webp 等)'; this.cdr.detectChanges(); return; } scene.error = undefined; scene.pendingTarget = target; if (target === 'head') { scene.state = 'image-pending'; scene.imageUrl = undefined; scene.videoUrl = undefined; // 首帧变了,清掉旧视频 } else { scene.state = 'tail-pending'; scene.tailImageUrl = undefined; scene.videoUrl = undefined; } if (this.stage === 'done') { this.stage = 'idle'; this.resultVideoUrl = ''; } this.cdr.detectChanges(); this.jimeng.uploadFileToParse( file, `t2v-${target}-${Date.now()}-${file.name}`, file.type || 'image/jpeg', ).subscribe({ next: (url: string) => { this.zone.run(() => { scene.pendingTarget = undefined; if (target === 'head') { scene.imageUrl = url; scene.imageSource = 'upload'; scene.state = 'image-done'; } else { scene.tailImageUrl = url; scene.tailImageSource = 'upload'; scene.state = 'tail-done'; } this.cdr.detectChanges(); }); }, error: (err) => { this.zone.run(() => { scene.pendingTarget = undefined; scene.state = target === 'head' ? 'image-failed' : 'tail-failed'; scene.error = err?.message || '上传失败'; this.cdr.detectChanges(); }); }, }); } /** * 手动模式:AI 生成尾帧(即梦文生图)。 * 使用 scene.scenePrompt 作为输入,加 "ending frame, final shot of scene" 提示, * 让画面与首帧保持一致风格但作为「场景尾」。 */ regenerateTailImage(index: number): void { const scene = this.scenes[index]; if (!scene) return; const prompt = scene.scenePrompt.trim(); if (!prompt) { scene.error = '请先填写画面描述'; this.cdr.detectChanges(); return; } scene.state = 'tail-pending'; scene.pendingTarget = 'tail'; scene.tailImageUrl = undefined; scene.videoUrl = undefined; scene.error = undefined; if (this.stage === 'done') { this.stage = 'idle'; this.resultVideoUrl = ''; } this.cdr.detectChanges(); const { w, h } = this.currentAspect(); const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || ''; const fullPrompt = `${prompt}, ${styleHint}, ending frame composition, final beat of scene`; this.jimeng.generateImgV4(fullPrompt, [], { width: w, height: h }).subscribe({ next: (url: string) => { this.zone.run(() => { scene.tailImageUrl = url; scene.tailImageSource = 'ai'; scene.state = 'tail-done'; scene.pendingTarget = undefined; this.cdr.detectChanges(); }); }, error: (err) => { this.zone.run(() => { scene.state = 'tail-failed'; scene.pendingTarget = undefined; scene.error = err?.message || '尾帧生成失败'; this.cdr.detectChanges(); }); }, }); } /** 清掉首/尾帧(让用户重新选择来源) */ clearSceneImage(index: number, target: 'head' | 'tail'): void { const scene = this.scenes[index]; if (!scene) return; if (target === 'head') { scene.imageUrl = undefined; scene.imageSource = 'ai'; scene.videoUrl = undefined; scene.state = scene.tailImageUrl ? 'tail-done' : 'idle'; } else { scene.tailImageUrl = undefined; scene.tailImageSource = 'ai'; scene.videoUrl = undefined; // 状态回退到首帧的状态 if (scene.imageUrl) scene.state = 'image-done'; } scene.error = undefined; this.cdr.detectChanges(); } /** 删除指定段(任何状态都可删)。 */ removeScene(index: number): void { if (index < 0 || index >= this.scenes.length) return; if (this.hasInflightScene) return; // 有运行中任务时禁删,避免状态错乱 this.scenes = this.scenes.filter((_, i) => i !== index); if (this.stage === 'done') { this.stage = 'idle'; this.resultVideoUrl = ''; } this.cdr.detectChanges(); } /** * 手动模式:单段生成 / 重新生成图片。 * 使用该卡当前 scenePrompt + 全局 style/aspect。 * 重新生成时会清掉旧 videoUrl(避免首帧不一致)。 */ regenerateImage(index: number): void { const scene = this.scenes[index]; if (!scene) return; const prompt = scene.scenePrompt.trim(); if (!prompt) { scene.error = '请先填写画面描述'; return; } // 重置该段状态 scene.state = 'image-pending'; scene.imageUrl = undefined; scene.videoUrl = undefined; scene.error = undefined; if (this.stage === 'done') { this.stage = 'idle'; this.resultVideoUrl = ''; } this.cdr.detectChanges(); const { w, h } = this.currentAspect(); const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || ''; const fullPrompt = `${prompt}, ${styleHint}`; this.jimeng.generateImgV4(fullPrompt, [], { width: w, height: h }).subscribe({ next: (url: string) => { this.zone.run(() => { scene.imageUrl = url; scene.state = 'image-done'; this.cdr.detectChanges(); }); }, error: (err) => { this.zone.run(() => { scene.state = 'image-failed'; scene.error = err?.message || '图片生成失败'; this.cdr.detectChanges(); }); }, }); } /** * 手动模式:单段生成 / 重新生成动态视频。 * * 路由: * - videoMode='first' → method='2',仅 imageUrl 一张 * - videoMode='first-last' → method='3',imageUrl + tailImageUrl 两张 * * 失败不降级为静态图(手动模式下用户可自行选择不要视频)。 */ regenerateVideo(index: number): void { const scene = this.scenes[index]; if (!scene || !scene.imageUrl) return; const useFirstLast = scene.videoMode === 'first-last'; if (useFirstLast && !scene.tailImageUrl) { scene.error = '首尾帧模式需先生成或上传尾帧'; this.cdr.detectChanges(); return; } scene.state = 'video-pending'; scene.pendingTarget = 'video'; scene.videoUrl = undefined; scene.error = undefined; if (this.stage === 'done') { this.stage = 'idle'; this.resultVideoUrl = ''; } this.cdr.detectChanges(); const aspectRatio: JimengAspectRatio = this.aspect === 'landscape' ? '16:9' : this.aspect === 'portrait' ? '9:16' : '1:1'; const imageUrls = useFirstLast ? [scene.imageUrl, scene.tailImageUrl!] : [scene.imageUrl]; const method: '2' | '3' = useFirstLast ? '3' : '2'; this.jimeng .generateImageToVideo({ imageUrls, prompt: scene.scenePrompt, method, quality: this.clipQuality, frames: this.clipFrames, aspectRatio, }) .subscribe({ next: (result) => { this.zone.run(() => { scene.videoUrl = result.videoUrl; scene.state = 'video-done'; scene.pendingTarget = undefined; this.cdr.detectChanges(); }); }, error: (err) => { this.zone.run(() => { // 失败回退到「已出图」状态,让用户可重试 scene.state = 'image-done'; scene.pendingTarget = undefined; scene.error = err?.message || '图生视频失败'; this.cdr.detectChanges(); }); }, }); } /** * 模板辅助:从文件 input 事件取出 File 并交给 uploadSceneImage。 * 模板里直接绑定 (change)="onSceneFileChange(i, 'head', $event)"。 */ onSceneFileChange(index: number, target: 'head' | 'tail', ev: Event): void { const input = ev.target as HTMLInputElement; const file = input?.files?.[0]; if (file) this.uploadSceneImage(index, target, file); // 重置 input value 以便重复选择同一文件 if (input) input.value = ''; } /** 手动模式右栏:已就绪的分镜数(image-done 或 video-done) */ get readyCount(): number { return this.scenes.filter((s) => s.state === 'image-done' || s.state === 'video-done').length; } /** 手动模式右栏:已带动态视频的分镜数 */ get videoReadyCount(): number { return this.scenes.filter((s) => !!s.videoUrl).length; } /** * 输出片段分布统计(TTS 暂停后只留 C / D 态): * C 动态视频 / D 静态图 * 仅在 stage === 'done' 时有意义。 */ get caseSummary(): { C: number; D: number; total: number } { const s = { C: 0, D: 0, total: 0 }; for (const sc of this.scenes) { if (sc.videoUrl) s.C++; else if (sc.imageUrl) s.D++; } s.total = s.C + s.D; return s; } formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } }