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 处理。
参考图人物替换
上传参考图,用您上传形象图中的人物替换参考图人物,保留参考图的姿态、动作与场景。
jimeng-img-v4 + Gemini
{{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;
}
}