topic-to-video.component.html 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. <section class="dh-page pl-page t2v-page">
  2. <!-- 创作会话顶栏:新建 / 历史 / 状态徽章 -->
  3. <app-session-bar
  4. pipelineId="topic-to-video"
  5. pipelineLabel="主题生视频"
  6. (newSession)="onNewSession()"
  7. (openSession)="onOpenSession($event)">
  8. </app-session-bar>
  9. <header class="dh-page-header">
  10. <div class="dh-header-icon">
  11. <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  12. <circle cx="12" cy="12" r="9"></circle>
  13. <path d="M12 7v5l3 2"></path>
  14. </svg>
  15. </div>
  16. <div class="dh-header-text">
  17. <h2>主题生视频 <span class="pl-pill">快速 / 手动</span></h2>
  18. <p>快速:一句话主题 → AI 拆分镜 → 配图 → 可选动态视频 → 拼接成片。手动:逐卡填 prompt、上传或 AI 生成首尾帧、单段图生视频,最后一键拼接。</p>
  19. </div>
  20. </header>
  21. <div class="t2v-template-panel">
  22. <div class="t2v-template-panel__main">
  23. <div class="t2v-template-panel__title">模板/预设</div>
  24. <div class="t2v-template-panel__controls">
  25. <select [(ngModel)]="selectedTemplateId" [disabled]="isRunning || hasInflightScene">
  26. <option value="">选择主题生视频模板</option>
  27. <option *ngFor="let template of topicTemplates" [value]="template.id">
  28. {{ template.scope === 'system' ? '系统' : '我的' }} · {{ template.name }}
  29. </option>
  30. </select>
  31. <button class="dh-chip-btn" type="button" [disabled]="!selectedTemplateId || isRunning || hasInflightScene" (click)="applySelectedTemplate()">
  32. 应用模板
  33. </button>
  34. </div>
  35. <p class="t2v-template-panel__message" *ngIf="templateMessage">{{ templateMessage }}</p>
  36. </div>
  37. <div class="t2v-template-panel__save">
  38. <input class="dh-input" type="text" placeholder="模板名称(可选)" [(ngModel)]="templateName" [disabled]="isRunning || hasInflightScene">
  39. <button class="dh-chip-btn" type="button" [disabled]="isRunning || hasInflightScene" (click)="saveCurrentAsTemplate()">
  40. 保存为模板
  41. </button>
  42. </div>
  43. </div>
  44. <!-- 顶部模式切换条(独立横向,使两种模式视觉分明) -->
  45. <div class="t2v-mode-bar">
  46. <button class="t2v-mode-bar__btn"
  47. [class.is-active]="quickMode"
  48. [disabled]="isRunning" (click)="quickMode = true">
  49. <span class="t2v-mode-bar__title">⚡ 快速模式</span>
  50. <span class="t2v-mode-bar__sub">一句主题 → AI 全自动出片</span>
  51. </button>
  52. <button class="t2v-mode-bar__btn"
  53. [class.is-active]="!quickMode"
  54. [disabled]="isRunning" (click)="quickMode = false">
  55. <span class="t2v-mode-bar__title">✍️ 手动模式</span>
  56. <span class="t2v-mode-bar__sub">逐卡填 prompt · 支持上传 / 首尾帧 i2v</span>
  57. </button>
  58. </div>
  59. <div class="pl-grid">
  60. <!-- ========================= 左:配置 ========================= -->
  61. <div class="dh-step-col">
  62. <div class="dh-step-col__head">
  63. <span class="dh-step-col__step">Step 1</span>
  64. <span class="dh-step-col__title">{{ quickMode ? '主题与配置' : '通用设置' }}</span>
  65. </div>
  66. <!-- 主题:快速模式必填且置顶;手动模式收为可选标题 -->
  67. <div class="dh-substep" *ngIf="quickMode">
  68. <div class="dh-substep__head">
  69. <h4>视频主题 <em>*</em></h4>
  70. </div>
  71. <textarea class="dh-textarea" rows="3" maxlength="120"
  72. placeholder="例如:探索中国西部最美高原 / 一杯美式拯救加班的下午 / 用 30 秒讲清楚什么是大语言模型"
  73. [(ngModel)]="topic" (ngModelChange)="syncDraft()" [disabled]="isRunning"></textarea>
  74. <div class="dh-field__counter">{{ topic.length }} / 120 字</div>
  75. </div>
  76. <!-- 分镜数量:仅快速模式 -->
  77. <div class="dh-substep" *ngIf="quickMode">
  78. <div class="dh-substep__head"><h4>分镜数量</h4></div>
  79. <div class="dh-segment">
  80. <button class="dh-segment__item" [class.is-active]="nScenes === 3" [disabled]="isRunning" (click)="nScenes = 3">3 段</button>
  81. <button class="dh-segment__item" [class.is-active]="nScenes === 5" [disabled]="isRunning" (click)="nScenes = 5">5 段</button>
  82. <button class="dh-segment__item" [class.is-active]="nScenes === 6" [disabled]="isRunning" (click)="nScenes = 6">6 段</button>
  83. <button class="dh-segment__item" [class.is-active]="nScenes === 8" [disabled]="isRunning" (click)="nScenes = 8">8 段</button>
  84. </div>
  85. <p class="t2v-tip">{{ nScenes }} 段约 {{ nScenes * 6 }}-{{ nScenes * 8 }} 秒。</p>
  86. </div>
  87. <!-- 视觉风格:两模式共用 -->
  88. <div class="dh-substep">
  89. <div class="dh-substep__head"><h4>视觉风格</h4></div>
  90. <div class="dh-segment dh-segment--wrap">
  91. <button class="dh-segment__item" *ngFor="let s of stylePresets"
  92. [class.is-active]="style === s.value"
  93. [disabled]="isRunning" (click)="style = s.value">
  94. {{ s.label }}
  95. </button>
  96. </div>
  97. </div>
  98. <!-- 画面比例:两模式共用 -->
  99. <div class="dh-substep">
  100. <div class="dh-substep__head"><h4>画面比例</h4></div>
  101. <div class="dh-segment">
  102. <button class="dh-segment__item" *ngFor="let a of aspectPresets"
  103. [class.is-active]="aspect === a.value"
  104. [disabled]="isRunning" (click)="aspect = a.value">
  105. {{ a.label }}
  106. </button>
  107. </div>
  108. </div>
  109. <div class="dh-substep">
  110. <div class="dh-substep__head">
  111. <h4>全局一致性设定</h4>
  112. <span class="t2v-tip">作为默认锚点;每个分镜仍可自由改动作和构图</span>
  113. </div>
  114. <label class="t2v-toggle-row">
  115. <input type="checkbox" [(ngModel)]="continuityEnabled" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
  116. <span>启用角色 / 场景 / 产品一致性增强</span>
  117. </label>
  118. <div class="t2v-bible-grid" *ngIf="continuityEnabled">
  119. <input class="dh-input" type="text" placeholder="主体/角色:外貌、服装、标志物"
  120. [(ngModel)]="visualBible.subject" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
  121. <input class="dh-input" type="text" placeholder="产品:外观、卖点、品牌感(无产品可空)"
  122. [(ngModel)]="visualBible.product" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
  123. <input class="dh-input" type="text" placeholder="场景:地点、空间布局、时代"
  124. [(ngModel)]="visualBible.location" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
  125. <input class="dh-input" type="text" placeholder="色调:warm amber / cool teal 等"
  126. [(ngModel)]="visualBible.palette" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
  127. <input class="dh-input" type="text" placeholder="光线:golden hour / soft studio light 等"
  128. [(ngModel)]="visualBible.lighting" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
  129. <input class="dh-input" type="text" placeholder="镜头基调:handheld / dolly / close-up 等"
  130. [(ngModel)]="visualBible.camera" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
  131. </div>
  132. <textarea class="dh-textarea t2v-bible-notes" rows="2" maxlength="240" *ngIf="continuityEnabled"
  133. placeholder="固定风格与禁止变化项:例如不要换衣服、不要改变产品颜色、不要切换地点"
  134. [(ngModel)]="visualBible.constraints" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()"></textarea>
  135. <!-- 主体 / 产品 / 场景 参考图:可选上传,传给即梦做生成参考 -->
  136. <div class="t2v-bible-refs" *ngIf="continuityEnabled">
  137. <div class="t2v-bible-refs__head">
  138. <span class="t2v-bible-refs__title">参考图(可选)</span>
  139. <span class="t2v-tip">上传后会作为画面参考;可在分镜中单独调整是否使用</span>
  140. </div>
  141. <div class="t2v-bible-refs__grid">
  142. <ng-container *ngFor="let f of bibleRefSlots">
  143. <div class="t2v-bible-ref">
  144. <div class="t2v-bible-ref__label">{{ f.label }}</div>
  145. <div class="t2v-bible-ref__box"
  146. [class.is-empty]="!bibleImageUrlFor(f.field)"
  147. [class.is-busy]="bibleImageUploading[f.field]">
  148. <ng-container *ngIf="bibleImageUrlFor(f.field); else emptySlot">
  149. <a [href]="bibleImageUrlFor(f.field)" target="_blank" rel="noopener" title="点击查看大图">
  150. <img [src]="bibleImageUrlFor(f.field)" [alt]="f.label" loading="lazy">
  151. </a>
  152. <button type="button" class="t2v-bible-ref__clear"
  153. [disabled]="isRunning || hasInflightScene || bibleImageUploading[f.field]"
  154. (click)="clearBibleImage(f.field)" title="移除参考图">×</button>
  155. </ng-container>
  156. <ng-template #emptySlot>
  157. <label class="t2v-bible-ref__add">
  158. <input type="file" accept="image/*" hidden
  159. [disabled]="isRunning || hasInflightScene || bibleImageUploading[f.field]"
  160. (change)="onBibleImagePick(f.field, $event)">
  161. <span *ngIf="!bibleImageUploading[f.field]">+ 上传</span>
  162. <span *ngIf="bibleImageUploading[f.field]">上传中…</span>
  163. </label>
  164. </ng-template>
  165. </div>
  166. </div>
  167. </ng-container>
  168. </div>
  169. <div class="t2v-bible-refs__error" *ngIf="bibleImageError">{{ bibleImageError }}</div>
  170. </div>
  171. </div>
  172. <!-- 画面类型:仅快速模式 -->
  173. <div class="dh-substep" *ngIf="quickMode">
  174. <div class="dh-substep__head">
  175. <h4>画面类型</h4>
  176. <span class="t2v-tip" *ngIf="mediaMode === 'video'">每段画面会生成动态视频</span>
  177. </div>
  178. <div class="dh-segment">
  179. <button class="dh-segment__item"
  180. [class.is-active]="mediaMode === 'image'"
  181. [disabled]="isRunning" (click)="mediaMode = 'image'">静态图(快)</button>
  182. <button class="dh-segment__item"
  183. [class.is-active]="mediaMode === 'video'"
  184. [disabled]="isRunning" (click)="mediaMode = 'video'">动态视频(慢)</button>
  185. </div>
  186. <ng-container *ngIf="mediaMode === 'video'">
  187. <span class="t2v-sublabel">单段时长</span>
  188. <div class="dh-segment">
  189. <button class="dh-segment__item"
  190. [class.is-active]="clipFrames === 121"
  191. [disabled]="isRunning" (click)="clipFrames = 121">5 秒</button>
  192. <button class="dh-segment__item"
  193. [class.is-active]="clipFrames === 241"
  194. [disabled]="isRunning" (click)="clipFrames = 241">10 秒</button>
  195. </div>
  196. <span class="t2v-sublabel">视频质量</span>
  197. <div class="dh-segment">
  198. <button class="dh-segment__item"
  199. [class.is-active]="clipQuality === '720p'"
  200. [disabled]="isRunning" (click)="clipQuality = '720p'">720p</button>
  201. <button class="dh-segment__item"
  202. [class.is-active]="clipQuality === '1080p'"
  203. [disabled]="isRunning" (click)="clipQuality = '1080p'">1080p</button>
  204. </div>
  205. </ng-container>
  206. </div>
  207. <!-- 手动模式专属:图生视频默认设置(应用到每张卡的「生成视频」按钮) -->
  208. <div class="dh-substep" *ngIf="!quickMode">
  209. <div class="dh-substep__head">
  210. <h4>图生视频默认设置</h4>
  211. <span class="t2v-tip">手动模式下每段「生成视频」时使用</span>
  212. </div>
  213. <span class="t2v-sublabel">单段时长</span>
  214. <div class="dh-segment">
  215. <button class="dh-segment__item"
  216. [class.is-active]="clipFrames === 121"
  217. [disabled]="isRunning || hasInflightScene" (click)="clipFrames = 121">5 秒</button>
  218. <button class="dh-segment__item"
  219. [class.is-active]="clipFrames === 241"
  220. [disabled]="isRunning || hasInflightScene" (click)="clipFrames = 241">10 秒</button>
  221. </div>
  222. <span class="t2v-sublabel">视频质量</span>
  223. <div class="dh-segment">
  224. <button class="dh-segment__item"
  225. [class.is-active]="clipQuality === '720p'"
  226. [disabled]="isRunning || hasInflightScene" (click)="clipQuality = '720p'">720p</button>
  227. <button class="dh-segment__item"
  228. [class.is-active]="clipQuality === '1080p'"
  229. [disabled]="isRunning || hasInflightScene" (click)="clipQuality = '1080p'">1080p</button>
  230. </div>
  231. </div>
  232. <!-- 手动模式:可选标题,移到底部 -->
  233. <div class="dh-substep" *ngIf="!quickMode">
  234. <div class="dh-substep__head">
  235. <h4>视频标题(可选)</h4>
  236. <span class="t2v-tip">仅作保存命名,不影响生成</span>
  237. </div>
  238. <input class="dh-input" type="text" maxlength="60"
  239. placeholder="例如:周末一日 vlog"
  240. [(ngModel)]="topic" [disabled]="isRunning">
  241. </div>
  242. </div>
  243. <!-- ========================= 中:分镜 ========================= -->
  244. <div class="dh-step-col">
  245. <div class="dh-step-col__head">
  246. <span class="dh-step-col__step">Step 2</span>
  247. <span class="dh-step-col__title">分镜与素材</span>
  248. </div>
  249. <!-- ============ 快速模式:只读卡片 ============ -->
  250. <ng-container *ngIf="quickMode; else manualBlock">
  251. <div *ngIf="!scenes.length && stage === 'idle'" class="t2v-empty">
  252. <p>填写主题并点击右侧 <strong>开始生成</strong>,AI 将自动产出分镜画面。</p>
  253. </div>
  254. <div *ngIf="!scenes.length && stage === 'scripting'" class="t2v-empty">
  255. <p>🤖 AI 正在拆分分镜...</p>
  256. </div>
  257. <ul class="t2v-scene-list" *ngIf="scenes.length">
  258. <li *ngFor="let s of scenes; let i = index" class="t2v-scene-item">
  259. <div class="t2v-scene-item__head">
  260. <span class="t2v-scene-item__seq">#{{ i + 1 }}</span>
  261. <span class="t2v-scene-item__state"
  262. [class.is-pending]="['image-pending','tail-pending','video-pending'].includes(s.state)"
  263. [class.is-done]="['image-done','tail-done','video-done'].includes(s.state)"
  264. [class.is-failed]="['image-failed','tail-failed','video-failed'].includes(s.state)">
  265. <ng-container [ngSwitch]="s.state">
  266. <span *ngSwitchCase="'idle'">等待</span>
  267. <span *ngSwitchCase="'image-pending'">🖼️ 生成画面中</span>
  268. <span *ngSwitchCase="'image-done'">画面 ✓</span>
  269. <span *ngSwitchCase="'image-failed'">画面 ✗</span>
  270. <span *ngSwitchCase="'video-pending'">🎬 图生视频中</span>
  271. <span *ngSwitchCase="'video-done'">视频 ✓</span>
  272. <span *ngSwitchCase="'video-failed'">视频 ✗ (降静图)</span>
  273. </ng-container>
  274. </span>
  275. <span class="t2v-scene-item__media" *ngIf="s.videoUrl || s.imageUrl">
  276. <ng-container *ngIf="s.videoUrl">🎬</ng-container>
  277. <ng-container *ngIf="!s.videoUrl && s.imageUrl">🖼️</ng-container>
  278. </span>
  279. </div>
  280. <div class="t2v-scene-item__body">
  281. <video *ngIf="s.videoUrl" [src]="s.videoUrl" class="t2v-scene-item__img" muted playsinline
  282. onmouseenter="this.play()" onmouseleave="this.pause()"></video>
  283. <img *ngIf="!s.videoUrl && s.imageUrl" [src]="s.imageUrl" alt="" class="t2v-scene-item__img">
  284. <div *ngIf="!s.imageUrl && !s.videoUrl" class="t2v-scene-item__img t2v-scene-item__img--ph"></div>
  285. <div class="t2v-scene-item__text">
  286. <p *ngIf="s.narration" class="t2v-scene-item__narration">{{ s.narration }}</p>
  287. <p class="t2v-scene-item__prompt" [title]="s.scenePrompt">{{ s.scenePrompt }}</p>
  288. <p *ngIf="s.error" class="t2v-scene-item__error">{{ s.error }}</p>
  289. </div>
  290. </div>
  291. </li>
  292. </ul>
  293. </ng-container>
  294. <!-- ============ 手动模式:可编辑卡片(首/尾帧 + 上传) ============ -->
  295. <ng-template #manualBlock>
  296. <div *ngIf="!scenes.length" class="t2v-empty">
  297. <p>点击下方 <strong>➕ 添加分镜</strong> 添加卡片。每卡可独立选「仅首帧」或「首尾帧」i2v 模式,每个帧位都支持 AI 生成或本地上传。</p>
  298. </div>
  299. <ul class="t2v-scene-list" *ngIf="scenes.length">
  300. <li *ngFor="let s of scenes; let i = index" class="t2v-scene-item t2v-scene-item--manual">
  301. <!-- 头部:序号 + 状态 + 删除 -->
  302. <div class="t2v-scene-item__head">
  303. <span class="t2v-scene-item__seq">#{{ i + 1 }}</span>
  304. <span class="t2v-scene-item__state"
  305. [class.is-pending]="['image-pending','tail-pending','video-pending'].includes(s.state)"
  306. [class.is-done]="['image-done','tail-done','video-done'].includes(s.state)"
  307. [class.is-failed]="['image-failed','tail-failed','video-failed'].includes(s.state)">
  308. <ng-container [ngSwitch]="s.state">
  309. <span *ngSwitchCase="'idle'">未开始</span>
  310. <span *ngSwitchCase="'image-pending'">⏳ 处理首帧...</span>
  311. <span *ngSwitchCase="'image-done'">首帧 ✓</span>
  312. <span *ngSwitchCase="'image-failed'">首帧 ✗</span>
  313. <span *ngSwitchCase="'tail-pending'">⏳ 处理尾帧...</span>
  314. <span *ngSwitchCase="'tail-done'">尾帧 ✓</span>
  315. <span *ngSwitchCase="'tail-failed'">尾帧 ✗</span>
  316. <span *ngSwitchCase="'video-pending'">🎬 生成视频...</span>
  317. <span *ngSwitchCase="'video-done'">视频 ✓</span>
  318. <span *ngSwitchCase="'video-failed'">视频 ✗</span>
  319. </ng-container>
  320. </span>
  321. <button class="t2v-scene-item__del" type="button"
  322. [disabled]="hasInflightScene"
  323. title="删除该分镜" (click)="removeScene(i)">✖</button>
  324. </div>
  325. <!-- 1. 画面描述(始终可编辑) -->
  326. <label class="t2v-card-label">画面描述</label>
  327. <textarea class="dh-textarea t2v-scene-item__edit" rows="2" maxlength="300"
  328. placeholder="中英文均可,推荐英文。例:a woman walks in dawn fog, golden hour, wide shot, slow zoom in"
  329. [(ngModel)]="s.scenePrompt"
  330. (ngModelChange)="syncDraft()"
  331. [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)"></textarea>
  332. <!-- 2. 视频模式选择 -->
  333. <div class="t2v-shot-controls" *ngIf="continuityEnabled">
  334. <label class="t2v-card-label">镜头类型</label>
  335. <div class="dh-segment dh-segment--wrap">
  336. <button class="dh-segment__item" type="button"
  337. *ngFor="let t of shotTypePresets"
  338. [class.is-active]="(s.shotType || 'action') === t.value"
  339. [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)"
  340. (click)="setSceneShotType(s, t.value)">
  341. {{ t.label }}
  342. </button>
  343. </div>
  344. <label class="t2v-card-label">继承内容</label>
  345. <div class="t2v-inherit-row">
  346. <label><input type="checkbox" [(ngModel)]="s.inheritSubject" (ngModelChange)="syncDraft()"> 主体</label>
  347. <label><input type="checkbox" [(ngModel)]="s.inheritLocation" (ngModelChange)="syncDraft()"> 场景</label>
  348. <label><input type="checkbox" [(ngModel)]="s.inheritStyle" (ngModelChange)="syncDraft()"> 风格</label>
  349. </div>
  350. <label class="t2v-card-label">参考图</label>
  351. <div class="dh-segment dh-segment--wrap">
  352. <button class="dh-segment__item" type="button"
  353. *ngFor="let r of referenceModePresets"
  354. [class.is-active]="(s.referenceMode || 'auto') === r.value"
  355. [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)"
  356. (click)="s.referenceMode = r.value; syncDraft()">
  357. {{ r.label }}
  358. </button>
  359. </div>
  360. </div>
  361. <div class="t2v-continuity-fields" *ngIf="continuityEnabled">
  362. <input class="dh-input" type="text" maxlength="180"
  363. placeholder="起始状态:承接上一镜,可留空"
  364. [(ngModel)]="s.startState" (ngModelChange)="syncDraft()"
  365. [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)">
  366. <input class="dh-input" type="text" maxlength="180"
  367. placeholder="结束状态:用于尾帧和下一镜,可留空"
  368. [(ngModel)]="s.endState" (ngModelChange)="syncDraft()"
  369. [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)">
  370. </div>
  371. <label class="t2v-card-label" style="margin-top:14px;">图生视频模式</label>
  372. <div class="dh-segment t2v-vmode-seg">
  373. <button class="dh-segment__item"
  374. [class.is-active]="(s.videoMode || 'first') === 'first'"
  375. [disabled]="hasInflightScene"
  376. (click)="setSceneVideoMode(i, 'first')">
  377. 仅首帧
  378. <span class="t2v-vmode-hint">1 张图 · 通用</span>
  379. </button>
  380. <button class="dh-segment__item"
  381. [class.is-active]="s.videoMode === 'first-last'"
  382. [disabled]="hasInflightScene"
  383. (click)="setSceneVideoMode(i, 'first-last')">
  384. 首尾帧
  385. <span class="t2v-vmode-hint">2 张图 · 更平滑</span>
  386. </button>
  387. </div>
  388. <!-- 3. 帧位编辑器:首帧 + (可选) 尾帧 -->
  389. <div class="t2v-frames" [class.t2v-frames--dual]="s.videoMode === 'first-last'">
  390. <!-- 首帧 -->
  391. <div class="t2v-frame-slot">
  392. <div class="t2v-frame-slot__head">
  393. <span class="t2v-frame-slot__title">首帧</span>
  394. <span class="t2v-frame-slot__source"
  395. *ngIf="s.imageUrl"
  396. [class.is-upload]="s.imageSource === 'upload'">
  397. {{ s.imageSource === 'upload' ? '上传' : 'AI' }}
  398. </span>
  399. </div>
  400. <!-- 缩略图 / 占位 / 加载 -->
  401. <div class="t2v-frame-slot__thumb">
  402. <div *ngIf="s.pendingTarget === 'head' && (s.state === 'image-pending')"
  403. class="t2v-frame-slot__loading">
  404. <span class="t2v-spin"></span>
  405. <span>处理中...</span>
  406. </div>
  407. <a *ngIf="s.imageUrl && s.pendingTarget !== 'head'"
  408. [href]="s.imageUrl" target="_blank" rel="noopener"
  409. title="点击查看原图">
  410. <img [src]="s.imageUrl" alt="首帧">
  411. </a>
  412. <div *ngIf="!s.imageUrl && s.pendingTarget !== 'head'" class="t2v-frame-slot__ph">
  413. <span>无首帧</span>
  414. </div>
  415. </div>
  416. <!-- 操作 -->
  417. <div class="t2v-frame-slot__ops">
  418. <button class="dh-chip-btn"
  419. [class.is-primary]="!s.imageUrl"
  420. [disabled]="!s.scenePrompt.trim() || hasInflightScene"
  421. (click)="regenerateImage(i)">
  422. <span *ngIf="!s.imageUrl">▶ AI 生成</span>
  423. <span *ngIf="s.imageUrl">🔄 AI 重生</span>
  424. </button>
  425. <input #headInput type="file" accept="image/*"
  426. hidden [disabled]="hasInflightScene"
  427. (change)="onSceneFileChange(i, 'head', $event)">
  428. <button class="dh-chip-btn"
  429. [disabled]="hasInflightScene"
  430. (click)="headInput.click()">📤 上传</button>
  431. <button class="dh-chip-btn"
  432. *ngIf="s.imageUrl"
  433. [disabled]="hasInflightScene"
  434. (click)="clearSceneImage(i, 'head')"
  435. title="清空首帧">×</button>
  436. </div>
  437. </div>
  438. <!-- 尾帧(仅首尾帧模式) -->
  439. <div class="t2v-frame-slot" *ngIf="s.videoMode === 'first-last'">
  440. <div class="t2v-frame-slot__head">
  441. <span class="t2v-frame-slot__title">尾帧</span>
  442. <span class="t2v-frame-slot__source"
  443. *ngIf="s.tailImageUrl"
  444. [class.is-upload]="s.tailImageSource === 'upload'">
  445. {{ s.tailImageSource === 'upload' ? '上传' : 'AI' }}
  446. </span>
  447. </div>
  448. <div class="t2v-frame-slot__thumb">
  449. <div *ngIf="s.pendingTarget === 'tail'"
  450. class="t2v-frame-slot__loading">
  451. <span class="t2v-spin"></span>
  452. <span>处理中...</span>
  453. </div>
  454. <a *ngIf="s.tailImageUrl && s.pendingTarget !== 'tail'"
  455. [href]="s.tailImageUrl" target="_blank" rel="noopener"
  456. title="点击查看原图">
  457. <img [src]="s.tailImageUrl" alt="尾帧">
  458. </a>
  459. <div *ngIf="!s.tailImageUrl && s.pendingTarget !== 'tail'" class="t2v-frame-slot__ph">
  460. <span>无尾帧</span>
  461. </div>
  462. </div>
  463. <div class="t2v-frame-slot__ops">
  464. <button class="dh-chip-btn"
  465. [class.is-primary]="!s.tailImageUrl"
  466. [disabled]="!s.scenePrompt.trim() || hasInflightScene"
  467. (click)="regenerateTailImage(i)">
  468. <span *ngIf="!s.tailImageUrl">▶ AI 生成</span>
  469. <span *ngIf="s.tailImageUrl">🔄 AI 重生</span>
  470. </button>
  471. <input #tailInput type="file" accept="image/*"
  472. hidden [disabled]="hasInflightScene"
  473. (change)="onSceneFileChange(i, 'tail', $event)">
  474. <button class="dh-chip-btn"
  475. [disabled]="hasInflightScene"
  476. (click)="tailInput.click()">📤 上传</button>
  477. <button class="dh-chip-btn"
  478. *ngIf="s.tailImageUrl"
  479. [disabled]="hasInflightScene"
  480. (click)="clearSceneImage(i, 'tail')"
  481. title="清空尾帧">×</button>
  482. </div>
  483. </div>
  484. </div>
  485. <!-- 4. 视频预览(已生成时) -->
  486. <div *ngIf="s.videoUrl" class="t2v-video-preview">
  487. <video [src]="s.videoUrl" controls muted playsinline></video>
  488. </div>
  489. <!-- 5. 错误 -->
  490. <p *ngIf="s.error" class="t2v-scene-item__error">{{ s.error }}</p>
  491. <!-- 6. 视频操作 -->
  492. <div class="t2v-scene-item__actions">
  493. <ng-container *ngIf="s.imageUrl && (s.videoMode !== 'first-last' || s.tailImageUrl)">
  494. <button class="dh-chip-btn is-primary"
  495. [disabled]="hasInflightScene"
  496. (click)="regenerateVideo(i)">
  497. <span *ngIf="!s.videoUrl">🎬 生成视频</span>
  498. <span *ngIf="s.videoUrl">🔄 重生视频</span>
  499. </button>
  500. </ng-container>
  501. <span *ngIf="!s.imageUrl" class="t2v-tip">先生成或上传首帧</span>
  502. <span *ngIf="s.imageUrl && s.videoMode === 'first-last' && !s.tailImageUrl" class="t2v-tip">首尾帧模式需要尾帧</span>
  503. </div>
  504. </li>
  505. </ul>
  506. <button class="t2v-add-scene" type="button"
  507. [disabled]="hasInflightScene"
  508. (click)="addManualScene()">➕ 添加分镜</button>
  509. </ng-template>
  510. </div>
  511. <!-- ========================= 右:操作与结果 ========================= -->
  512. <div class="dh-step-col">
  513. <div class="dh-step-col__head">
  514. <span class="dh-step-col__step">Step 3</span>
  515. <span class="dh-step-col__title">生成与预览</span>
  516. </div>
  517. <div class="dh-substep pl-action-area">
  518. <button class="pl-cta-primary"
  519. type="button"
  520. [disabled]="!canStart"
  521. [attr.data-loading]="isRunning ? 'true' : null"
  522. (click)="start()">
  523. <span *ngIf="!isRunning && !resultVideoUrl">
  524. {{ quickMode ? '▶ 开始生成视频' : '▶ 合成最终视频' }}
  525. </span>
  526. <span *ngIf="!isRunning && resultVideoUrl">🔄 重新合成</span>
  527. <span *ngIf="isRunning">{{ totalProgress }}% {{ quickMode ? '生成中' : '合成中' }}</span>
  528. </button>
  529. <button *ngIf="isRunning" class="dh-chip-btn" (click)="cancel()">取消</button>
  530. </div>
  531. <!-- 手动模式:当前有几段已就绪 -->
  532. <div *ngIf="!quickMode && scenes.length > 0" class="dh-substep">
  533. <div class="dh-substep__head"><h4>分镜状态</h4></div>
  534. <div class="t2v-scene-summary">
  535. <span class="t2v-scene-summary__chip">
  536. 共 <strong>{{ scenes.length }}</strong> 段
  537. </span>
  538. <span class="t2v-scene-summary__chip is-ok">
  539. ✓ 已就绪 <strong>{{ readyCount }}</strong>
  540. </span>
  541. <span class="t2v-scene-summary__chip" *ngIf="videoReadyCount > 0">
  542. 🎬 含视频 <strong>{{ videoReadyCount }}</strong>
  543. </span>
  544. </div>
  545. </div>
  546. <div *ngIf="stage !== 'idle'" class="dh-substep">
  547. <div class="dh-substep__head"><h4>总体进度</h4></div>
  548. <div class="pl-progress">
  549. <div class="pl-progress__bar"><div class="pl-progress__fill" [style.width.%]="totalProgress"></div></div>
  550. <div class="pl-progress__text">
  551. <span>{{ stageStatus || '准备中...' }}</span>
  552. <span class="pl-progress__pct">{{ totalProgress }}%</span>
  553. </div>
  554. </div>
  555. <ul class="t2v-stage-list">
  556. <li *ngIf="quickMode"
  557. [class.is-active]="stage === 'scripting'"
  558. [class.is-done]="['imaging','videoing','composing','done'].includes(stage)">
  559. <span class="t2v-stage-list__dot"></span>
  560. <span>📝 AI 分镜拆分</span>
  561. </li>
  562. <li *ngIf="quickMode"
  563. [class.is-active]="stage === 'imaging'"
  564. [class.is-done]="['videoing','composing','done'].includes(stage)">
  565. <span class="t2v-stage-list__dot"></span>
  566. <span>🖼️ 生成画面({{ imageProgress }}%)</span>
  567. </li>
  568. <li *ngIf="quickMode && mediaMode === 'video'"
  569. [class.is-active]="stage === 'videoing'"
  570. [class.is-done]="['composing','done'].includes(stage)">
  571. <span class="t2v-stage-list__dot"></span>
  572. <span>🎞️ 生成动态片段({{ videoProgress }}%)</span>
  573. </li>
  574. <li [class.is-active]="stage === 'composing'" [class.is-done]="stage === 'done'">
  575. <span class="t2v-stage-list__dot"></span>
  576. <span>🎬 合成视频({{ composeProgress }}%)</span>
  577. </li>
  578. </ul>
  579. </div>
  580. <div *ngIf="errorMsg" class="pl-error-card">
  581. <strong>失败:</strong>{{ errorMsg }}
  582. </div>
  583. <div *ngIf="resultVideoUrl" class="dh-substep">
  584. <div class="dh-substep__head"><h4>生成结果</h4></div>
  585. <video class="pl-result-video" [src]="resultVideoUrl" controls playsinline></video>
  586. <div class="t2v-case-summary" *ngIf="caseSummary.total > 0">
  587. <span class="t2v-case-summary__label">合成明细:</span>
  588. <span class="t2v-case-chip" *ngIf="caseSummary.C" title="动态视频段">动态视频 × {{ caseSummary.C }}</span>
  589. <span class="t2v-case-chip" *ngIf="caseSummary.D" title="静态画面段">静态画面 × {{ caseSummary.D }}</span>
  590. </div>
  591. <div class="pl-result-actions">
  592. <a class="dh-chip-btn" [href]="resultVideoUrl" target="_blank" rel="noopener">在新窗口打开</a>
  593. <a class="dh-chip-btn" [href]="resultVideoUrl" [download]="(topic || 'topic-to-video') + '.mp4'">下载视频</a>
  594. </div>
  595. </div>
  596. </div>
  597. </div>
  598. </section>