portrait-prepare.component.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { JimengService } from '../../services/jimeng.service';
  5. import { PortraitPipelineService, PortraitPipelineResult } from '../../services/portrait-pipeline.service';
  6. export type PortraitPrepareMode = 'raw' | 'benchmark';
  7. export interface PortraitPrepareOutput {
  8. /** 最终用于数字人生成的形象图 URL(raw 模式则为空,调用方自行用原 file 上传) */
  9. portraitUrl: string;
  10. /** 中间产物:规范化全身图 URL(benchmark 模式才有值) */
  11. fullBodyUrl: string;
  12. /** 对标提示词文本(benchmark 模式才有值) */
  13. benchmarkSummary: string;
  14. /** 当前模式 */
  15. mode: PortraitPrepareMode;
  16. }
  17. /**
  18. * 数字人形象图准备子组件(v2,已移除全身图生成步骤):
  19. * - raw:直接使用上传图(不调用 AI)
  20. * - benchmark:上传参考图 + Gemini 反推姿态/场景 + jimeng-img-v4 人物替换
  21. * (用上传形象图中的人物替换参考图中的人物,保留参考图姿态/场景)
  22. *
  23. * 仅在 selfieFile 存在时才显示模式切换 UI。
  24. */
  25. @Component({
  26. selector: 'app-portrait-prepare',
  27. standalone: true,
  28. imports: [CommonModule, FormsModule],
  29. template: `
  30. <div class="pp-container" *ngIf="selfieFile">
  31. <label class="pp-label">形象图准备方式</label>
  32. <div class="pp-mode-grid">
  33. <div class="pp-mode-card"
  34. [class.active]="mode === 'raw'"
  35. [class.disabled]="running"
  36. (click)="setMode('raw')">
  37. <div class="pp-mode-title">直接使用上传图</div>
  38. <div class="pp-mode-desc">沿用原始上传的人物形象图,不做 AI 处理。</div>
  39. </div>
  40. <div class="pp-mode-card"
  41. [class.active]="mode === 'benchmark'"
  42. [class.disabled]="running"
  43. (click)="setMode('benchmark')">
  44. <div class="pp-mode-title">参考图人物替换</div>
  45. <div class="pp-mode-desc">上传参考图,用您上传形象图中的人物替换参考图人物,保留参考图的姿态、动作与场景。</div>
  46. </div>
  47. </div>
  48. <!-- 参考图(提供姿态/场景)上传 -->
  49. <div *ngIf="mode === 'benchmark'" class="pp-benchmark">
  50. <label class="pp-label-sub">参考图(提供姿态、动作与场景)</label>
  51. <input type="file"
  52. #benchmarkInput
  53. accept="image/jpeg,image/png,image/jpg,image/gif,image/webp,.jpg,.jpeg,.png,.gif,.webp"
  54. (change)="onBenchmarkSelected($event)"
  55. style="display:none">
  56. <div class="pp-empty" *ngIf="!benchmarkPreviewUrl">
  57. <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">
  58. <rect x="3" y="3" width="18" height="18" rx="2"/>
  59. <circle cx="8.5" cy="10" r="1.5"/>
  60. <path d="M21 15l-5-5L5 21"/>
  61. </svg>
  62. <div class="pp-empty-text">上传一张参考图(含人物 + 背景)</div>
  63. <button class="pp-btn pp-btn-primary" type="button" (click)="benchmarkInput.click()" [disabled]="running">
  64. <svg class="pp-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  65. <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
  66. <polyline points="17 8 12 3 7 8"/>
  67. <line x1="12" y1="3" x2="12" y2="15"/>
  68. </svg>
  69. 选择参考图
  70. </button>
  71. </div>
  72. <div class="pp-preview" *ngIf="benchmarkPreviewUrl">
  73. <img [src]="benchmarkPreviewUrl" [alt]="benchmarkFileName">
  74. <div class="pp-preview-meta">
  75. <div class="pp-preview-name">{{benchmarkFileName}}</div>
  76. <button class="pp-btn pp-btn-ghost" type="button" (click)="clearBenchmark()" [disabled]="running">
  77. <svg class="pp-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  78. <polyline points="3 6 5 6 21 6"/>
  79. <path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/>
  80. <path d="M10 11v6M14 11v6"/>
  81. </svg>
  82. 移除
  83. </button>
  84. </div>
  85. </div>
  86. </div>
  87. <!-- 生成按钮 + 进度 -->
  88. <div *ngIf="mode === 'benchmark'" class="pp-action">
  89. <button class="pp-btn pp-btn-primary" type="button"
  90. (click)="run()"
  91. [disabled]="running || !benchmarkFile">
  92. <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">
  93. <ng-container *ngIf="!running">
  94. <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"/>
  95. </ng-container>
  96. <ng-container *ngIf="running">
  97. <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
  98. </ng-container>
  99. </svg>
  100. {{running ? '生成中...' : (generatedPortraitUrl ? '重新生成' : '生成形象图')}}
  101. </button>
  102. <button class="pp-btn pp-btn-ghost" type="button" *ngIf="generatedPortraitUrl && !running" (click)="reset()">
  103. <svg class="pp-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  104. <polyline points="23 4 23 10 17 10"/>
  105. <polyline points="1 20 1 14 7 14"/>
  106. <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"/>
  107. </svg>
  108. 清除结果
  109. </button>
  110. <span class="pp-hint">jimeng-img-v4 + Gemini</span>
  111. </div>
  112. <div class="pp-status" *ngIf="running || statusText">
  113. <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"
  114. [style.color]="running ? 'var(--accent-color, #3b82f6)' : 'var(--success-color, #10b981)'">
  115. <ng-container *ngIf="running">
  116. <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
  117. </ng-container>
  118. <ng-container *ngIf="!running">
  119. <circle cx="12" cy="12" r="10"/>
  120. <polyline points="9 12 11 14 15 10"/>
  121. </ng-container>
  122. </svg>
  123. <div class="pp-status-body">
  124. <div class="pp-status-text">{{statusText}}</div>
  125. <div class="pp-status-bar" [class.pp-status-bar--indeterminate]="running && progress < 5"><div class="pp-status-bar__fill" [style.width]="progress + '%'"></div></div>
  126. </div>
  127. <div class="pp-status-pct">{{progress}}%</div>
  128. </div>
  129. <div class="pp-error" *ngIf="errorText">{{errorText}}</div>
  130. <!-- 生成结果对比预览 -->
  131. <div class="pp-result" *ngIf="generatedPortraitUrl">
  132. <div class="pp-result-row">
  133. <div class="pp-result-cell">
  134. <div class="pp-result-cap">原图</div>
  135. <img [src]="selfiePreviewUrl" alt="原图">
  136. </div>
  137. <div class="pp-result-cell" *ngIf="benchmarkPreviewUrl">
  138. <div class="pp-result-cap">参考图(姿态/场景)</div>
  139. <img [src]="benchmarkPreviewUrl" alt="参考图">
  140. </div>
  141. <div class="pp-result-cell">
  142. <div class="pp-result-cap pp-result-cap-success">✨ 人物替换后形象图(将用于数字人)</div>
  143. <img [src]="generatedPortraitUrl" alt="生成形象图" class="pp-result-img-final">
  144. </div>
  145. </div>
  146. <div class="pp-summary" *ngIf="generatedSummary">
  147. <strong>参考图姿态提示词:</strong>{{generatedSummary}}
  148. </div>
  149. </div>
  150. </div>
  151. `,
  152. styles: [`
  153. :host { display: block; }
  154. .pp-container { display: flex; flex-direction: column; gap: 10px; }
  155. .pp-label { font-size: 13px; font-weight: 600; color: var(--text-primary, #e5e7eb); }
  156. .pp-label-sub { font-size: 12.5px; color: var(--text-secondary, #9ca3af); display: block; margin-bottom: 6px; }
  157. /* 模式卡片 */
  158. .pp-mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  159. .pp-mode-card {
  160. padding: 10px 12px; border: 1px solid var(--border-color, rgba(255,255,255,0.1));
  161. border-radius: 10px; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
  162. background: rgba(255,255,255,0.02);
  163. }
  164. .pp-mode-card:hover:not(.disabled) { border-color: var(--accent-color, #3b82f6); background: rgba(59,130,246,0.06); }
  165. .pp-mode-card.active {
  166. border-color: var(--accent-color, #3b82f6);
  167. background: linear-gradient(135deg, rgba(59,130,246,0.10), rgba(168,85,247,0.10));
  168. box-shadow: 0 0 0 1px var(--accent-color, #3b82f6) inset;
  169. }
  170. .pp-mode-card.disabled { opacity: 0.5; cursor: not-allowed; }
  171. .pp-mode-title { font-size: 13px; font-weight: 600; margin-bottom: 3px; color: var(--text-primary, #e5e7eb); }
  172. .pp-mode-desc { font-size: 11.5px; color: var(--text-muted, #9ca3af); line-height: 1.45; }
  173. /* 参考图上传 */
  174. .pp-benchmark { margin-top: 2px; }
  175. .pp-empty {
  176. padding: 14px; border: 1.5px dashed var(--border-color, rgba(255,255,255,0.15)); border-radius: 10px;
  177. display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
  178. background: rgba(255,255,255,0.015);
  179. }
  180. .pp-empty-text { font-size: 12px; color: var(--text-muted, #9ca3af); }
  181. .pp-preview {
  182. display: flex; gap: 10px; align-items: center; padding: 8px;
  183. background: rgba(255,255,255,0.04); border-radius: 10px;
  184. border: 1px solid var(--border-color, rgba(255,255,255,0.08));
  185. }
  186. .pp-preview img { width: 56px; height: 56px; object-fit: cover; border-radius: 8px; flex-shrink: 0; }
  187. .pp-preview-meta { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; }
  188. .pp-preview-name {
  189. font-size: 12px; font-weight: 500; color: var(--text-primary, #e5e7eb);
  190. overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  191. }
  192. /* 按钮 —— 使用 button.xxx 提升具体性,避免被全局 .btn / button:hover 覆盖 */
  193. button.pp-btn {
  194. display: inline-flex; align-items: center; gap: 6px;
  195. padding: 7px 12px; border-radius: 8px; font-size: 12.5px; font-weight: 500;
  196. border: 1px solid var(--border-color, rgba(255,255,255,0.12));
  197. background: rgba(99, 102, 241, 0.10);
  198. color: var(--text-primary, #1f2937);
  199. cursor: pointer; transition: background 0.15s, box-shadow 0.15s, transform 0.15s, border-color 0.15s;
  200. white-space: nowrap;
  201. }
  202. button.pp-btn:hover:not(:disabled) {
  203. background: rgba(99, 102, 241, 0.18);
  204. border-color: rgba(99, 102, 241, 0.45);
  205. transform: translateY(-1px);
  206. }
  207. button.pp-btn:disabled { opacity: 0.5; cursor: not-allowed; }
  208. button.pp-btn-primary {
  209. background: linear-gradient(135deg, #3b82f6, #8b5cf6);
  210. border-color: transparent;
  211. color: #ffffff;
  212. box-shadow: 0 2px 8px rgba(59,130,246,0.3);
  213. }
  214. button.pp-btn-primary:hover:not(:disabled) {
  215. background: linear-gradient(135deg, #2563eb, #7c3aed);
  216. border-color: transparent;
  217. color: #ffffff;
  218. box-shadow: 0 4px 14px rgba(59,130,246,0.5);
  219. transform: translateY(-1px);
  220. }
  221. button.pp-btn-primary:active:not(:disabled) {
  222. background: linear-gradient(135deg, #1d4ed8, #6d28d9);
  223. transform: translateY(0);
  224. }
  225. button.pp-btn-ghost {
  226. background: transparent;
  227. border-color: var(--border-color, rgba(0,0,0,0.12));
  228. color: var(--text-primary, #1f2937);
  229. }
  230. button.pp-btn-ghost:hover:not(:disabled) {
  231. background: rgba(0,0,0,0.04);
  232. border-color: rgba(0,0,0,0.2);
  233. }
  234. /* 动作区 */
  235. .pp-action { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
  236. .pp-hint { font-size: 11px; color: var(--text-muted, #6b7280); margin-left: auto; }
  237. /* 状态条 */
  238. .pp-status {
  239. display: flex; align-items: center; gap: 10px; padding: 8px 10px;
  240. background: rgba(255,255,255,0.04); border-radius: 8px;
  241. border: 1px solid var(--border-color, rgba(255,255,255,0.08));
  242. }
  243. .pp-status-body { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
  244. .pp-status-text { font-size: 12px; color: var(--text-primary, #e5e7eb); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  245. .pp-status-bar { position: relative; height: 4px; background: rgba(99,102,241,0.12); border-radius: 2px; overflow: hidden; }
  246. .pp-status-bar__fill {
  247. height: 100%;
  248. background: linear-gradient(90deg, #3b82f6, #8b5cf6);
  249. transition: width 0.3s ease;
  250. }
  251. /* 上传阶段(progress<5)显示流动条纹,避免视觉上静止 */
  252. .pp-status-bar--indeterminate::after {
  253. content: '';
  254. position: absolute; inset: 0;
  255. background: linear-gradient(90deg,
  256. transparent 0%,
  257. rgba(139, 92, 246, 0.6) 50%,
  258. transparent 100%);
  259. animation: pp-indeterminate 1.2s linear infinite;
  260. }
  261. @keyframes pp-indeterminate {
  262. 0% { transform: translateX(-100%); }
  263. 100% { transform: translateX(100%); }
  264. }
  265. .pp-status-pct { font-size: 11.5px; font-weight: 600; color: var(--accent-color, #3b82f6); flex-shrink: 0; }
  266. .pp-error {
  267. padding: 8px 10px; background: rgba(220, 38, 38, 0.08);
  268. color: #ef4444; border-radius: 8px; font-size: 12px;
  269. border: 1px solid rgba(220, 38, 38, 0.25);
  270. }
  271. /* 结果对比 */
  272. .pp-result {
  273. display: flex; flex-direction: column; gap: 8px; padding: 10px;
  274. background: rgba(255,255,255,0.03); border-radius: 10px;
  275. border: 1px solid var(--border-color, rgba(255,255,255,0.08));
  276. }
  277. .pp-result-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
  278. .pp-result-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
  279. .pp-result-cap { font-size: 11px; color: var(--text-muted, #9ca3af); }
  280. .pp-result-cap-success { color: #10b981; font-weight: 600; }
  281. .pp-result-cell img {
  282. width: 100%; aspect-ratio: 4/5; object-fit: cover;
  283. border-radius: 6px; background: rgba(0,0,0,0.2);
  284. }
  285. .pp-result-img-final { box-shadow: 0 0 0 2px #10b981; }
  286. .pp-summary {
  287. font-size: 11.5px; color: var(--text-muted, #9ca3af); padding: 8px 10px;
  288. background: rgba(0,0,0,0.15); border-radius: 6px; line-height: 1.5;
  289. }
  290. .pp-summary strong { color: var(--text-primary, #e5e7eb); }
  291. /* SVG 图标 */
  292. .pp-svg { width: 14px; height: 14px; flex-shrink: 0; }
  293. .pp-svg-lg { width: 28px; height: 28px; color: var(--text-muted, #6b7280); }
  294. .pp-svg.spinning { animation: pp-spin 1s linear infinite; }
  295. @keyframes pp-spin { to { transform: rotate(360deg); } }
  296. /* 窄列适配(在 dh-step-col 280px 列宽下) */
  297. @media (max-width: 1280px) {
  298. .pp-mode-grid { grid-template-columns: 1fr; }
  299. .pp-result-row { grid-template-columns: 1fr; }
  300. .pp-result-cell img { aspect-ratio: auto; max-height: 180px; }
  301. }
  302. `]
  303. })
  304. export class PortraitPrepareComponent implements OnDestroy {
  305. /** 用户上传的自拍/形象图 File(必填) */
  306. @Input() selfieFile: File | null = null;
  307. /** 自拍预览 URL(用于对比展示) */
  308. @Input() selfiePreviewUrl: string = '';
  309. /** 当模式或生成结果变化时通知父组件 */
  310. @Output() prepared = new EventEmitter<PortraitPrepareOutput>();
  311. mode: PortraitPrepareMode = 'raw';
  312. benchmarkFile: File | null = null;
  313. benchmarkFileName: string = '';
  314. benchmarkPreviewUrl: string = '';
  315. running: boolean = false;
  316. statusText: string = '';
  317. progress: number = 0;
  318. errorText: string = '';
  319. generatedPortraitUrl: string = '';
  320. generatedFullBodyUrl: string = '';
  321. generatedSummary: string = '';
  322. // 已上传过的 selfie/benchmark URL,避免重复上传
  323. private cachedSelfieUrl: string = '';
  324. private cachedBenchmarkUrl: string = '';
  325. private cachedSelfieFile: File | null = null;
  326. private cachedBenchmarkFile: File | null = null;
  327. constructor(
  328. private jimeng: JimengService,
  329. private portraitPipeline: PortraitPipelineService
  330. ) {}
  331. ngOnDestroy(): void {
  332. this.revokeBenchmarkPreview();
  333. }
  334. setMode(mode: PortraitPrepareMode): void {
  335. if (this.running) return;
  336. this.mode = mode;
  337. this.generatedPortraitUrl = '';
  338. this.generatedFullBodyUrl = '';
  339. this.generatedSummary = '';
  340. this.statusText = '';
  341. this.progress = 0;
  342. this.errorText = '';
  343. if (mode !== 'benchmark') {
  344. this.clearBenchmark();
  345. }
  346. this.emitState();
  347. }
  348. onBenchmarkSelected(event: Event): void {
  349. const input = event.target as HTMLInputElement;
  350. if (!input.files || input.files.length === 0) return;
  351. const file = input.files[0];
  352. const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/gif', 'image/webp'];
  353. const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
  354. const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
  355. if ((!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) || file.size <= 0) {
  356. this.errorText = '对标场景图仅支持 jpg/jpeg/png/gif/webp 格式';
  357. input.value = '';
  358. return;
  359. }
  360. if (file.size > 20 * 1024 * 1024) {
  361. this.errorText = '对标场景图大小不能超过 20MB';
  362. input.value = '';
  363. return;
  364. }
  365. this.revokeBenchmarkPreview();
  366. this.benchmarkFile = file;
  367. this.benchmarkFileName = file.name;
  368. this.benchmarkPreviewUrl = URL.createObjectURL(file);
  369. this.errorText = '';
  370. this.generatedPortraitUrl = '';
  371. this.cachedBenchmarkUrl = '';
  372. this.cachedBenchmarkFile = null;
  373. input.value = '';
  374. this.emitState();
  375. }
  376. clearBenchmark(): void {
  377. this.revokeBenchmarkPreview();
  378. this.benchmarkFile = null;
  379. this.benchmarkFileName = '';
  380. this.cachedBenchmarkUrl = '';
  381. this.cachedBenchmarkFile = null;
  382. }
  383. reset(): void {
  384. if (this.running) return;
  385. this.generatedPortraitUrl = '';
  386. this.generatedFullBodyUrl = '';
  387. this.generatedSummary = '';
  388. this.statusText = '';
  389. this.progress = 0;
  390. this.errorText = '';
  391. this.emitState();
  392. }
  393. run(): void {
  394. if (this.running) return;
  395. if (!this.selfieFile) {
  396. this.errorText = '请先上传人物自拍/形象图';
  397. return;
  398. }
  399. if (this.mode === 'benchmark' && !this.benchmarkFile) {
  400. this.errorText = '请上传一张对标账号的场景图';
  401. return;
  402. }
  403. this.running = true;
  404. this.errorText = '';
  405. this.progress = 1;
  406. this.statusText = '正在上传形象图...';
  407. // Step 1:上传自拍(带缓存)
  408. this.ensureUrl(this.selfieFile, this.cachedSelfieFile, this.cachedSelfieUrl, 'portrait-selfie').then((selfieUrl) => {
  409. this.cachedSelfieFile = this.selfieFile;
  410. this.cachedSelfieUrl = selfieUrl;
  411. this.progress = 2;
  412. this.statusText = this.benchmarkFile ? '正在上传参考图...' : '形象图上传完成';
  413. // Step 2:上传对标图(带缓存,benchmark 模式才需要)
  414. const benchmarkPromise: Promise<string | null> = this.benchmarkFile
  415. ? this.ensureUrl(this.benchmarkFile, this.cachedBenchmarkFile, this.cachedBenchmarkUrl, 'portrait-benchmark').then((url) => {
  416. this.cachedBenchmarkFile = this.benchmarkFile;
  417. this.cachedBenchmarkUrl = url;
  418. this.progress = 4;
  419. this.statusText = '素材上传完成,准备分析...';
  420. return url;
  421. })
  422. : Promise.resolve(null);
  423. return benchmarkPromise.then((benchmarkUrl) => ({ selfieUrl, benchmarkUrl }));
  424. }).then(({ selfieUrl, benchmarkUrl }) => {
  425. // Step 3:执行 pipeline
  426. this.portraitPipeline.buildBenchmarkedPortrait(selfieUrl, benchmarkUrl, (status, progress) => {
  427. this.statusText = status;
  428. this.progress = Math.round(progress);
  429. }).subscribe({
  430. next: (result: PortraitPipelineResult) => {
  431. this.generatedFullBodyUrl = result.fullBodyUrl;
  432. this.generatedPortraitUrl = result.finalPortraitUrl;
  433. this.generatedSummary = result.benchmarkPrompt?.merged || '';
  434. this.running = false;
  435. this.statusText = '形象图生成完成';
  436. this.progress = 100;
  437. this.emitState();
  438. },
  439. error: (err) => {
  440. this.running = false;
  441. this.errorText = this.getErrorText(err, '形象图生成失败');
  442. this.emitState();
  443. }
  444. });
  445. }).catch((err) => {
  446. this.running = false;
  447. this.errorText = this.getErrorText(err, '素材上传失败');
  448. this.emitState();
  449. });
  450. }
  451. private ensureUrl(file: File, cachedFile: File | null, cachedUrl: string, prefix: string): Promise<string> {
  452. if (cachedUrl && cachedFile === file) {
  453. return Promise.resolve(cachedUrl);
  454. }
  455. return new Promise<string>((resolve, reject) => {
  456. this.jimeng.uploadFileToParse(
  457. file,
  458. `${prefix}-${Date.now()}-${file.name}`,
  459. file.type || 'image/jpeg'
  460. ).subscribe({
  461. next: (url: string) => resolve(url),
  462. error: (err) => reject(err)
  463. });
  464. });
  465. }
  466. private emitState(): void {
  467. this.prepared.emit({
  468. portraitUrl: this.generatedPortraitUrl,
  469. fullBodyUrl: this.generatedFullBodyUrl,
  470. benchmarkSummary: this.generatedSummary,
  471. mode: this.mode
  472. });
  473. }
  474. private revokeBenchmarkPreview(): void {
  475. if (this.benchmarkPreviewUrl) {
  476. URL.revokeObjectURL(this.benchmarkPreviewUrl);
  477. this.benchmarkPreviewUrl = '';
  478. }
  479. }
  480. private getErrorText(err: any, fallback: string): string {
  481. return err?.error?.message || err?.error?.error || err?.message || fallback;
  482. }
  483. }