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: `
直接使用上传图
沿用原始上传的人物形象图,不做 AI 处理。
参考图人物替换
上传参考图,用您上传形象图中的人物替换参考图人物,保留参考图的姿态、动作与场景。
上传一张参考图(含人物 + 背景)
{{benchmarkFileName}}
jimeng-img-v4 + Gemini
{{statusText}}
{{progress}}%
{{errorText}}
原图
原图
参考图(姿态/场景)
参考图
✨ 人物替换后形象图(将用于数字人)
生成形象图
参考图姿态提示词:{{generatedSummary}}
`, 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: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease; 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(); 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 = 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 { if (cachedUrl && cachedFile === file) { return Promise.resolve(cachedUrl); } return new Promise((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; } }