|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|