topic-to-video.component.html 32 KB

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