topic-to-video.component.html 35 KB

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