Kaynağa Gözat

modified: src/app/app.css
modified: src/app/app.html
modified: src/app/app.ts
new file: src/app/components/digital-human-form/digital-human-form.component.ts
new file: src/app/components/portrait-prepare/portrait-prepare.component.ts
new file: src/app/services/digital-human.service.ts
modified: src/app/services/jimeng.service.ts
new file: src/app/services/portrait-pipeline.service.ts

Yi Jiarui 4 ay önce
ebeveyn
işleme
f961758994

+ 14 - 0
src/app/app.css

@@ -6641,6 +6641,12 @@ code {
   padding: 16px 18px 18px;
   padding: 16px 18px 18px;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
+  /* 内部滚动:列高跟随父级,内容超出时只滚动列内部,不撑高整个 board */
+  max-height: calc(100vh - 220px);
+  overflow-y: auto;
+  overflow-x: hidden;
+  scrollbar-width: thin;
+  scrollbar-color: var(--dh-border) transparent;
   gap: 12px;
   gap: 12px;
   flex: 1;
   flex: 1;
 }
 }
@@ -6694,6 +6700,14 @@ code {
   border-color: rgba(52, 211, 153, 0.4) !important;
   border-color: rgba(52, 211, 153, 0.4) !important;
   color: #34d399 !important;
   color: #34d399 !important;
 }
 }
+.dh-step-col__body::-webkit-scrollbar { width: 6px; }
+.dh-step-col__body::-webkit-scrollbar-track { background: transparent; }
+.dh-step-col__body::-webkit-scrollbar-thumb {
+  background: var(--dh-border);
+  border-radius: 3px;
+}
+.dh-step-col__body::-webkit-scrollbar-thumb:hover { background: var(--dh-text-3); }
+
 .dh-step-col__stack { display: flex; flex-direction: column; gap: 12px; }
 .dh-step-col__stack { display: flex; flex-direction: column; gap: 12px; }
 .dh-step-col__hint { font-size: 11.5px; color: var(--dh-text-3); margin-top: 10px; line-height: 1.55; }
 .dh-step-col__hint { font-size: 11.5px; color: var(--dh-text-3); margin-top: 10px; line-height: 1.55; }
 
 

+ 36 - 259
src/app/app.html

@@ -1,4 +1,4 @@
-<div class="app-container" [class.theme-day]="themeMode === 'day'" [class.theme-night]="themeMode === 'night'">
+<div class="app-container" [class.theme-day]="themeMode === 'day'" [class.theme-night]="themeMode === 'night'">
 
 
   <!-- 头部导航 -->
   <!-- 头部导航 -->
 
 
@@ -3481,6 +3481,15 @@
                   </label>
                   </label>
                   <input class="dh-input" type="text" [(ngModel)]="dhImageName" maxlength="60" placeholder="请输入数字人名称">
                   <input class="dh-input" type="text" [(ngModel)]="dhImageName" maxlength="60" placeholder="请输入数字人名称">
                 </div>
                 </div>
+
+                <!-- 形象准备:raw / 自拍优化 / 对标账号重塑(仅当上传图后显示) -->
+                <div *ngIf="dhImageFile" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--border-color)">
+                  <app-portrait-prepare
+                    [selfieFile]="dhImageFile"
+                    [selfiePreviewUrl]="dhImagePreviewUrl"
+                    (prepared)="onDhPortraitPrepared($event)">
+                  </app-portrait-prepare>
+                </div>
               </div>
               </div>
 
 
               <!-- 音频来源 -->
               <!-- 音频来源 -->
@@ -5252,161 +5261,27 @@
 
 
           <div class="remix-section">
           <div class="remix-section">
 
 
-            <label class="remix-label">数字人形象图</label>
-
-            <div class="remix-digital-human-box">
-
-              <div class="remix-digital-human-tip">请上传一张清晰的单人照片,并选择音频来源:可使用已复刻音色合成语音,或直接使用原视频音频。</div>
-
-              <input type="file"
-
-                     id="remixDigitalHumanImageInput"
-
-                     accept="image/jpeg,image/png,image/jpg,image/gif,.jpg,.jpeg,.png,.gif"
-
-                     (change)="onRemixDigitalHumanImageSelected($event)"
-
-                     style="display:none">
-
-
-
-              <div class="remix-no-transcript" *ngIf="!remixDigitalHumanImagePreviewUrl">
-
-                <div class="remix-no-transcript-icon">
-
-                  <span class="icon" style="font-size:36px;color:var(--text-muted)">add_photo_alternate</span>
-
-                </div>
-
-                <div class="remix-no-transcript-text">上传人物形象图后即可继续</div>
-
-                <div class="remix-no-transcript-actions">
-
-                  <label for="remixDigitalHumanImageInput" class="btn btn-primary" style="cursor:pointer;display:inline-flex">
-
-                    <span class="icon">upload</span> 选择形象图
-
-                  </label>
-
-                </div>
-
-              </div>
-
-
-
-              <div class="remix-digital-human-preview" *ngIf="remixDigitalHumanImagePreviewUrl">
-
-                <img [src]="remixDigitalHumanImagePreviewUrl" [alt]="remixDigitalHumanImageName || '数字人形象图'">
-
-                <div class="remix-digital-human-meta">
-
-                  <div>
-
-                    <div class="remix-digital-human-name">{{remixDigitalHumanImageName}}</div>
-
-                    <div class="remix-hint">支持 jpg、jpeg、png、gif,最大 20MB</div>
-
-                  </div>
-
-                  <button class="btn" (click)="clearRemixDigitalHumanImage()">
-
-                    <span class="icon">delete</span> 移除
-
-                  </button>
-
-                </div>
-
-              </div>
-
-
-
-              <div class="storyboard-error" *ngIf="remixDigitalHumanError">{{remixDigitalHumanError}}</div>
-
-            </div>
-
-          </div>
-
-
-
-          <div class="remix-section">
-
-            <label class="remix-label">音频来源</label>
-
-            <div class="remix-mode-grid">
-
-              <div class="remix-mode-card"
-
-                   [class.active]="remixDigitalHumanAudioSource === 'tts'"
-
-                   (click)="remixDigitalHumanAudioSource = 'tts'; remixDigitalHumanError = ''">
-
-                <div class="remix-mode-title">语音合成</div>
-
-                <div class="remix-mode-desc">输入文本 + 选择已复刻音色,合成语音作为数字人口播音频。</div>
-
-              </div>
-
-              <div class="remix-mode-card"
-
-                   [class.active]="remixDigitalHumanAudioSource === 'original'"
-
-                   (click)="remixDigitalHumanAudioSource = 'original'; remixDigitalHumanError = ''">
-
-                <div class="remix-mode-title">原视频音频</div>
-
-                <div class="remix-mode-desc">提取当前视频音频作为数字人口播音频(时长需小于 60 秒)。</div>
-
-              </div>
-
-            </div>
-
-
-
-            <div *ngIf="remixDigitalHumanAudioSource === 'tts'" style="margin-top:14px;display:flex;flex-direction:column;gap:12px">
-
-              <div class="form-field">
-
-                <label>音色 ID(timbreId)*</label>
-
-                <input type="text" [(ngModel)]="remixDigitalHumanTtsTimbreId"
-
-                       placeholder="在「语音合成」标签页复刻音色后获得的 objectId">
-
-              </div>
-
-
-
-              <div *ngIf="vsClonedTimbreList.length > 0" style="display:flex;flex-wrap:wrap;gap:6px">
-
-                <button class="btn" *ngFor="let t of vsClonedTimbreList"
-
-                        [class.btn-primary]="remixDigitalHumanTtsTimbreId === t.objectId"
+            <app-digital-human-form
 
 
-                        (click)="remixDigitalHumanTtsTimbreId = t.objectId"
+              [audioSourceOptions]="['tts', 'original']"
 
 
-                        style="font-size:12px">
+              [showPortraitPipeline]="true"
 
 
-                  {{t.name}}
-
-                </button>
-
-              </div>
+              [timbreOptions]="remixTimbreOptions"
 
 
+              [promptPresets]="digitalHumanPromptPresets"
 
 
+              [originalAudioVideoId]="remixTargetVideo?.id || ''"
 
 
-              <div class="form-field">
+              [originalAudioLabel]="remixTargetVideo?.title || ''"
 
 
-                <label>口播文本 *</label>
+              [submitting]="isRemixSubmitting"
 
 
-                <textarea [(ngModel)]="remixDigitalHumanTtsText" rows="4"
+              submitButtonLabel="开始生成数字人视频"
 
 
-                          placeholder="输入数字人要说的话,将使用上面选择的音色进行语音合成..."
+              (request)="onRemixDhRequest($event)">
 
 
-                          style="resize:vertical;min-height:80px"></textarea>
-
-              </div>
-
-            </div>
+            </app-digital-human-form>
 
 
           </div>
           </div>
 
 
@@ -5604,113 +5479,7 @@
 
 
 
 
 
 
-        <ng-container *ngIf="remixMode === 'digital-human'">
-
-          <div class="remix-section">
-
-            <label class="remix-label">数字人生成参数</label>
-
-            <div class="remix-params remix-params-digital-human">
-
-              <div class="remix-param-item">
-
-                <label class="remix-label">输出清晰度</label>
-
-                <select [(ngModel)]="remixDigitalHumanResolution">
-
-                  <option value="1080p">1080P</option>
-
-                  <option value="720p">720P</option>
-
-                </select>
-
-              </div>
-
-              <div class="remix-param-item remix-param-checkbox">
-
-                <label class="remix-checkbox-label">
-
-                  <input type="checkbox" [(ngModel)]="remixDigitalHumanFastMode">
-
-                  启用快速模式
-
-                </label>
-
-                <div class="remix-hint">720P 会优先按快速模式提交,适合更快出结果。</div>
-
-              </div>
-
-            </div>
-
-          </div>
-
-
-
-          <div class="remix-section">
-
-            <label class="remix-label">音频来源确认</label>
-
-            <div *ngIf="remixDigitalHumanAudioSource === 'tts'" style="padding:12px;background:var(--bg-tertiary);border-radius:8px;border:1px solid var(--border-color);font-size:13px">
-
-              <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
-
-                <span class="icon" style="color:var(--accent-color)">record_voice_over</span>
-
-                <strong>语音合成模式</strong>
-
-              </div>
-
-              <div style="color:var(--text-secondary)">音色 ID:<code>{{remixDigitalHumanTtsTimbreId}}</code></div>
-
-              <div style="color:var(--text-secondary);margin-top:4px">口播文本:{{remixDigitalHumanTtsText.length > 60 ? remixDigitalHumanTtsText.substring(0, 60) + '...' : remixDigitalHumanTtsText}}</div>
-
-            </div>
-
-            <div *ngIf="remixDigitalHumanAudioSource === 'original'" style="padding:12px;background:var(--bg-tertiary);border-radius:8px;border:1px solid var(--border-color);font-size:13px">
-
-              <div style="display:flex;align-items:center;gap:6px">
-
-                <span class="icon" style="color:var(--accent-color)">music_note</span>
-
-                <strong>原视频音频模式</strong> — 提取当前视频音频作为数字人口播音频
-
-              </div>
-
-            </div>
-
-          </div>
-
-
-
-          <div class="remix-section">
-
-            <label class="remix-label">补充提示词</label>
-
-            <textarea class="remix-textarea"
-
-                      [(ngModel)]="remixDigitalHumanPrompt"
-
-                      rows="4"
-
-                      placeholder="可选:补充人物动作、镜头语言或风格要求..."></textarea>
-
-            <div class="remix-hint">当前音频来源:{{remixDigitalHumanAudioSource === 'tts' ? '语音合成' : '原视频提取音频'}}</div>
-
-            <div class="storyboard-error" *ngIf="remixDigitalHumanError">{{remixDigitalHumanError}}</div>
-
-          </div>
-
-
-
-          <div class="remix-cost">
-
-            <span class="icon">movie</span>
-
-            本次将生成 <strong>1</strong> 条数字人视频,输出清晰度为 <strong>{{remixDigitalHumanResolution}}</strong>,音频来源为 <strong>{{remixDigitalHumanAudioSource === 'tts' ? '语音合成' : '原视频提取音频'}}</strong>
-
-          </div>
-
-        </ng-container>
+        
 
 
       </div>
       </div>
 
 
@@ -5962,14 +5731,22 @@
 
 
         <button class="btn btn-primary"
         <button class="btn btn-primary"
 
 
+                *ngIf="remixMode === 'standard'"
+
                 (click)="goToRemixGenerationConfig()"
                 (click)="goToRemixGenerationConfig()"
 
 
-                [disabled]="remixMode === 'digital-human' ? !remixDigitalHumanImageFile : !remixTranscript.trim()">
+                [disabled]="!remixTranscript.trim()">
 
 
-          {{remixMode === 'digital-human' ? '下一步:生成参数' : '下一步:AI美化'}} <span class="icon">arrow_forward</span>
+          下一步:AI美化 <span class="icon">arrow_forward</span>
 
 
         </button>
         </button>
 
 
+        <div class="remix-footer-status" *ngIf="remixMode === 'digital-human'" style="font-size:12px;color:var(--text-muted)">
+
+          完成表单后点击右下方「开始生成数字人视频」按钮
+
+        </div>
+
       </ng-container>
       </ng-container>
 
 
       <!-- Step 2 -->
       <!-- Step 2 -->
@@ -6000,15 +5777,15 @@
 
 
       </ng-container>
       </ng-container>
 
 
-      <!-- Step 4 -->
+      <!-- Step 4(仅标准重塑模式)-->
 
 
-      <ng-container *ngIf="remixStep === 4">
+      <ng-container *ngIf="remixStep === 4 && remixMode === 'standard'">
 
 
-        <button class="btn" (click)="remixStep = remixMode === 'digital-human' ? 1 : 3"><span class="icon">arrow_back</span> 上一步</button>
+        <button class="btn" (click)="remixStep = 3"><span class="icon">arrow_back</span> 上一步</button>
 
 
         <button class="btn btn-primary" (click)="startBatchGeneration()">
         <button class="btn btn-primary" (click)="startBatchGeneration()">
 
 
-          <span class="icon">auto_awesome</span> {{remixMode === 'digital-human' ? '开始生成数字人视频' : '开始生成 (' + remixStoryboard.length + ' 个片段)'}}
+          <span class="icon">auto_awesome</span> 开始生成 ({{remixStoryboard.length}} 个片段)
 
 
         </button>
         </button>
 
 

Dosya farkı çok büyük olduğundan ihmal edildi
+ 859 - 163
src/app/app.ts


+ 490 - 0
src/app/components/digital-human-form/digital-human-form.component.ts

@@ -0,0 +1,490 @@
+import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { PortraitPrepareComponent, PortraitPrepareOutput } from '../portrait-prepare/portrait-prepare.component';
+import {
+  AudioSourceMode,
+  DigitalHumanRequest
+} from '../../services/digital-human.service';
+
+export interface TimbreOption {
+  /** 唯一 id(即 timbre_id 或 speaker_id) */
+  id: string;
+  /** 显示名 */
+  name: string;
+  /** 试听 URL(可选) */
+  demoUrl?: string;
+}
+
+export interface PromptPreset {
+  label: string;
+  value: string;
+}
+
+/**
+ * 共享的"数字人表单"组件:
+ *  - 形象图上传 + 形象准备(normalize / benchmark)
+ *  - 音频来源(tts / upload / original)
+ *  - 提示词 + 预设
+ *  - 分辨率 / fast mode
+ *  - 提交按钮 → emit DigitalHumanRequest
+ *
+ * 父组件接管真正的生成调用(任务管理、进度同步、历史记录),
+ * 组件本身保持 dumb,便于在多处复用。
+ */
+@Component({
+  selector: 'app-digital-human-form',
+  standalone: true,
+  imports: [CommonModule, FormsModule, PortraitPrepareComponent],
+  template: `
+    <div class="dhf-container">
+
+      <!-- ① 形象图上传 -->
+      <div class="dhf-section">
+        <label class="dhf-label">数字人形象图</label>
+        <input type="file" #imageInput
+               accept="image/jpeg,image/png,image/jpg,image/gif,.jpg,.jpeg,.png,.gif"
+               (change)="onImageSelected($event)" style="display:none">
+
+        <div class="dhf-empty" *ngIf="!imagePreviewUrl">
+          <span class="icon dhf-empty-icon">add_photo_alternate</span>
+          <div class="dhf-empty-text">上传一张清晰的单人照片(jpg/png/gif,最大 20MB)</div>
+          <button class="btn btn-primary" type="button" (click)="imageInput.click()">
+            <span class="icon">upload</span> 选择形象图
+          </button>
+        </div>
+
+        <div class="dhf-preview" *ngIf="imagePreviewUrl">
+          <img [src]="imagePreviewUrl" [alt]="imageFileName">
+          <div class="dhf-preview-meta">
+            <div>
+              <div class="dhf-preview-name">{{imageFileName}}</div>
+              <div class="dhf-hint">支持 jpg、jpeg、png、gif,最大 20MB</div>
+            </div>
+            <button class="btn" type="button" (click)="clearImage()" [disabled]="submitting">
+              <span class="icon">delete</span> 移除
+            </button>
+          </div>
+        </div>
+
+        <div class="dhf-error" *ngIf="imageError">{{imageError}}</div>
+      </div>
+
+      <!-- ② 形象准备(自拍优化 / 对标重塑),仅当有图时显示 -->
+      <div class="dhf-section" *ngIf="showPortraitPipeline && imageFile">
+        <app-portrait-prepare
+          [selfieFile]="imageFile"
+          [selfiePreviewUrl]="imagePreviewUrl"
+          (prepared)="onPortraitPrepared($event)">
+        </app-portrait-prepare>
+      </div>
+
+      <!-- ③ 音频来源 -->
+      <div class="dhf-section" *ngIf="audioSourceOptions.length > 1">
+        <label class="dhf-label">音频来源</label>
+        <div class="dhf-mode-grid">
+          <div *ngFor="let opt of audioSourceOptions"
+               class="dhf-mode-card"
+               [class.active]="audioSource === opt"
+               (click)="setAudioSource(opt)">
+            <div class="dhf-mode-title">{{audioSourceLabel(opt)}}</div>
+            <div class="dhf-mode-desc">{{audioSourceDesc(opt)}}</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- TTS 面板 -->
+      <div class="dhf-section" *ngIf="audioSource === 'tts'">
+        <label class="dhf-label">TTS 文本</label>
+        <textarea class="dhf-textarea" rows="4"
+                  [(ngModel)]="ttsText"
+                  placeholder="请输入数字人要朗读的口播文本"></textarea>
+
+        <label class="dhf-label" style="margin-top:10px">音色</label>
+        <select class="dhf-select" [(ngModel)]="ttsTimbreId">
+          <option value="">请选择音色</option>
+          <option *ngFor="let t of timbreOptions" [value]="t.id">{{t.name}}</option>
+        </select>
+        <div class="dhf-hint" *ngIf="timbreOptions.length === 0">
+          暂无可用音色,请先在「音色合成」模块创建。
+        </div>
+      </div>
+
+      <!-- 上传音频面板 -->
+      <div class="dhf-section" *ngIf="audioSource === 'upload'">
+        <label class="dhf-label">音频文件</label>
+        <input type="file" #audioInput
+               accept="audio/mpeg,audio/mp3,audio/wav,audio/m4a,audio/aac,.mp3,.wav,.m4a,.aac"
+               (change)="onAudioSelected($event)" style="display:none">
+
+        <div class="dhf-empty" *ngIf="!audioFile">
+          <span class="icon dhf-empty-icon">audiotrack</span>
+          <div class="dhf-empty-text">上传 mp3 / wav / m4a / aac 音频</div>
+          <button class="btn btn-primary" type="button" (click)="audioInput.click()">
+            <span class="icon">upload</span> 选择音频
+          </button>
+        </div>
+
+        <div class="dhf-preview" *ngIf="audioFile">
+          <span class="icon" style="font-size:36px;color:var(--accent-color)">audiotrack</span>
+          <div class="dhf-preview-meta">
+            <div>
+              <div class="dhf-preview-name">{{audioFileName}}</div>
+              <div class="dhf-hint">{{audioFileSizeText}}</div>
+            </div>
+            <button class="btn" type="button" (click)="clearAudio()" [disabled]="submitting">
+              <span class="icon">delete</span> 移除
+            </button>
+          </div>
+        </div>
+
+        <div class="dhf-error" *ngIf="audioError">{{audioError}}</div>
+      </div>
+
+      <!-- 原视频音频面板 -->
+      <div class="dhf-section" *ngIf="audioSource === 'original'">
+        <div class="dhf-info">
+          <span class="icon" style="color:var(--accent-color)">info</span>
+          将自动从原视频「{{originalAudioLabel || '未指定'}}」提取音频作为数字人语音。
+        </div>
+      </div>
+
+      <!-- ④ 提示词 -->
+      <div class="dhf-section">
+        <label class="dhf-label">表演风格提示词(可选)</label>
+        <div class="dhf-preset-row" *ngIf="promptPresets.length > 0">
+          <button *ngFor="let p of promptPresets" type="button" class="dhf-preset-chip"
+                  (click)="prompt = p.value">
+            {{p.label}}
+          </button>
+        </div>
+        <textarea class="dhf-textarea" rows="3"
+                  [(ngModel)]="prompt"
+                  placeholder="例如:以新闻主播的语气朗读,吐字清晰、节奏稳定。"></textarea>
+      </div>
+
+      <!-- ⑤ 分辨率 / fast mode -->
+      <div class="dhf-section dhf-row">
+        <div>
+          <label class="dhf-label">输出分辨率</label>
+          <div class="dhf-segmented">
+            <button type="button" class="dhf-seg-btn"
+                    [class.active]="resolution === '720p'"
+                    (click)="resolution = '720p'">720p</button>
+            <button type="button" class="dhf-seg-btn"
+                    [class.active]="resolution === '1080p'"
+                    (click)="resolution = '1080p'">1080p</button>
+          </div>
+        </div>
+        <div>
+          <label class="dhf-label">快速模式</label>
+          <label class="dhf-switch">
+            <input type="checkbox" [(ngModel)]="fastMode" [disabled]="resolution === '720p'">
+            <span class="dhf-switch-slider"></span>
+          </label>
+          <div class="dhf-hint">720p 默认开启快速模式</div>
+        </div>
+      </div>
+
+      <!-- ⑥ 提交 -->
+      <div class="dhf-section dhf-submit-row">
+        <div class="dhf-error" *ngIf="formError">{{formError}}</div>
+        <button class="btn btn-primary dhf-submit-btn" type="button"
+                (click)="submit()" [disabled]="submitting || !canSubmit()">
+          <span class="icon" [class.spinning]="submitting">{{submitting ? 'sync' : 'auto_awesome'}}</span>
+          {{submitButtonLabel}}
+        </button>
+      </div>
+    </div>
+  `,
+  styles: [`
+    .dhf-container { display: flex; flex-direction: column; gap: 18px; }
+    .dhf-section { display: flex; flex-direction: column; gap: 10px; }
+    .dhf-label { font-size: 14px; font-weight: 600; color: var(--text-primary); }
+    .dhf-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
+    .dhf-error {
+      padding: 8px 12px; background: rgba(220, 38, 38, 0.08);
+      color: var(--error-color, #dc2626); border-radius: 8px; font-size: 13px;
+    }
+    .dhf-info {
+      padding: 10px 12px; background: rgba(0, 113, 227, 0.06);
+      border-radius: 8px; font-size: 13px; display: flex; align-items: center; gap: 8px;
+    }
+    .dhf-empty {
+      padding: 24px; border: 2px dashed var(--border-color); border-radius: 10px;
+      display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center;
+    }
+    .dhf-empty-icon { font-size: 40px; color: var(--text-muted); }
+    .dhf-empty-text { font-size: 13px; color: var(--text-muted); }
+    .dhf-preview {
+      display: flex; gap: 14px; align-items: center; padding: 12px;
+      background: var(--bg-secondary); border-radius: 10px;
+    }
+    .dhf-preview img { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; }
+    .dhf-preview-meta { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
+    .dhf-preview-name { font-size: 13px; font-weight: 500; color: var(--text-primary); margin-bottom: 2px; }
+    .dhf-mode-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
+    .dhf-mode-card {
+      padding: 12px 14px; border: 1px solid var(--border-color); border-radius: 10px;
+      cursor: pointer; transition: all 0.15s; background: var(--bg-primary);
+    }
+    .dhf-mode-card:hover { border-color: var(--accent-color); }
+    .dhf-mode-card.active { border-color: var(--accent-color); background: rgba(0, 113, 227, 0.06); }
+    .dhf-mode-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; color: var(--text-primary); }
+    .dhf-mode-desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
+    .dhf-textarea {
+      width: 100%; padding: 10px 12px; border: 1px solid var(--border-color);
+      border-radius: 8px; background: var(--bg-primary); color: var(--text-primary);
+      font-size: 13px; font-family: inherit; resize: vertical;
+    }
+    .dhf-select {
+      width: 100%; padding: 8px 12px; border: 1px solid var(--border-color);
+      border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); font-size: 13px;
+    }
+    .dhf-preset-row { display: flex; gap: 6px; flex-wrap: wrap; }
+    .dhf-preset-chip {
+      padding: 4px 10px; border: 1px solid var(--border-color); border-radius: 999px;
+      background: var(--bg-primary); color: var(--text-primary); font-size: 12px; cursor: pointer;
+    }
+    .dhf-preset-chip:hover { border-color: var(--accent-color); color: var(--accent-color); }
+    .dhf-row { flex-direction: row; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
+    .dhf-row > div { display: flex; flex-direction: column; gap: 8px; }
+    .dhf-segmented { display: inline-flex; border: 1px solid var(--border-color); border-radius: 999px; overflow: hidden; }
+    .dhf-seg-btn {
+      padding: 6px 14px; border: 0; background: transparent; cursor: pointer;
+      font-size: 13px; color: var(--text-primary);
+    }
+    .dhf-seg-btn.active { background: var(--accent-color); color: #fff; }
+    .dhf-switch { position: relative; display: inline-block; width: 44px; height: 24px; }
+    .dhf-switch input { opacity: 0; width: 0; height: 0; }
+    .dhf-switch-slider {
+      position: absolute; cursor: pointer; inset: 0; background: var(--border-color);
+      border-radius: 24px; transition: 0.2s;
+    }
+    .dhf-switch-slider::before {
+      content: ''; position: absolute; height: 18px; width: 18px; left: 3px; top: 3px;
+      background: #fff; border-radius: 50%; transition: 0.2s;
+    }
+    .dhf-switch input:checked + .dhf-switch-slider { background: var(--accent-color); }
+    .dhf-switch input:checked + .dhf-switch-slider::before { transform: translateX(20px); }
+    .dhf-submit-row { gap: 12px; }
+    .dhf-submit-btn { align-self: flex-start; padding: 10px 24px; font-size: 14px; }
+    .icon.spinning { animation: dhf-spin 1s linear infinite; }
+    @keyframes dhf-spin { to { transform: rotate(360deg); } }
+  `]
+})
+export class DigitalHumanFormComponent implements OnInit, OnDestroy {
+
+  /** 可选的音频来源(按顺序展示)。默认 ['tts'] */
+  @Input() audioSourceOptions: AudioSourceMode[] = ['tts'];
+  /** 是否显示形象准备(自拍优化 / 对标重塑)。默认 true */
+  @Input() showPortraitPipeline: boolean = true;
+  /** TTS 音色列表 */
+  @Input() timbreOptions: TimbreOption[] = [];
+  /** 提示词预设 */
+  @Input() promptPresets: PromptPreset[] = [];
+  /** 当 audioSource = 'original' 时的视频上下文 */
+  @Input() originalAudioVideoId: string = '';
+  @Input() originalAudioLabel: string = '';
+  /** 提交按钮文案 */
+  @Input() submitButtonLabel: string = '开始生成数字人视频';
+  /** 父组件控制的提交锁(生成中禁用按钮) */
+  @Input() submitting: boolean = false;
+
+  /** 用户点提交时发出,父组件接管真正的生成调用 */
+  @Output() request = new EventEmitter<DigitalHumanRequest>();
+
+  // -------------------- 表单状态 --------------------
+
+  imageFile: File | null = null;
+  imageFileName: string = '';
+  imagePreviewUrl: string = '';
+  imageError: string = '';
+
+  // 形象准备产出
+  preparedPortraitUrl: string = '';
+
+  audioSource: AudioSourceMode = 'tts';
+
+  // tts
+  ttsText: string = '';
+  ttsTimbreId: string = '';
+
+  // upload
+  audioFile: File | null = null;
+  audioFileName: string = '';
+  audioFileSizeText: string = '';
+  audioError: string = '';
+
+  // 通用
+  prompt: string = '';
+  fastMode: boolean = false;
+  resolution: '720p' | '1080p' = '1080p';
+
+  formError: string = '';
+
+  ngOnInit(): void {
+    if (this.audioSourceOptions.length > 0 && !this.audioSourceOptions.includes(this.audioSource)) {
+      this.audioSource = this.audioSourceOptions[0];
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.revokeImagePreview();
+  }
+
+  // -------------------- 形象图 --------------------
+
+  onImageSelected(event: Event): void {
+    const input = event.target as HTMLInputElement;
+    if (!input.files || input.files.length === 0) return;
+
+    const file = input.files[0];
+    const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/gif'];
+    const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
+    const allowedExts = ['.jpg', '.jpeg', '.png', '.gif'];
+
+    if ((!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) || file.size <= 0) {
+      this.imageError = '仅支持 jpg/jpeg/png/gif 格式';
+      input.value = '';
+      return;
+    }
+    if (file.size > 20 * 1024 * 1024) {
+      this.imageError = '形象图大小不能超过 20MB';
+      input.value = '';
+      return;
+    }
+
+    this.revokeImagePreview();
+    this.imageFile = file;
+    this.imageFileName = file.name;
+    this.imagePreviewUrl = URL.createObjectURL(file);
+    this.imageError = '';
+    this.preparedPortraitUrl = '';
+    input.value = '';
+  }
+
+  clearImage(): void {
+    this.revokeImagePreview();
+    this.imageFile = null;
+    this.imageFileName = '';
+    this.preparedPortraitUrl = '';
+    this.imageError = '';
+  }
+
+  onPortraitPrepared(out: PortraitPrepareOutput): void {
+    this.preparedPortraitUrl = out.portraitUrl;
+  }
+
+  // -------------------- 音频 --------------------
+
+  setAudioSource(mode: AudioSourceMode): void {
+    if (this.submitting) return;
+    this.audioSource = mode;
+    this.formError = '';
+  }
+
+  audioSourceLabel(mode: AudioSourceMode): string {
+    return mode === 'tts' ? 'TTS 合成语音'
+      : mode === 'upload' ? '上传音频'
+      : '使用原视频音频';
+  }
+
+  audioSourceDesc(mode: AudioSourceMode): string {
+    return mode === 'tts' ? '输入文本 + 选择音色,由系统合成口播'
+      : mode === 'upload' ? '上传一段已有的口播录音'
+      : '从原视频自动提取音频,沿用原声';
+  }
+
+  onAudioSelected(event: Event): void {
+    const input = event.target as HTMLInputElement;
+    if (!input.files || input.files.length === 0) return;
+
+    const file = input.files[0];
+    if (file.size > 50 * 1024 * 1024) {
+      this.audioError = '音频文件不能超过 50MB';
+      input.value = '';
+      return;
+    }
+
+    this.audioFile = file;
+    this.audioFileName = file.name;
+    this.audioFileSizeText = this.formatBytes(file.size);
+    this.audioError = '';
+    input.value = '';
+  }
+
+  clearAudio(): void {
+    this.audioFile = null;
+    this.audioFileName = '';
+    this.audioFileSizeText = '';
+  }
+
+  // -------------------- 提交 --------------------
+
+  canSubmit(): boolean {
+    if (!this.imageFile && !this.preparedPortraitUrl) return false;
+
+    if (this.audioSource === 'tts') {
+      return !!this.ttsText.trim() && !!this.ttsTimbreId.trim();
+    }
+    if (this.audioSource === 'upload') {
+      return !!this.audioFile;
+    }
+    if (this.audioSource === 'original') {
+      return !!this.originalAudioVideoId;
+    }
+    return false;
+  }
+
+  submit(): void {
+    if (this.submitting) return;
+    if (!this.canSubmit()) {
+      this.formError = '请先完善表单:上传形象图、配置音频来源';
+      return;
+    }
+    this.formError = '';
+
+    const req: DigitalHumanRequest = {
+      portrait: this.preparedPortraitUrl
+        ? { imageUrl: this.preparedPortraitUrl }
+        : { imageFile: this.imageFile! },
+      audio: this.buildAudioInput(),
+      prompt: this.prompt.trim(),
+      fastMode: this.fastMode,
+      resolution: this.resolution
+    };
+
+    this.request.emit(req);
+  }
+
+  private buildAudioInput(): DigitalHumanRequest['audio'] {
+    switch (this.audioSource) {
+      case 'tts':
+        return {
+          source: 'tts',
+          ttsText: this.ttsText.trim(),
+          ttsTimbreId: this.ttsTimbreId.trim()
+        };
+      case 'upload':
+        return { source: 'upload', audioFile: this.audioFile! };
+      case 'original':
+        return { source: 'original', videoId: this.originalAudioVideoId };
+    }
+  }
+
+  // -------------------- 工具 --------------------
+
+  private revokeImagePreview(): void {
+    if (this.imagePreviewUrl) {
+      URL.revokeObjectURL(this.imagePreviewUrl);
+      this.imagePreviewUrl = '';
+    }
+  }
+
+  private formatBytes(bytes: number): string {
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+  }
+}

+ 529 - 0
src/app/components/portrait-prepare/portrait-prepare.component.ts

@@ -0,0 +1,529 @@
+import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { JimengService } from '../../services/jimeng.service';
+import { PortraitPipelineService, PortraitPipelineResult } from '../../services/portrait-pipeline.service';
+
+export type PortraitPrepareMode = 'raw' | 'benchmark';
+
+export interface PortraitPrepareOutput {
+  /** 最终用于数字人生成的形象图 URL(raw 模式则为空,调用方自行用原 file 上传) */
+  portraitUrl: string;
+  /** 中间产物:规范化全身图 URL(benchmark 模式才有值) */
+  fullBodyUrl: string;
+  /** 对标提示词文本(benchmark 模式才有值) */
+  benchmarkSummary: string;
+  /** 当前模式 */
+  mode: PortraitPrepareMode;
+}
+
+/**
+ * 数字人形象图准备子组件(v2,已移除全身图生成步骤):
+ *  - raw:直接使用上传图(不调用 AI)
+ *  - benchmark:上传参考图 + Gemini 反推姿态/场景 + jimeng-img-v4 人物替换
+ *               (用上传形象图中的人物替换参考图中的人物,保留参考图姿态/场景)
+ *
+ * 仅在 selfieFile 存在时才显示模式切换 UI。
+ */
+@Component({
+  selector: 'app-portrait-prepare',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  template: `
+    <div class="pp-container" *ngIf="selfieFile">
+      <label class="pp-label">形象图准备方式</label>
+
+      <div class="pp-mode-grid">
+        <div class="pp-mode-card"
+             [class.active]="mode === 'raw'"
+             [class.disabled]="running"
+             (click)="setMode('raw')">
+          <div class="pp-mode-title">直接使用上传图</div>
+          <div class="pp-mode-desc">沿用原始上传的人物形象图,不做 AI 处理。</div>
+        </div>
+        <div class="pp-mode-card"
+             [class.active]="mode === 'benchmark'"
+             [class.disabled]="running"
+             (click)="setMode('benchmark')">
+          <div class="pp-mode-title">参考图人物替换</div>
+          <div class="pp-mode-desc">上传参考图,用您上传形象图中的人物替换参考图人物,保留参考图的姿态、动作与场景(约 0.22 元)。</div>
+        </div>
+      </div>
+
+      <!-- 参考图(提供姿态/场景)上传 -->
+      <div *ngIf="mode === 'benchmark'" class="pp-benchmark">
+        <label class="pp-label-sub">参考图(提供姿态、动作与场景)</label>
+        <input type="file"
+               #benchmarkInput
+               accept="image/jpeg,image/png,image/jpg,image/gif,image/webp,.jpg,.jpeg,.png,.gif,.webp"
+               (change)="onBenchmarkSelected($event)"
+               style="display:none">
+
+        <div class="pp-empty" *ngIf="!benchmarkPreviewUrl">
+          <svg class="pp-svg pp-svg-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+            <rect x="3" y="3" width="18" height="18" rx="2"/>
+            <circle cx="8.5" cy="10" r="1.5"/>
+            <path d="M21 15l-5-5L5 21"/>
+          </svg>
+          <div class="pp-empty-text">上传一张参考图(含人物 + 背景)</div>
+          <button class="pp-btn pp-btn-primary" type="button" (click)="benchmarkInput.click()" [disabled]="running">
+            <svg class="pp-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
+              <polyline points="17 8 12 3 7 8"/>
+              <line x1="12" y1="3" x2="12" y2="15"/>
+            </svg>
+            选择参考图
+          </button>
+        </div>
+
+        <div class="pp-preview" *ngIf="benchmarkPreviewUrl">
+          <img [src]="benchmarkPreviewUrl" [alt]="benchmarkFileName">
+          <div class="pp-preview-meta">
+            <div class="pp-preview-name">{{benchmarkFileName}}</div>
+            <button class="pp-btn pp-btn-ghost" type="button" (click)="clearBenchmark()" [disabled]="running">
+              <svg class="pp-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <polyline points="3 6 5 6 21 6"/>
+                <path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/>
+                <path d="M10 11v6M14 11v6"/>
+              </svg>
+              移除
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 生成按钮 + 进度 -->
+      <div *ngIf="mode === 'benchmark'" class="pp-action">
+        <button class="pp-btn pp-btn-primary" type="button"
+                (click)="run()"
+                [disabled]="running || !benchmarkFile">
+          <svg class="pp-svg" [class.spinning]="running" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <ng-container *ngIf="!running">
+              <path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
+            </ng-container>
+            <ng-container *ngIf="running">
+              <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
+            </ng-container>
+          </svg>
+          {{running ? '生成中...' : (generatedPortraitUrl ? '重新生成' : '生成形象图')}}
+        </button>
+        <button class="pp-btn pp-btn-ghost" type="button" *ngIf="generatedPortraitUrl && !running" (click)="reset()">
+          <svg class="pp-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <polyline points="23 4 23 10 17 10"/>
+            <polyline points="1 20 1 14 7 14"/>
+            <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
+          </svg>
+          清除结果
+        </button>
+        <span class="pp-hint">jimeng-img-v4 + Gemini</span>
+      </div>
+
+      <div class="pp-status" *ngIf="running || statusText">
+        <svg class="pp-svg" [class.spinning]="running" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+             [style.color]="running ? 'var(--accent-color, #3b82f6)' : 'var(--success-color, #10b981)'">
+          <ng-container *ngIf="running">
+            <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
+          </ng-container>
+          <ng-container *ngIf="!running">
+            <circle cx="12" cy="12" r="10"/>
+            <polyline points="9 12 11 14 15 10"/>
+          </ng-container>
+        </svg>
+        <div class="pp-status-body">
+          <div class="pp-status-text">{{statusText}}</div>
+          <div class="pp-status-bar" [class.pp-status-bar--indeterminate]="running && progress < 5"><div class="pp-status-bar__fill" [style.width]="progress + '%'"></div></div>
+        </div>
+        <div class="pp-status-pct">{{progress}}%</div>
+      </div>
+
+      <div class="pp-error" *ngIf="errorText">{{errorText}}</div>
+
+      <!-- 生成结果对比预览 -->
+      <div class="pp-result" *ngIf="generatedPortraitUrl">
+        <div class="pp-result-row">
+          <div class="pp-result-cell">
+            <div class="pp-result-cap">原图</div>
+            <img [src]="selfiePreviewUrl" alt="原图">
+          </div>
+          <div class="pp-result-cell" *ngIf="benchmarkPreviewUrl">
+            <div class="pp-result-cap">参考图(姿态/场景)</div>
+            <img [src]="benchmarkPreviewUrl" alt="参考图">
+          </div>
+          <div class="pp-result-cell">
+            <div class="pp-result-cap pp-result-cap-success">✨ 人物替换后形象图(将用于数字人)</div>
+            <img [src]="generatedPortraitUrl" alt="生成形象图" class="pp-result-img-final">
+          </div>
+        </div>
+        <div class="pp-summary" *ngIf="generatedSummary">
+          <strong>参考图姿态提示词:</strong>{{generatedSummary}}
+        </div>
+      </div>
+    </div>
+  `,
+  styles: [`
+    :host { display: block; }
+    .pp-container { display: flex; flex-direction: column; gap: 10px; }
+    .pp-label { font-size: 13px; font-weight: 600; color: var(--text-primary, #e5e7eb); }
+    .pp-label-sub { font-size: 12.5px; color: var(--text-secondary, #9ca3af); display: block; margin-bottom: 6px; }
+
+    /* 模式卡片 */
+    .pp-mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+    .pp-mode-card {
+      padding: 10px 12px; border: 1px solid var(--border-color, rgba(255,255,255,0.1));
+      border-radius: 10px; cursor: pointer; transition: all 0.15s;
+      background: rgba(255,255,255,0.02);
+    }
+    .pp-mode-card:hover:not(.disabled) { border-color: var(--accent-color, #3b82f6); background: rgba(59,130,246,0.06); }
+    .pp-mode-card.active {
+      border-color: var(--accent-color, #3b82f6);
+      background: linear-gradient(135deg, rgba(59,130,246,0.10), rgba(168,85,247,0.10));
+      box-shadow: 0 0 0 1px var(--accent-color, #3b82f6) inset;
+    }
+    .pp-mode-card.disabled { opacity: 0.5; cursor: not-allowed; }
+    .pp-mode-title { font-size: 13px; font-weight: 600; margin-bottom: 3px; color: var(--text-primary, #e5e7eb); }
+    .pp-mode-desc { font-size: 11.5px; color: var(--text-muted, #9ca3af); line-height: 1.45; }
+
+    /* 参考图上传 */
+    .pp-benchmark { margin-top: 2px; }
+    .pp-empty {
+      padding: 14px; border: 1.5px dashed var(--border-color, rgba(255,255,255,0.15)); border-radius: 10px;
+      display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
+      background: rgba(255,255,255,0.015);
+    }
+    .pp-empty-text { font-size: 12px; color: var(--text-muted, #9ca3af); }
+    .pp-preview {
+      display: flex; gap: 10px; align-items: center; padding: 8px;
+      background: rgba(255,255,255,0.04); border-radius: 10px;
+      border: 1px solid var(--border-color, rgba(255,255,255,0.08));
+    }
+    .pp-preview img { width: 56px; height: 56px; object-fit: cover; border-radius: 8px; flex-shrink: 0; }
+    .pp-preview-meta { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; }
+    .pp-preview-name {
+      font-size: 12px; font-weight: 500; color: var(--text-primary, #e5e7eb);
+      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+    }
+
+    /* 按钮 —— 使用 button.xxx 提升具体性,避免被全局 .btn / button:hover 覆盖 */
+    button.pp-btn {
+      display: inline-flex; align-items: center; gap: 6px;
+      padding: 7px 12px; border-radius: 8px; font-size: 12.5px; font-weight: 500;
+      border: 1px solid var(--border-color, rgba(255,255,255,0.12));
+      background: rgba(99, 102, 241, 0.10);
+      color: var(--text-primary, #1f2937);
+      cursor: pointer; transition: background 0.15s, box-shadow 0.15s, transform 0.15s, border-color 0.15s;
+      white-space: nowrap;
+    }
+    button.pp-btn:hover:not(:disabled) {
+      background: rgba(99, 102, 241, 0.18);
+      border-color: rgba(99, 102, 241, 0.45);
+      transform: translateY(-1px);
+    }
+    button.pp-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+    button.pp-btn-primary {
+      background: linear-gradient(135deg, #3b82f6, #8b5cf6);
+      border-color: transparent;
+      color: #ffffff;
+      box-shadow: 0 2px 8px rgba(59,130,246,0.3);
+    }
+    button.pp-btn-primary:hover:not(:disabled) {
+      background: linear-gradient(135deg, #2563eb, #7c3aed);
+      border-color: transparent;
+      color: #ffffff;
+      box-shadow: 0 4px 14px rgba(59,130,246,0.5);
+      transform: translateY(-1px);
+    }
+    button.pp-btn-primary:active:not(:disabled) {
+      background: linear-gradient(135deg, #1d4ed8, #6d28d9);
+      transform: translateY(0);
+    }
+
+    button.pp-btn-ghost {
+      background: transparent;
+      border-color: var(--border-color, rgba(0,0,0,0.12));
+      color: var(--text-primary, #1f2937);
+    }
+    button.pp-btn-ghost:hover:not(:disabled) {
+      background: rgba(0,0,0,0.04);
+      border-color: rgba(0,0,0,0.2);
+    }
+
+    /* 动作区 */
+    .pp-action { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+    .pp-hint { font-size: 11px; color: var(--text-muted, #6b7280); margin-left: auto; }
+
+    /* 状态条 */
+    .pp-status {
+      display: flex; align-items: center; gap: 10px; padding: 8px 10px;
+      background: rgba(255,255,255,0.04); border-radius: 8px;
+      border: 1px solid var(--border-color, rgba(255,255,255,0.08));
+    }
+    .pp-status-body { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+    .pp-status-text { font-size: 12px; color: var(--text-primary, #e5e7eb); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .pp-status-bar { position: relative; height: 4px; background: rgba(99,102,241,0.12); border-radius: 2px; overflow: hidden; }
+    .pp-status-bar__fill {
+      height: 100%;
+      background: linear-gradient(90deg, #3b82f6, #8b5cf6);
+      transition: width 0.3s ease;
+    }
+    /* 上传阶段(progress<5)显示流动条纹,避免视觉上静止 */
+    .pp-status-bar--indeterminate::after {
+      content: '';
+      position: absolute; inset: 0;
+      background: linear-gradient(90deg,
+        transparent 0%,
+        rgba(139, 92, 246, 0.6) 50%,
+        transparent 100%);
+      animation: pp-indeterminate 1.2s linear infinite;
+    }
+    @keyframes pp-indeterminate {
+      0% { transform: translateX(-100%); }
+      100% { transform: translateX(100%); }
+    }
+    .pp-status-pct { font-size: 11.5px; font-weight: 600; color: var(--accent-color, #3b82f6); flex-shrink: 0; }
+
+    .pp-error {
+      padding: 8px 10px; background: rgba(220, 38, 38, 0.08);
+      color: #ef4444; border-radius: 8px; font-size: 12px;
+      border: 1px solid rgba(220, 38, 38, 0.25);
+    }
+
+    /* 结果对比 */
+    .pp-result {
+      display: flex; flex-direction: column; gap: 8px; padding: 10px;
+      background: rgba(255,255,255,0.03); border-radius: 10px;
+      border: 1px solid var(--border-color, rgba(255,255,255,0.08));
+    }
+    .pp-result-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+    .pp-result-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+    .pp-result-cap { font-size: 11px; color: var(--text-muted, #9ca3af); }
+    .pp-result-cap-success { color: #10b981; font-weight: 600; }
+    .pp-result-cell img {
+      width: 100%; aspect-ratio: 4/5; object-fit: cover;
+      border-radius: 6px; background: rgba(0,0,0,0.2);
+    }
+    .pp-result-img-final { box-shadow: 0 0 0 2px #10b981; }
+    .pp-summary {
+      font-size: 11.5px; color: var(--text-muted, #9ca3af); padding: 8px 10px;
+      background: rgba(0,0,0,0.15); border-radius: 6px; line-height: 1.5;
+    }
+    .pp-summary strong { color: var(--text-primary, #e5e7eb); }
+
+    /* SVG 图标 */
+    .pp-svg { width: 14px; height: 14px; flex-shrink: 0; }
+    .pp-svg-lg { width: 28px; height: 28px; color: var(--text-muted, #6b7280); }
+    .pp-svg.spinning { animation: pp-spin 1s linear infinite; }
+    @keyframes pp-spin { to { transform: rotate(360deg); } }
+
+    /* 窄列适配(在 dh-step-col 280px 列宽下) */
+    @media (max-width: 1280px) {
+      .pp-mode-grid { grid-template-columns: 1fr; }
+      .pp-result-row { grid-template-columns: 1fr; }
+      .pp-result-cell img { aspect-ratio: auto; max-height: 180px; }
+    }
+  `]
+})
+export class PortraitPrepareComponent implements OnDestroy {
+  /** 用户上传的自拍/形象图 File(必填) */
+  @Input() selfieFile: File | null = null;
+  /** 自拍预览 URL(用于对比展示) */
+  @Input() selfiePreviewUrl: string = '';
+
+  /** 当模式或生成结果变化时通知父组件 */
+  @Output() prepared = new EventEmitter<PortraitPrepareOutput>();
+
+  mode: PortraitPrepareMode = 'raw';
+
+  benchmarkFile: File | null = null;
+  benchmarkFileName: string = '';
+  benchmarkPreviewUrl: string = '';
+
+  running: boolean = false;
+  statusText: string = '';
+  progress: number = 0;
+  errorText: string = '';
+
+  generatedPortraitUrl: string = '';
+  generatedFullBodyUrl: string = '';
+  generatedSummary: string = '';
+
+  // 已上传过的 selfie/benchmark URL,避免重复上传
+  private cachedSelfieUrl: string = '';
+  private cachedBenchmarkUrl: string = '';
+  private cachedSelfieFile: File | null = null;
+  private cachedBenchmarkFile: File | null = null;
+
+  constructor(
+    private jimeng: JimengService,
+    private portraitPipeline: PortraitPipelineService
+  ) {}
+
+  ngOnDestroy(): void {
+    this.revokeBenchmarkPreview();
+  }
+
+  setMode(mode: PortraitPrepareMode): void {
+    if (this.running) return;
+    this.mode = mode;
+    this.generatedPortraitUrl = '';
+    this.generatedFullBodyUrl = '';
+    this.generatedSummary = '';
+    this.statusText = '';
+    this.progress = 0;
+    this.errorText = '';
+    if (mode !== 'benchmark') {
+      this.clearBenchmark();
+    }
+    this.emitState();
+  }
+
+  onBenchmarkSelected(event: Event): void {
+    const input = event.target as HTMLInputElement;
+    if (!input.files || input.files.length === 0) return;
+
+    const file = input.files[0];
+    const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/gif', 'image/webp'];
+    const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
+    const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
+
+    if ((!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) || file.size <= 0) {
+      this.errorText = '对标场景图仅支持 jpg/jpeg/png/gif/webp 格式';
+      input.value = '';
+      return;
+    }
+    if (file.size > 20 * 1024 * 1024) {
+      this.errorText = '对标场景图大小不能超过 20MB';
+      input.value = '';
+      return;
+    }
+
+    this.revokeBenchmarkPreview();
+    this.benchmarkFile = file;
+    this.benchmarkFileName = file.name;
+    this.benchmarkPreviewUrl = URL.createObjectURL(file);
+    this.errorText = '';
+    this.generatedPortraitUrl = '';
+    this.cachedBenchmarkUrl = '';
+    this.cachedBenchmarkFile = null;
+    input.value = '';
+    this.emitState();
+  }
+
+  clearBenchmark(): void {
+    this.revokeBenchmarkPreview();
+    this.benchmarkFile = null;
+    this.benchmarkFileName = '';
+    this.cachedBenchmarkUrl = '';
+    this.cachedBenchmarkFile = null;
+  }
+
+  reset(): void {
+    if (this.running) return;
+    this.generatedPortraitUrl = '';
+    this.generatedFullBodyUrl = '';
+    this.generatedSummary = '';
+    this.statusText = '';
+    this.progress = 0;
+    this.errorText = '';
+    this.emitState();
+  }
+
+  run(): void {
+    if (this.running) return;
+    if (!this.selfieFile) {
+      this.errorText = '请先上传人物自拍/形象图';
+      return;
+    }
+    if (this.mode === 'benchmark' && !this.benchmarkFile) {
+      this.errorText = '请上传一张对标账号的场景图';
+      return;
+    }
+
+    this.running = true;
+    this.errorText = '';
+    this.progress = 1;
+    this.statusText = '正在上传形象图...';
+
+    // Step 1:上传自拍(带缓存)
+    this.ensureUrl(this.selfieFile, this.cachedSelfieFile, this.cachedSelfieUrl, 'portrait-selfie').then((selfieUrl) => {
+      this.cachedSelfieFile = this.selfieFile;
+      this.cachedSelfieUrl = selfieUrl;
+      this.progress = 2;
+      this.statusText = this.benchmarkFile ? '正在上传参考图...' : '形象图上传完成';
+
+      // Step 2:上传对标图(带缓存,benchmark 模式才需要)
+      const benchmarkPromise: Promise<string | null> = this.benchmarkFile
+        ? this.ensureUrl(this.benchmarkFile, this.cachedBenchmarkFile, this.cachedBenchmarkUrl, 'portrait-benchmark').then((url) => {
+            this.cachedBenchmarkFile = this.benchmarkFile;
+            this.cachedBenchmarkUrl = url;
+            this.progress = 4;
+            this.statusText = '素材上传完成,准备分析...';
+            return url;
+          })
+        : Promise.resolve(null);
+
+      return benchmarkPromise.then((benchmarkUrl) => ({ selfieUrl, benchmarkUrl }));
+    }).then(({ selfieUrl, benchmarkUrl }) => {
+      // Step 3:执行 pipeline
+      this.portraitPipeline.buildBenchmarkedPortrait(selfieUrl, benchmarkUrl, (status, progress) => {
+        this.statusText = status;
+        this.progress = Math.round(progress);
+      }).subscribe({
+        next: (result: PortraitPipelineResult) => {
+          this.generatedFullBodyUrl = result.fullBodyUrl;
+          this.generatedPortraitUrl = result.finalPortraitUrl;
+          this.generatedSummary = result.benchmarkPrompt?.merged || '';
+          this.running = false;
+          this.statusText = '形象图生成完成';
+          this.progress = 100;
+          this.emitState();
+        },
+        error: (err) => {
+          this.running = false;
+          this.errorText = this.getErrorText(err, '形象图生成失败');
+          this.emitState();
+        }
+      });
+    }).catch((err) => {
+      this.running = false;
+      this.errorText = this.getErrorText(err, '素材上传失败');
+      this.emitState();
+    });
+  }
+
+  private ensureUrl(file: File, cachedFile: File | null, cachedUrl: string, prefix: string): Promise<string> {
+    if (cachedUrl && cachedFile === file) {
+      return Promise.resolve(cachedUrl);
+    }
+    return new Promise<string>((resolve, reject) => {
+      this.jimeng.uploadFileToParse(
+        file,
+        `${prefix}-${Date.now()}-${file.name}`,
+        file.type || 'image/jpeg'
+      ).subscribe({
+        next: (url: string) => resolve(url),
+        error: (err) => reject(err)
+      });
+    });
+  }
+
+  private emitState(): void {
+    this.prepared.emit({
+      portraitUrl: this.generatedPortraitUrl,
+      fullBodyUrl: this.generatedFullBodyUrl,
+      benchmarkSummary: this.generatedSummary,
+      mode: this.mode
+    });
+  }
+
+  private revokeBenchmarkPreview(): void {
+    if (this.benchmarkPreviewUrl) {
+      URL.revokeObjectURL(this.benchmarkPreviewUrl);
+      this.benchmarkPreviewUrl = '';
+    }
+  }
+
+  private getErrorText(err: any, fallback: string): string {
+    return err?.error?.message || err?.error?.error || err?.message || fallback;
+  }
+}

+ 295 - 0
src/app/services/digital-human.service.ts

@@ -0,0 +1,295 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable, Subject, of, throwError, from } from 'rxjs';
+import { switchMap, catchError, map } from 'rxjs/operators';
+import { JimengService } from './jimeng.service';
+import { environment } from '../../environments/environment';
+
+// ==================== 类型定义 ====================
+
+export type AudioSourceMode = 'tts' | 'upload' | 'original';
+
+export interface DigitalHumanPortraitInput {
+  /** 已上传的可访问 URL(优先使用) */
+  imageUrl?: string;
+  /** 本地文件,service 内部会上传 */
+  imageFile?: File;
+}
+
+export interface DigitalHumanAudioInput {
+  source: AudioSourceMode;
+  /** TTS 模式参数 */
+  ttsText?: string;
+  ttsTimbreId?: string;
+  /** upload 模式参数(File 二选一 url) */
+  audioFile?: File;
+  audioUrl?: string;
+  /** original 模式参数:从某个本地视频提取音频 */
+  videoId?: string;
+}
+
+export interface DigitalHumanRequest {
+  portrait: DigitalHumanPortraitInput;
+  audio: DigitalHumanAudioInput;
+  prompt?: string;
+  fastMode?: boolean;
+  resolution?: '720p' | '1080p';
+}
+
+export type DigitalHumanStage =
+  | 'upload-image'
+  | 'prepare-audio'
+  | 'identify'
+  | 'generate'
+  | 'done';
+
+export interface DigitalHumanProgressEvent {
+  type: 'progress';
+  stage: DigitalHumanStage;
+  status: string;
+  /** 0~100 总进度 */
+  progress: number;
+}
+
+export interface DigitalHumanResult {
+  type: 'result';
+  videoUrl: string;
+  workId: string;
+  imageUrl: string;
+  audioUrl: string;
+}
+
+export type DigitalHumanEvent = DigitalHumanProgressEvent | DigitalHumanResult;
+
+// ==================== 服务 ====================
+
+/**
+ * 数字人生成业务编排:
+ *  ① 形象图准备:若传 File 则上传到 Parse;若已有 URL 直接使用
+ *  ② 音频准备:tts / upload / original 三种来源
+ *  ③ identifyDigitalHuman → 主体识别
+ *  ④ generateDigitalHuman → 生成视频
+ *
+ * 通过 Observable 持续 emit progress 事件,最后一次 emit 为 result。
+ */
+@Injectable({ providedIn: 'root' })
+export class DigitalHumanService {
+
+  private readonly ttsEndpoint = 'https://server.fmode.cn/api/volcengine/tts/unidirectional';
+  private readonly extractAudioEndpoint = '/backend/api/remix/extract-audio';
+  private readonly ttsToken = environment.jimengApi.token; // Bearer r:xxx
+
+  constructor(
+    private http: HttpClient,
+    private jimeng: JimengService
+  ) {}
+
+  /**
+   * 一站式生成数字人视频。Observable 期间会多次 emit 进度,最后 emit 一次 result。
+   */
+  generate(req: DigitalHumanRequest): Observable<DigitalHumanEvent> {
+    const subject = new Subject<DigitalHumanEvent>();
+
+    const emitProgress = (stage: DigitalHumanStage, status: string, progress: number) => {
+      subject.next({ type: 'progress', stage, status, progress });
+    };
+
+    // Phase 1:图像
+    this.ensureImageUrl(req.portrait, (s, p) => emitProgress('upload-image', s, p)).pipe(
+      switchMap((imageUrl: string) => {
+        // Phase 2:音频
+        return this.ensureAudioUrl(req.audio, (s, p) => emitProgress('prepare-audio', s, p)).pipe(
+          map((audioUrl: string) => ({ imageUrl, audioUrl }))
+        );
+      }),
+      switchMap(({ imageUrl, audioUrl }) => {
+        // Phase 3:identify
+        emitProgress('identify', '正在识别数字人主体...', 35);
+        return this.jimeng.identifyDigitalHuman(imageUrl).pipe(
+          switchMap(({ workId }) => {
+            return new Observable<{ workId: string; imageUrl: string; audioUrl: string }>((obs) => {
+              this.jimeng.pollDigitalHumanIdentifyUntilReady(workId, (status, p) => {
+                emitProgress('identify', status, Math.min(35 + p * 0.15, 50));
+              }).subscribe({
+                next: () => {
+                  obs.next({ workId, imageUrl, audioUrl });
+                  obs.complete();
+                },
+                error: (err) => obs.error(err)
+              });
+            });
+          })
+        );
+      }),
+      switchMap(({ workId, imageUrl, audioUrl }) => {
+        // Phase 4:generate
+        emitProgress('generate', '开始生成数字人视频...', 50);
+        return this.jimeng.generateDigitalHuman(workId, audioUrl, {
+          prompt: req.prompt || '',
+          peFastMode: req.resolution === '720p' ? true : !!req.fastMode,
+          outputResolution: req.resolution || '1080p',
+          maskUrl: []
+        }, (status, p) => {
+          emitProgress('generate', status, Math.min(50 + p * 0.5, 99));
+        }).pipe(
+          map(({ videoUrl, workId: resultWorkId }) => ({
+            videoUrl,
+            workId: resultWorkId || workId,
+            imageUrl,
+            audioUrl
+          }))
+        );
+      })
+    ).subscribe({
+      next: ({ videoUrl, workId, imageUrl, audioUrl }) => {
+        emitProgress('done', '数字人视频生成完成', 100);
+        subject.next({ type: 'result', videoUrl, workId, imageUrl, audioUrl });
+        subject.complete();
+      },
+      error: (err) => {
+        subject.error(err);
+      }
+    });
+
+    return subject.asObservable();
+  }
+
+  // -------------------- 内部辅助 --------------------
+
+  private ensureImageUrl(
+    portrait: DigitalHumanPortraitInput,
+    onProgress: (status: string, progress: number) => void
+  ): Observable<string> {
+    if (portrait.imageUrl) {
+      onProgress('使用已生成形象图', 10);
+      return of(portrait.imageUrl);
+    }
+    if (!portrait.imageFile) {
+      return throwError(() => new Error('缺少数字人形象图'));
+    }
+    onProgress('正在上传形象图...', 5);
+    return this.jimeng.uploadFileToParse(
+      portrait.imageFile,
+      `digital-human-${Date.now()}-${portrait.imageFile.name}`,
+      portrait.imageFile.type || 'image/jpeg'
+    ).pipe(
+      map((url: string) => {
+        onProgress('形象图上传完成', 15);
+        return url;
+      })
+    );
+  }
+
+  private ensureAudioUrl(
+    audio: DigitalHumanAudioInput,
+    onProgress: (status: string, progress: number) => void
+  ): Observable<string> {
+    if (audio.audioUrl) {
+      onProgress('使用已上传音频', 30);
+      return of(audio.audioUrl);
+    }
+
+    switch (audio.source) {
+      case 'tts':
+        return this.synthesizeTts(audio, onProgress);
+      case 'upload':
+        return this.uploadAudioFile(audio, onProgress);
+      case 'original':
+        return this.extractOriginalAudio(audio, onProgress);
+      default:
+        return throwError(() => new Error(`未知的音频来源:${audio.source}`));
+    }
+  }
+
+  private synthesizeTts(
+    audio: DigitalHumanAudioInput,
+    onProgress: (status: string, progress: number) => void
+  ): Observable<string> {
+    if (!audio.ttsText || !audio.ttsText.trim()) {
+      return throwError(() => new Error('请填写 TTS 文本'));
+    }
+    if (!audio.ttsTimbreId || !audio.ttsTimbreId.trim()) {
+      return throwError(() => new Error('请选择 TTS 音色'));
+    }
+    onProgress('正在合成语音...', 18);
+
+    return this.http.post<any>(this.ttsEndpoint, {
+      token: this.ttsToken,
+      text: audio.ttsText.trim(),
+      timbreId: audio.ttsTimbreId.trim(),
+      isStream: false,
+      audio_params: {
+        format: 'mp3',
+        sample_rate: 24000,
+        speech_rate: 0,
+        loudness_rate: 0
+      }
+    }).pipe(
+      map((res: any) => {
+        if (res?.code === 200 && res?.data?.audioUrl) {
+          onProgress('语音合成完成', 30);
+          return String(res.data.audioUrl);
+        }
+        throw new Error(res?.error?.message || res?.msg || 'TTS 合成失败');
+      })
+    );
+  }
+
+  private uploadAudioFile(
+    audio: DigitalHumanAudioInput,
+    onProgress: (status: string, progress: number) => void
+  ): Observable<string> {
+    if (!audio.audioFile) {
+      return throwError(() => new Error('请先选择音频文件'));
+    }
+    onProgress('正在上传音频文件...', 18);
+    return this.jimeng.uploadFileToParse(
+      audio.audioFile,
+      `digital-human-audio-${Date.now()}-${audio.audioFile.name}`,
+      audio.audioFile.type || 'audio/mpeg'
+    ).pipe(
+      map((url: string) => {
+        onProgress('音频上传完成', 30);
+        return url;
+      })
+    );
+  }
+
+  private extractOriginalAudio(
+    audio: DigitalHumanAudioInput,
+    onProgress: (status: string, progress: number) => void
+  ): Observable<string> {
+    if (!audio.videoId) {
+      return throwError(() => new Error('缺少原视频 ID,无法提取音频'));
+    }
+    onProgress('正在提取原视频音频...', 18);
+
+    return this.http.post(this.extractAudioEndpoint, { videoId: audio.videoId }, {
+      responseType: 'blob'
+    }).pipe(
+      switchMap((audioBlob: Blob) => {
+        onProgress('音频提取完成,正在上传...', 24);
+        const file = new File(
+          [audioBlob],
+          `extracted-${Date.now()}.wav`,
+          { type: audioBlob.type || 'audio/wav' }
+        );
+        return this.jimeng.uploadFileToParse(file, file.name, file.type);
+      }),
+      map((url: string) => {
+        onProgress('原视频音频准备完成', 30);
+        return url;
+      }),
+      catchError((err) => {
+        return throwError(() => new Error(this.extractErrorMessage(err) || '提取/上传原视频音频失败'));
+      })
+    );
+  }
+
+  private extractErrorMessage(err: any): string {
+    if (err?.error instanceof Blob) {
+      return ''; // caller can handle async
+    }
+    return err?.error?.error || err?.error?.message || err?.message || '';
+  }
+}

+ 68 - 0
src/app/services/jimeng.service.ts

@@ -224,6 +224,74 @@ export class JimengService {
     );
     );
   }
   }
 
 
+  // 提交即梦4.0图片生成任务(支持文生图、单/多图编辑)
+  submitImgV4(
+    prompt: string,
+    imageUrls: string[] = [],
+    sizeDate: { width: number; height: number } = { width: 2048, height: 2048 },
+    scale: number = 0.5,
+    forceSingle: boolean = true
+  ): Observable<{ workId: string }> {
+    const body = {
+      prompt,
+      image_urls: imageUrls,
+      sizeDate,
+      scale,
+      force_single: forceSingle,
+      token: this.token
+    };
+
+    console.log('🖼️ 即梦API v4 - 提交图片生成任务:', body);
+
+    return this.http.post<any>(`${this.baseUrl}/getImgV4`, body, {
+      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
+    }).pipe(
+      map((res: any) => {
+        const workId = res?.data?.workId || res?.data?.taskId || '';
+        if (res?.code !== 200 || !workId) {
+          throw new Error(res?.msg || res?.message || 'jimeng-img-v4 任务提交失败');
+        }
+        return { workId };
+      }),
+      tap((res: { workId: string }) => console.log('🖼️ 即梦API v4 - workId:', res.workId)),
+      catchError(this.handleError('submitImgV4'))
+    );
+  }
+
+  // 一站式:提交 jimeng-img-v4 任务并轮询直到拿到第一张结果图 URL
+  generateImgV4(
+    prompt: string,
+    imageUrls: string[] = [],
+    sizeDate: { width: number; height: number } = { width: 2048, height: 2048 },
+    onProgress?: (status: string, progress: number) => void
+  ): Observable<string> {
+    if (onProgress) onProgress('正在提交图片生成任务...', 5);
+
+    return this.submitImgV4(prompt, imageUrls, sizeDate, 0.5, true).pipe(
+      switchMap(({ workId }) => {
+        if (onProgress) onProgress('图片任务已提交,正在生成中...', 15);
+        return this.pollUntilComplete(workId, 'getImgV4', (status, attempt) => {
+          if (onProgress) {
+            const progress = Math.min(15 + attempt * 5, 90);
+            onProgress(status.tip || '图片生成中...', progress);
+          }
+        });
+      }),
+      map((result: JimengWorkResult) => {
+        const url = result?.images?.[0] || '';
+        if (!url) {
+          throw new Error('jimeng-img-v4 未返回有效的图片 URL');
+        }
+        if (onProgress) onProgress('图片生成完成', 100);
+        return url;
+      }),
+      catchError((err) => {
+        if (onProgress) onProgress(`图片生成失败: ${err?.message || err}`, 0);
+        return throwError(() => err);
+      })
+    );
+  }
+
   // 查询任务状态
   // 查询任务状态
   queryTask(workId: string, routerName: string): Observable<any> {
   queryTask(workId: string, routerName: string): Observable<any> {
     const body = {
     const body = {

+ 199 - 0
src/app/services/portrait-pipeline.service.ts

@@ -0,0 +1,199 @@
+import { Injectable } from '@angular/core';
+import { Observable, from, throwError } from 'rxjs';
+import { switchMap, map, catchError } from 'rxjs/operators';
+import { JimengService } from './jimeng.service';
+import { LlmService } from './llm.service';
+
+export type PortraitProgressFn = (status: string, progress: number) => void;
+
+export interface BenchmarkPromptResult {
+  pose: string;
+  scene: string;
+  composition: string;
+  /** 已废弃:之前会描述参考图服装,反而污染身份 */
+  outfit?: string;
+  merged: string;
+}
+
+export interface PortraitPipelineResult {
+  selfieUrl: string;
+  benchmarkUrl?: string;
+  /** 兼容旧字段:现在等于 selfieUrl(不再单独生成全身图) */
+  fullBodyUrl: string;
+  benchmarkPrompt?: BenchmarkPromptResult;
+  finalPortraitUrl: string;
+}
+
+/**
+ * 数字人形象图准备流水线(v2,已移除全身图生成步骤):
+ * Step 1:Gemini 分析对标场景图,仅提取「姿态/动作/场景/构图」(不含人物外观)
+ * Step 2:jimeng-img-v4 双图编辑:用「形象图人物」替换「参考图人物」,
+ *         仅迁移参考图的姿态、动作、场景与构图。
+ */
+@Injectable({ providedIn: 'root' })
+export class PortraitPipelineService {
+
+  constructor(
+    private jimeng: JimengService,
+    private llm: LlmService
+  ) {}
+
+  /**
+   * Step 1:用 Gemini 分析对标场景图,提取姿态/动作/场景/构图
+   * 注意:刻意不让 Gemini 描述参考图人物的外观/服装,避免后续 prompt 把
+   *       参考图人物画回来。
+   */
+  describeBenchmark(
+    benchmarkUrl: string,
+    onProgress?: PortraitProgressFn
+  ): Observable<BenchmarkPromptResult> {
+    if (onProgress) onProgress('正在分析参考图...', 10);
+
+    // 注意:避免使用「替换 / 换脸 / face swap / 身份」等触发 Gemini 安全策略的词汇,
+    //       只做中性的视觉描述。
+    const ask = `请描述这张图片,输出严格的 JSON(仅 JSON,不要前后多余文字、不要 markdown 代码块):
+{
+  "pose": "图中人物的肢体动作、手部姿势、头部朝向、视线方向,60字内",
+  "scene": "所处场景、环境、背景陈设、光线氛围,60字内",
+  "composition": "镜头景别、机位、角度、人物在画面中的位置、画幅比例,40字内"
+}
+仅描述视觉信息,使用中文,不要解释。`;
+
+    return from(this.llm.urlToBase64(benchmarkUrl)).pipe(
+      switchMap(({ base64, mimeType }) => {
+        if (onProgress) onProgress('Gemini 正在反推姿态/场景...', 40);
+        return this.llm.analyzeImage(base64, mimeType, ask, {
+          model: 'gemini-2.5-flash',
+          // 中文字符 token 消耗约 2~3 倍于英文,160 字描述 + JSON 结构需 ~1500
+          generationConfig: { temperature: 0.4, maxOutputTokens: 1500 }
+        });
+      }),
+      map((rawText: string) => {
+        const text = (rawText || '').trim();
+        console.log('🔍 Gemini 参考图分析原始返回:', text);
+        if (!text) {
+          throw new Error('Gemini 返回为空(可能触发了安全策略,请尝试更换参考图)');
+        }
+        // 去掉所有 markdown code fence(开头/结尾/中间任意位置)
+        const cleaned = text
+          .replace(/```(?:json)?/gi, '')
+          .replace(/```/g, '')
+          .trim();
+        // 找到第一个 { 和最后一个 } 之间的内容
+        const firstBrace = cleaned.indexOf('{');
+        const lastBrace = cleaned.lastIndexOf('}');
+        if (firstBrace < 0) {
+          throw new Error(`Gemini 未返回 JSON 结构。原始内容前 300 字:\n${text.slice(0, 300)}`);
+        }
+        if (lastBrace < 0 || lastBrace <= firstBrace) {
+          // 截断:有开头 { 但没闭合 } —— 尝试手工补齐
+          throw new Error(`Gemini 返回被截断(无闭合括号),请重试或缩短参考图分析提示。已收到 ${text.length} 字符。`);
+        }
+        const jsonStr = cleaned.slice(firstBrace, lastBrace + 1);
+        let parsed: any;
+        try {
+          parsed = JSON.parse(jsonStr);
+        } catch (e) {
+          throw new Error(`JSON 解析失败:${(e as Error).message}\n内容:${jsonStr.slice(0, 300)}`);
+        }
+        const pose = String(parsed.pose || '').trim();
+        const scene = String(parsed.scene || '').trim();
+        const composition = String(parsed.composition || '').trim();
+        const merged = [
+          pose ? `动作姿态:${pose}` : '',
+          scene ? `场景环境:${scene}` : '',
+          composition ? `构图镜头:${composition}` : ''
+        ].filter(Boolean).join(';') + '。';
+        if (onProgress) onProgress('参考图分析完成', 100);
+        return { pose, scene, composition, merged };
+      }),
+      catchError(err => {
+        if (onProgress) onProgress(`参考图分析失败:${err?.message || err}`, 0);
+        return throwError(() => err);
+      })
+    );
+  }
+
+  /**
+   * Step 2:人物替换 —— 用第一张图(用户形象图)的人物去替换第二张图(参考图)的人物,
+   *         仅保留第二张图的姿态、动作、场景与构图。
+   */
+  composeBenchmarkedPortrait(
+    userPortraitUrl: string,
+    benchmarkUrl: string,
+    benchmark: BenchmarkPromptResult,
+    onProgress?: PortraitProgressFn
+  ): Observable<string> {
+    const prompt = [
+      '【任务】人物替换:把第二张图中的人物,替换为第一张图中的人物。',
+      '【身份保持】严格保留第一张图人物的面部特征、五官、发型、发色、肤色、性别、年龄、体型与服装风格,',
+      '完全不得改变身份;最终人物必须一眼看上去就是第一张图里的同一个人。',
+      '【姿态/场景迁移】仅借用第二张图的:人物动作、肢体姿态、手势、视线、所处场景、背景陈设、光线氛围、镜头景别与构图角度。',
+      '【外观禁用】不要采用第二张图人物的脸、发型、肤色、年龄、性别或服装;不要把人物画成第二张图里的那个人。',
+      benchmark.merged,
+      '【输出】单人写实摄影风格,自然光,4K 高清,无文字、无水印、无字幕。'
+    ].filter(Boolean).join('');
+
+    return this.jimeng.generateImgV4(
+      prompt,
+      [userPortraitUrl, benchmarkUrl],
+      { width: 2048, height: 2560 },
+      onProgress
+    );
+  }
+
+  /**
+   * 一键执行:用户形象图 + 对标场景图 -> 对标后用户形象图(人物替换)
+   * stages: 0~40% 反推提示词,40~100% 融合生图
+   */
+  buildBenchmarkedPortrait(
+    selfieUrl: string,
+    benchmarkUrl: string | null,
+    onProgress?: PortraitProgressFn
+  ): Observable<PortraitPipelineResult> {
+    const emit = (s: string, p: number) => onProgress && onProgress(s, p);
+
+    if (!benchmarkUrl) {
+      emit('未提供参考图,直接使用原图', 100);
+      // 兜底:没有参考图则原图直通
+      return new Observable<PortraitPipelineResult>(sub => {
+        sub.next({
+          selfieUrl,
+          fullBodyUrl: selfieUrl,
+          finalPortraitUrl: selfieUrl
+        });
+        sub.complete();
+      });
+    }
+
+    emit('【1/2】正在分析参考图姿态与场景...', 5);
+
+    return this.describeBenchmark(benchmarkUrl, (s, p) => {
+      // 0~40%
+      emit(`【1/2】${s}`, Math.min(5 + p * 0.35, 40));
+    }).pipe(
+      switchMap((benchmark) => {
+        emit('【2/2】正在用形象图人物替换参考图人物...', 42);
+        return this.composeBenchmarkedPortrait(selfieUrl, benchmarkUrl, benchmark, (s, p) => {
+          // 40~99%
+          emit(`【2/2】${s}`, Math.min(40 + p * 0.6, 99));
+        }).pipe(
+          map((finalPortraitUrl) => {
+            emit('对标形象图生成完成', 100);
+            return {
+              selfieUrl,
+              benchmarkUrl,
+              fullBodyUrl: selfieUrl, // 兼容字段
+              benchmarkPrompt: benchmark,
+              finalPortraitUrl
+            } as PortraitPipelineResult;
+          })
+        );
+      }),
+      catchError(err => {
+        emit(`形象生成失败:${err?.message || err}`, 0);
+        return throwError(() => err);
+      })
+    );
+  }
+}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor