| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- 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<void> {
- 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<string, any>) => {
- 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`;
- }
- }
|