| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
- import { CommonModule } from '@angular/common';
- import { FormsModule } from '@angular/forms';
- import { Subscription } from 'rxjs';
- import { ResultsService } from '../../../services/results.service';
- import {
- JimengService,
- JimengCameraTemplateId,
- JimengCameraStrength,
- JimengAspectRatio,
- JimengVideoQuality,
- } from '../../../services/jimeng.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';
- import { VideoDurationService } from '../../../services/video-duration.service';
- interface CameraTemplateOption {
- id: JimengCameraTemplateId;
- label: string;
- hint: string;
- }
- const CAMERA_TEMPLATES: CameraTemplateOption[] = [
- { id: 'hitchcock_dolly_in', label: '希区柯克推进', hint: '边推进边变焦的紧张感' },
- { id: 'hitchcock_dolly_out', label: '希区柯克拉远', hint: '边拉远边变焦的孤立感' },
- { id: 'quick_pull_back', label: '超级拉远', hint: '快速向后拉远揭示场景' },
- { id: 'crane_push', label: '起重机', hint: '由低到高的提升运镜' },
- { id: 'robo_arm', label: '机械臂', hint: '机械臂式精准运动' },
- { id: 'dynamic_orbit', label: '动感环绕', hint: '快速环绕主体' },
- { id: 'central_orbit', label: '中心环绕', hint: '围绕中心点平稳环绕' },
- { id: 'clockwise_swivel', label: '顺时针回旋', hint: '顺时针旋转镜头' },
- { id: 'counterclockwise_swivel', label: '逆时针回旋', hint: '逆时针旋转镜头' },
- { id: 'handheld', label: '手持运镜', hint: '手持晃动的纪实感' },
- { id: 'rapid_push_pull', label: '快速推拉', hint: '快速来回推拉' },
- ];
- interface I2vUploadedImage {
- url: string;
- name: string;
- size: number;
- preview: string; // local object URL for instant preview
- }
- type I2vQuality = JimengVideoQuality;
- type I2vAspect = JimengAspectRatio;
- type I2vMode = '2' | '3' | '4'; // 2首帧 3首尾帧 4运镜
- /** 会话快照:只存可序列化字段(File / ObjectURL 不入快照,但七牛云 url 可保留) */
- interface I2vSnapshot {
- prompt: string;
- mode: I2vMode;
- quality: I2vQuality;
- aspect: I2vAspect;
- frames: number;
- durationSeconds?: number;
- cameraTemplate: JimengCameraTemplateId;
- cameraStrength: JimengCameraStrength;
- firstFrame: { url: string; name: string; size: number } | null;
- lastFrame: { url: string; name: string; size: number } | null;
- resultVideoUrl: string;
- resultWorkId: string;
- }
- /**
- * P1: 图生视频 Pipeline
- *
- * 复用 JimengService.remixVideo({ method:'2'|'3', imageUrl, quality })。
- * 上传走七牛云直传(云函数签发 token)。
- */
- @Component({
- selector: 'app-image-to-video',
- standalone: true,
- imports: [CommonModule, FormsModule, SessionBarComponent],
- templateUrl: './image-to-video.component.html',
- styleUrls: ['./image-to-video.component.css'],
- })
- export class ImageToVideoComponent implements OnInit, OnDestroy {
- // ---- 输入 ----
- prompt = '';
- /** '2' = 单图首帧;'3' = 首尾帧;'4' = 运镜 */
- mode: I2vMode = '2';
- quality: I2vQuality = '1080p';
- aspect: I2vAspect = '16:9';
- /** 用户看秒数,提交时转换为 frames */
- durationSeconds = 5;
- frames = 121;
- readonly durationOptions = [5, 8, 10, 15];
- // 运镜参数
- readonly cameraTemplates = CAMERA_TEMPLATES;
- cameraTemplate: JimengCameraTemplateId = 'quick_pull_back';
- cameraStrength: JimengCameraStrength = 'medium';
- // ---- 上传素材 ----
- firstFrame: I2vUploadedImage | null = null;
- lastFrame: I2vUploadedImage | null = null;
- uploadingSlot: 'first' | 'last' | null = null;
- uploadError = '';
- // ---- 生成状态 ----
- generating = false;
- progress = 0;
- statusText = '';
- resultVideoUrl = '';
- resultWorkId = '';
- errorMsg = '';
- // ---- 预设提示词 ----
- readonly promptPresets: { label: string; value: string }[] = [
- { label: '镜头缓推', value: '镜头缓慢推进,画面富有电影感' },
- { label: '人物动作', value: '主体自然走动,环境光影变化' },
- { label: '风吹动感', value: '微风吹拂,画面元素自然摆动' },
- { label: '光影流动', value: '光线随时间流动变化,电影质感' },
- { label: '运镜环绕', value: '镜头环绕主体缓慢旋转一周' },
- ];
- 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,
- private videoDuration: VideoDurationService,
- ) {}
- ngOnInit(): void {
- void this.session.bootstrap();
- const cur = this.session.active();
- if (cur && cur.pipelineId === 'image-to-video' && cur.snapshot) {
- this.fromSnapshot(cur.snapshot as I2vSnapshot);
- }
- }
- // ====== Session ======
- private toSnapshot(): I2vSnapshot {
- const stripPreview = (f: I2vUploadedImage | null) =>
- f ? { url: f.url, name: f.name, size: f.size } : null;
- return {
- prompt: this.prompt,
- mode: this.mode,
- quality: this.quality,
- aspect: this.aspect,
- frames: this.frames,
- durationSeconds: this.durationSeconds,
- cameraTemplate: this.cameraTemplate,
- cameraStrength: this.cameraStrength,
- firstFrame: stripPreview(this.firstFrame),
- lastFrame: stripPreview(this.lastFrame),
- resultVideoUrl: this.resultVideoUrl,
- resultWorkId: this.resultWorkId,
- };
- }
- private fromSnapshot(snap: I2vSnapshot): void {
- if (!snap) return;
- this.prompt = snap.prompt ?? '';
- this.mode = (snap.mode ?? '2') as I2vMode;
- this.quality = (snap.quality ?? '1080p') as I2vQuality;
- this.aspect = (snap.aspect ?? '16:9') as I2vAspect;
- this.durationSeconds = Math.max(3, Math.min(15, Math.round(Number(snap.durationSeconds ?? this.videoDuration.framesToSeconds(snap.frames ?? 121)) || 5)));
- this.frames = this.videoDuration.secondsToFrames(this.durationSeconds);
- this.cameraTemplate = snap.cameraTemplate ?? 'quick_pull_back';
- this.cameraStrength = snap.cameraStrength ?? 'medium';
- // 恢复首/尾帧:preview 丢失(本地预览 URL),调用方仅依赖 url 即可继续生成
- this.firstFrame = snap.firstFrame ? { ...snap.firstFrame, preview: snap.firstFrame.url } : null;
- this.lastFrame = snap.lastFrame ? { ...snap.lastFrame, preview: snap.lastFrame.url } : null;
- this.resultVideoUrl = snap.resultVideoUrl ?? '';
- this.resultWorkId = snap.resultWorkId ?? '';
- this.errorMsg = '';
- this.statusText = '';
- this.progress = 0;
- this.cdr.detectChanges();
- }
- private deriveTitle(): string {
- const t = (this.prompt || '').trim();
- if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t;
- const modeLabel = this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : '运镜';
- return `图生视频 · ${modeLabel}`;
- }
- syncDraft(): void {
- this.session.ensureActive('image-to-video', () => 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();
- this.prompt = '';
- if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
- if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
- this.firstFrame = null;
- this.lastFrame = null;
- 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 I2vSnapshot);
- }
- ngOnDestroy(): void {
- this.genSub?.unsubscribe();
- if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
- if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
- }
- // ===== 上传 =====
- triggerFilePick(slot: 'first' | 'last', input: HTMLInputElement): void {
- input.value = '';
- input.click();
- }
- onFilePicked(event: Event, slot: 'first' | 'last'): void {
- const input = event.target as HTMLInputElement;
- const file = input.files?.[0];
- if (!file) return;
- if (!file.type.startsWith('image/')) {
- this.uploadError = '请上传图片文件';
- return;
- }
- if (file.size > 10 * 1024 * 1024) {
- this.uploadError = '图片大小不能超过 10MB';
- return;
- }
- this.uploadError = '';
- this.uploadingSlot = slot;
- this.qiniuUpload.uploadFile(file, file.name, file.type, 'image').subscribe({
- next: (url: string) => {
- if (!url) {
- this.uploadError = '上传失败:未返回 URL';
- this.uploadingSlot = null;
- this.cdr.detectChanges();
- return;
- }
- const previewUrl = URL.createObjectURL(file);
- const img: I2vUploadedImage = {
- url,
- name: file.name,
- size: file.size,
- preview: previewUrl,
- };
- if (slot === 'first') {
- if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
- this.firstFrame = img;
- } else {
- if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
- this.lastFrame = img;
- }
- this.uploadingSlot = null;
- this.cdr.detectChanges();
- },
- error: (err) => {
- console.error('[i2v] upload error', err);
- this.uploadError = userFriendlyError(err, '图片上传失败,请检查网络后重试');
- this.uploadingSlot = null;
- this.cdr.detectChanges();
- },
- });
- }
- removeFrame(slot: 'first' | 'last'): void {
- if (slot === 'first') {
- if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
- this.firstFrame = null;
- } else {
- if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
- this.lastFrame = null;
- }
- }
- applyPreset(value: string): void {
- this.prompt = this.prompt
- ? `${this.prompt.replace(/\s+$/, '')},${value}`
- : value;
- }
- // ===== 生成 =====
- /** 当前质量与模式是否兼容 */
- get qualityCompatible(): boolean {
- if (this.mode === '3' && this.quality === 'pro') return false;
- if (this.mode === '4' && this.quality !== '720p') return false;
- return true;
- }
- /** 切换模式时自动修正不兼容的质量 */
- setMode(m: I2vMode): void {
- this.mode = m;
- if (m === '3' && this.quality === 'pro') this.quality = '1080p';
- if (m === '4') this.quality = '720p';
- }
- setQuality(q: I2vQuality): void {
- // 反向保护:如果选择了不兼容的质量,回到首帧模式
- if (this.mode === '4' && q !== '720p') return;
- if (this.mode === '3' && q === 'pro') return;
- this.quality = q;
- }
- setDurationSeconds(seconds: number): void {
- this.durationSeconds = Math.max(3, Math.min(15, Math.round(Number(seconds) || 5)));
- this.frames = this.videoDuration.secondsToFrames(this.durationSeconds);
- this.syncDraft();
- }
- get canGenerate(): boolean {
- if (this.generating) return false;
- // 运镜模式提示词可选;其它模式必填
- if (this.mode !== '4' && !this.prompt.trim()) return false;
- if (!this.firstFrame) return false;
- if (this.mode === '3' && !this.lastFrame) return false;
- if (this.mode === '4' && !this.cameraTemplate) return false;
- return this.qualityCompatible;
- }
- generate(): void {
- if (!this.canGenerate) return;
- this.generating = true;
- this.progress = 0;
- this.statusText = '正在提交任务...';
- this.errorMsg = '';
- this.resultVideoUrl = '';
- this.resultWorkId = '';
- const estimate = this.costEstimator.estimateImageToVideo(this.quality, this.durationSeconds);
- const task = this.generationTasks.create({
- title: this.deriveTitle(),
- pipelineId: 'image-to-video',
- 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('image-to-video', () => this.toSnapshot(), this.deriveTitle());
- this.session.markRunning();
- this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
- const imageUrls =
- this.mode === '3'
- ? [this.firstFrame!.url, this.lastFrame!.url]
- : [this.firstFrame!.url];
- this.genSub = this.jimeng
- .generateImageToVideo(
- {
- imageUrls,
- prompt: this.prompt.trim(),
- method: this.mode,
- quality: this.quality,
- frames: this.frames,
- aspectRatio: this.aspect,
- cameraTemplate: this.mode === '4' ? this.cameraTemplate : undefined,
- cameraStrength: this.mode === '4' ? this.cameraStrength : undefined,
- },
- (status: string, progress: 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(progress),
- );
- } else {
- this.generationTasks.markRunning(this.generationTaskId, progress >= 15 ? 'poll' : 'submit', Math.round(progress));
- }
- this.statusText = status;
- this.progress = Math.round(progress);
- 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 modeLabel = this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : '运镜';
- const extras = { workId: result.workId, mode: this.mode, aspect: this.aspect };
- 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: `图生视频-${modeLabel}-${this.quality.toUpperCase()}`,
- quality: this.quality,
- duration: `${this.durationSeconds}s`,
- pipelineId: 'image_to_video',
- extras,
- }).subscribe();
- });
- },
- error: (err) => {
- this.zone.run(() => {
- console.error('[i2v] generate 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`;
- }
- }
|