import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Subscription } from 'rxjs'; import { JimengService } from '../../../services/jimeng.service'; import { ResultsService } from '../../../services/results.service'; import { PipelineSessionService } from '../../../services/pipeline-session.service'; import { QiniuUploadService } from '../../../services/qiniu-upload.service'; import { SessionBarComponent } from '../../../components/session-bar/session-bar.component'; import { DraftMeta } from '../../../models/pipeline-draft.model'; import { userFriendlyError } from '../../../services/user-message.util'; import { CostEstimatorService } from '../../../services/cost-estimator.service'; import { GenerationTaskService } from '../../../services/generation-task.service'; interface UploadedAsset { url: string; name: string; size: number; preview: string; type: 'image' | 'video'; } /** 会话快照:上传后的七牛云 url 可永久化,本地 preview 不存 */ interface AtSnapshot { characterImage: { url: string; name: string; size: number } | null; referenceVideo: { url: string; name: string; size: number } | null; cutFirstSecond: boolean; resultVideoUrl: string; resultWorkId: string; } /** * P2: 动作迁移 Pipeline * * 调用即梦 getActorV2: * image_url + video_url → 角色图按参考视频动作驱动起来。 * * 上传走七牛云直传(云函数签发 token,支持图片/视频)。 */ @Component({ selector: 'app-action-transfer', standalone: true, imports: [CommonModule, FormsModule, SessionBarComponent], templateUrl: './action-transfer.component.html', styleUrls: ['./action-transfer.component.css'], }) export class ActionTransferComponent implements OnInit, OnDestroy { characterImage: UploadedAsset | null = null; referenceVideo: UploadedAsset | null = null; /** 是否裁剪结果视频的第 1 秒(即梦默认 true,可关闭) */ cutFirstSecond = true; uploadingSlot: 'image' | 'video' | null = null; uploadError = ''; uploadProgress = 0; generating = false; progress = 0; statusText = ''; resultVideoUrl = ''; resultWorkId = ''; errorMsg = ''; private genSub?: Subscription; private generationTaskId = ''; constructor( private qiniuUpload: QiniuUploadService, private jimeng: JimengService, private cdr: ChangeDetectorRef, private zone: NgZone, private results: ResultsService, public session: PipelineSessionService, private generationTasks: GenerationTaskService, private costEstimator: CostEstimatorService, ) {} ngOnInit(): void { void this.session.bootstrap(); const cur = this.session.active(); if (cur && cur.pipelineId === 'action-transfer' && cur.snapshot) { this.fromSnapshot(cur.snapshot as AtSnapshot); } } // ====== Session ====== private toSnapshot(): AtSnapshot { const stripPreview = (a: UploadedAsset | null) => a ? { url: a.url, name: a.name, size: a.size } : null; return { characterImage: stripPreview(this.characterImage), referenceVideo: stripPreview(this.referenceVideo), cutFirstSecond: this.cutFirstSecond, resultVideoUrl: this.resultVideoUrl, resultWorkId: this.resultWorkId, }; } private fromSnapshot(snap: AtSnapshot): void { if (!snap) return; this.characterImage = snap.characterImage ? { ...snap.characterImage, preview: snap.characterImage.url, type: 'image' as const } : null; this.referenceVideo = snap.referenceVideo ? { ...snap.referenceVideo, preview: snap.referenceVideo.url, type: 'video' as const } : null; this.cutFirstSecond = snap.cutFirstSecond ?? true; this.resultVideoUrl = snap.resultVideoUrl ?? ''; this.resultWorkId = snap.resultWorkId ?? ''; this.errorMsg = ''; this.statusText = ''; this.progress = 0; this.cdr.detectChanges(); } private deriveTitle(): string { if (this.resultWorkId) return `动作迁移-${this.resultWorkId}`; if (this.characterImage?.name) return `动作迁移 · ${this.characterImage.name}`; return '动作迁移 · 未命名'; } syncDraft(): void { this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle()); const cur = this.session.active(); const derived = this.deriveTitle(); const isAutoTitle = !cur || !cur.title || cur.title === '未命名创作' || cur.title === derived; this.session.patch({ snapshot: this.toSnapshot(), ...(isAutoTitle ? { title: derived } : {}), }); } onNewSession(): void { if (this.generating) return; this.cancel(); if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview); if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview); this.characterImage = null; this.referenceVideo = null; this.cutFirstSecond = true; this.resultVideoUrl = ''; this.resultWorkId = ''; this.errorMsg = ''; this.statusText = ''; this.progress = 0; this.session.close(); this.cdr.detectChanges(); } async onOpenSession(meta: DraftMeta): Promise { if (this.generating) { alert('当前任务运行中,请先取消或等待完成再切换'); return; } const draft = await this.session.open(meta.id); if (draft?.snapshot) this.fromSnapshot(draft.snapshot as AtSnapshot); } ngOnDestroy(): void { this.genSub?.unsubscribe(); if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview); if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview); } triggerPick(slot: 'image' | 'video', input: HTMLInputElement): void { input.value = ''; input.click(); } onFilePicked(event: Event, slot: 'image' | 'video'): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; if (slot === 'image' && !file.type.startsWith('image/')) { this.uploadError = '角色素材需要图片文件'; return; } if (slot === 'video' && !file.type.startsWith('video/')) { this.uploadError = '参考素材需要视频文件'; return; } const limitMb = slot === 'video' ? 200 : 10; if (file.size > limitMb * 1024 * 1024) { this.uploadError = `${slot === 'video' ? '视频' : '图片'}大小不能超过 ${limitMb}MB`; return; } this.uploadError = ''; this.uploadingSlot = slot; this.uploadProgress = 0; this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, slot === 'image' ? 'image' : 'video').subscribe({ next: (event) => { if (event.state === 'progress') { this.uploadProgress = event.progress; this.cdr.detectChanges(); return; } const url = event.url || ''; if (!url) { this.uploadError = '上传失败,请检查网络后重试'; this.uploadingSlot = null; this.cdr.detectChanges(); return; } const previewUrl = URL.createObjectURL(file); const asset: UploadedAsset = { url, name: file.name, size: file.size, preview: previewUrl, type: slot === 'image' ? 'image' : 'video', }; if (slot === 'image') { if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview); this.characterImage = asset; } else { if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview); this.referenceVideo = asset; } this.uploadingSlot = null; this.uploadProgress = 0; this.cdr.detectChanges(); this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle()); this.session.upsertArtifact( (a) => a.extras?.['slot'] === slot, { type: slot === 'image' ? 'image' : 'video', url: asset.url, title: slot === 'image' ? '角色图片(上传)' : '参考动作视频(上传)', extras: { slot }, }, ); this.session.patch({ snapshot: this.toSnapshot() }); }, error: (err) => { this.uploadError = userFriendlyError(err, '素材上传失败,请检查网络后重试'); this.uploadingSlot = null; this.cdr.detectChanges(); }, }); } removeAsset(slot: 'image' | 'video'): void { if (slot === 'image') { if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview); this.characterImage = null; } else { if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview); this.referenceVideo = null; } } get canGenerate(): boolean { return !!this.characterImage && !!this.referenceVideo && !this.generating; } generate(): void { if (!this.canGenerate) return; this.generating = true; this.progress = 0; this.statusText = '正在提交任务...'; this.errorMsg = ''; this.resultVideoUrl = ''; this.resultWorkId = ''; const estimate = this.costEstimator.estimateActionTransfer(); const task = this.generationTasks.create({ title: this.deriveTitle(), pipelineId: 'action-transfer', operation: estimate.operation, estimatedCredits: estimate.totalCredits, costLines: estimate.lines, snapshot: this.toSnapshot(), steps: [ { id: 'prepare', label: '准备素材' }, { id: 'submit', label: '提交任务' }, { id: 'poll', label: '等待生成' }, { id: 'archive', label: '归档结果' }, ], }); this.generationTaskId = task.id; this.generationTasks.markRunning(task.id, 'submit', 5); this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle()); this.session.markRunning(); this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() }); this.genSub = this.jimeng .actionTransfer( this.characterImage!.url, this.referenceVideo!.url, { cutFirstSecond: this.cutFirstSecond }, (status: string, p: number, meta?: Record) => { this.zone.run(() => { if (meta?.['workId']) { this.generationTasks.markWaitingExternal( this.generationTaskId, { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') }, 'poll', Math.round(p), ); } else { this.generationTasks.markRunning(this.generationTaskId, p >= 15 ? 'poll' : 'submit', Math.round(p)); } this.statusText = status; this.progress = Math.round(p); this.cdr.detectChanges(); }); }, ) .subscribe({ next: (result) => { this.zone.run(() => { this.resultVideoUrl = result.videoUrl; this.resultWorkId = result.workId; this.progress = 100; this.statusText = '动作迁移完成!'; this.generating = false; this.cdr.detectChanges(); const extras = { workId: result.workId, cutFirstSecond: this.cutFirstSecond }; this.session.patch({ snapshot: this.toSnapshot() }); this.session.finalize(result.videoUrl, extras); this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100); this.generationTasks.markCompleted(this.generationTaskId, result.videoUrl); this.results.saveResult({ type: 'video', url: result.videoUrl, title: `动作迁移-${result.workId}`, pipelineId: 'action_transfer', extras, }).subscribe(); }); }, error: (err) => { this.zone.run(() => { console.error('[action-transfer] error', err); this.errorMsg = userFriendlyError(err, '动作视频生成失败,请稍后重试'); this.statusText = ''; this.generating = false; this.cdr.detectChanges(); this.session.fail(this.errorMsg); this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true }); }); }, }); } cancel(): void { this.genSub?.unsubscribe(); this.generating = false; this.statusText = '已取消'; this.progress = 0; } reset(): void { this.cancel(); this.resultVideoUrl = ''; this.resultWorkId = ''; this.errorMsg = ''; this.statusText = ''; this.progress = 0; } formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } }