|
@@ -1,12 +1,20 @@
|
|
|
-import { Component, ChangeDetectorRef, NgZone, OnDestroy } from '@angular/core';
|
|
|
|
|
|
|
+import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { HttpClient } from '@angular/common/http';
|
|
|
import { Subscription, from, of, throwError, timer, Observable } from 'rxjs';
|
|
import { Subscription, from, of, throwError, timer, Observable } from 'rxjs';
|
|
|
import { catchError, concatMap, finalize, map, toArray, mergeMap, retryWhen } from 'rxjs/operators';
|
|
import { catchError, concatMap, finalize, map, toArray, mergeMap, retryWhen } from 'rxjs/operators';
|
|
|
import { LlmService, GeminiContent, GeminiPart } from '../../../services/llm.service';
|
|
import { LlmService, GeminiContent, GeminiPart } from '../../../services/llm.service';
|
|
|
-import { composeVideoStream, CompositeHandle } from '../../../pipelines/composite-sse';
|
|
|
|
|
|
|
+import {
|
|
|
|
|
+ BrowserFfmpegService,
|
|
|
|
|
+ CompositeHandle,
|
|
|
|
|
+} from '../../../services/browser-ffmpeg.service';
|
|
|
import { ResultsService } from '../../../services/results.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 { OFFICIAL_VOICE_LIBRARY, VoicePickerComponent, VoiceSelection } from '../../../components/voice-picker/voice-picker.component';
|
|
|
|
|
|
|
|
interface UploadedImage {
|
|
interface UploadedImage {
|
|
|
url: string; // 七牛云公网 URL
|
|
url: string; // 七牛云公网 URL
|
|
@@ -30,6 +38,22 @@ interface RefineMessage {
|
|
|
ts: number;
|
|
ts: number;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** 会话快照:仅存可序列化字段。UploadedImage 中的 preview/base64/File 不入快照。 */
|
|
|
|
|
+interface ArSnapshot {
|
|
|
|
|
+ topic: string;
|
|
|
|
|
+ style: 'documentary' | 'energetic' | 'storytelling' | 'commerce';
|
|
|
|
|
+ voiceMode: 'system' | 'clone';
|
|
|
|
|
+ speakerId: string;
|
|
|
|
|
+ timbreId: string;
|
|
|
|
|
+ voiceName?: string;
|
|
|
|
|
+ images: Array<{
|
|
|
|
|
+ url: string; name: string; size: number; script: string;
|
|
|
|
|
+ audioUrl?: string;
|
|
|
|
|
+ ttsState?: 'idle' | 'pending' | 'done' | 'failed';
|
|
|
|
|
+ }>;
|
|
|
|
|
+ resultVideoUrl: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* P3: 素材合成视频 Pipeline (MVP)
|
|
* P3: 素材合成视频 Pipeline (MVP)
|
|
|
*
|
|
*
|
|
@@ -43,11 +67,11 @@ interface RefineMessage {
|
|
|
@Component({
|
|
@Component({
|
|
|
selector: 'app-asset-remix',
|
|
selector: 'app-asset-remix',
|
|
|
standalone: true,
|
|
standalone: true,
|
|
|
- imports: [CommonModule, FormsModule],
|
|
|
|
|
|
|
+ imports: [CommonModule, FormsModule, SessionBarComponent, VoicePickerComponent],
|
|
|
templateUrl: './asset-remix.component.html',
|
|
templateUrl: './asset-remix.component.html',
|
|
|
styleUrls: ['./asset-remix.component.css'],
|
|
styleUrls: ['./asset-remix.component.css'],
|
|
|
})
|
|
})
|
|
|
-export class AssetRemixComponent implements OnDestroy {
|
|
|
|
|
|
|
+export class AssetRemixComponent implements OnInit, OnDestroy {
|
|
|
// ====== 输入 ======
|
|
// ====== 输入 ======
|
|
|
topic = '';
|
|
topic = '';
|
|
|
/** 风格关键词:影响 LLM 生成口吻 */
|
|
/** 风格关键词:影响 LLM 生成口吻 */
|
|
@@ -61,10 +85,14 @@ export class AssetRemixComponent implements OnDestroy {
|
|
|
voiceMode: 'system' | 'clone' = 'system';
|
|
voiceMode: 'system' | 'clone' = 'system';
|
|
|
|
|
|
|
|
/** 系统音色 speaker_id */
|
|
/** 系统音色 speaker_id */
|
|
|
- speakerId = 'zh_female_shuangkuai-am_16k';
|
|
|
|
|
|
|
+ speakerId = '';
|
|
|
|
|
|
|
|
/** 克隆音色 timbreId(来自"语音合成"页训练后的 Parse objectId) */
|
|
/** 克隆音色 timbreId(来自"语音合成"页训练后的 Parse objectId) */
|
|
|
timbreId = '';
|
|
timbreId = '';
|
|
|
|
|
+ voiceName = '';
|
|
|
|
|
+ voicePickerOpen = false;
|
|
|
|
|
+ clonedTimbreList: { objectId: string; name: string; speakerId: string }[] = [];
|
|
|
|
|
+ private readonly timbreListStorageKey = 'tiktok.vsClonedTimbreList';
|
|
|
|
|
|
|
|
// ====== 素材 ======
|
|
// ====== 素材 ======
|
|
|
images: UploadedImage[] = [];
|
|
images: UploadedImage[] = [];
|
|
@@ -129,11 +157,108 @@ export class AssetRemixComponent implements OnDestroy {
|
|
|
constructor(
|
|
constructor(
|
|
|
private http: HttpClient,
|
|
private http: HttpClient,
|
|
|
private llm: LlmService,
|
|
private llm: LlmService,
|
|
|
|
|
+ private qiniuUpload: QiniuUploadService,
|
|
|
private cdr: ChangeDetectorRef,
|
|
private cdr: ChangeDetectorRef,
|
|
|
private zone: NgZone,
|
|
private zone: NgZone,
|
|
|
private results: ResultsService,
|
|
private results: ResultsService,
|
|
|
|
|
+ public session: PipelineSessionService,
|
|
|
|
|
+ private browserFfmpeg: BrowserFfmpegService,
|
|
|
) {}
|
|
) {}
|
|
|
|
|
|
|
|
|
|
+ ngOnInit(): void {
|
|
|
|
|
+ this.restoreClonedTimbreList();
|
|
|
|
|
+ void this.session.bootstrap();
|
|
|
|
|
+ const cur = this.session.active();
|
|
|
|
|
+ if (cur && cur.pipelineId === 'asset-remix' && cur.snapshot) {
|
|
|
|
|
+ this.fromSnapshot(cur.snapshot as ArSnapshot);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====== Session ======
|
|
|
|
|
+ private toSnapshot(): ArSnapshot {
|
|
|
|
|
+ return {
|
|
|
|
|
+ topic: this.topic,
|
|
|
|
|
+ style: this.style,
|
|
|
|
|
+ voiceMode: this.voiceMode,
|
|
|
|
|
+ speakerId: this.speakerId,
|
|
|
|
|
+ timbreId: this.timbreId,
|
|
|
|
|
+ voiceName: this.voiceName,
|
|
|
|
|
+ images: this.images.map((i) => ({
|
|
|
|
|
+ url: i.url, name: i.name, size: i.size, script: i.script,
|
|
|
|
|
+ audioUrl: i.audioUrl,
|
|
|
|
|
+ ttsState: i.ttsState === 'pending' ? 'idle' : i.ttsState,
|
|
|
|
|
+ })),
|
|
|
|
|
+ resultVideoUrl: this.resultVideoUrl,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fromSnapshot(snap: ArSnapshot): void {
|
|
|
|
|
+ if (!snap) return;
|
|
|
|
|
+ this.topic = snap.topic ?? '';
|
|
|
|
|
+ this.style = snap.style ?? 'documentary';
|
|
|
|
|
+ this.voiceMode = snap.voiceMode ?? 'system';
|
|
|
|
|
+ this.speakerId = snap.speakerId ?? '';
|
|
|
|
|
+ this.timbreId = snap.timbreId ?? '';
|
|
|
|
|
+ this.voiceName = snap.voiceName ?? '';
|
|
|
|
|
+ // 恢复图片列表:使用 url 作为 preview(本地 ObjectURL 已丢失),不重新走 base64
|
|
|
|
|
+ this.images = (snap.images || []).map((s) => ({
|
|
|
|
|
+ url: s.url, name: s.name, size: s.size, preview: s.url,
|
|
|
|
|
+ script: s.script || '',
|
|
|
|
|
+ audioUrl: s.audioUrl,
|
|
|
|
|
+ ttsState: s.ttsState ?? 'idle',
|
|
|
|
|
+ base64Loading: false,
|
|
|
|
|
+ }));
|
|
|
|
|
+ this.resultVideoUrl = snap.resultVideoUrl ?? '';
|
|
|
|
|
+ this.errorMsg = '';
|
|
|
|
|
+ this.synthStatus = '';
|
|
|
|
|
+ this.compositeStatus = '';
|
|
|
|
|
+ this.synthProgress = 0;
|
|
|
|
|
+ this.compositeProgress = 0;
|
|
|
|
|
+ this.cdr.detectChanges();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private deriveTitle(): string {
|
|
|
|
|
+ const t = (this.topic || '').trim();
|
|
|
|
|
+ if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t;
|
|
|
|
|
+ if (this.images.length) return `素材合成 · ${this.images.length} 张图`;
|
|
|
|
|
+ return '未命名创作';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ syncDraft(): void {
|
|
|
|
|
+ this.session.ensureActive('asset-remix', () => 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.synthesizing || this.compositing) return;
|
|
|
|
|
+ this.images.forEach((img) => img.preview && URL.revokeObjectURL(img.preview));
|
|
|
|
|
+ this.images = [];
|
|
|
|
|
+ this.topic = '';
|
|
|
|
|
+ this.resultVideoUrl = '';
|
|
|
|
|
+ this.errorMsg = '';
|
|
|
|
|
+ this.synthStatus = '';
|
|
|
|
|
+ this.compositeStatus = '';
|
|
|
|
|
+ this.synthProgress = 0;
|
|
|
|
|
+ this.compositeProgress = 0;
|
|
|
|
|
+ this.session.close();
|
|
|
|
|
+ this.cdr.detectChanges();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async onOpenSession(meta: DraftMeta): Promise<void> {
|
|
|
|
|
+ if (this.synthesizing || this.compositing) {
|
|
|
|
|
+ alert('当前任务运行中,请先取消或等待完成再切换');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const draft = await this.session.open(meta.id);
|
|
|
|
|
+ if (draft?.snapshot) this.fromSnapshot(draft.snapshot as ArSnapshot);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
ngOnDestroy(): void {
|
|
ngOnDestroy(): void {
|
|
|
this.genSub?.unsubscribe();
|
|
this.genSub?.unsubscribe();
|
|
|
this.images.forEach((img) => img.preview && URL.revokeObjectURL(img.preview));
|
|
this.images.forEach((img) => img.preview && URL.revokeObjectURL(img.preview));
|
|
@@ -170,16 +295,19 @@ export class AssetRemixComponent implements OnDestroy {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const formData = new FormData();
|
|
|
|
|
- formData.append('file', file);
|
|
|
|
|
const preview = URL.createObjectURL(file);
|
|
const preview = URL.createObjectURL(file);
|
|
|
const placeholderKey = `${file.name}-${file.size}`;
|
|
const placeholderKey = `${file.name}-${file.size}`;
|
|
|
this.uploadProgress[placeholderKey] = 0;
|
|
this.uploadProgress[placeholderKey] = 0;
|
|
|
|
|
|
|
|
- this.http.post<any>('/backend/api/remix/upload-asset', formData).subscribe({
|
|
|
|
|
- next: (res) => {
|
|
|
|
|
|
|
+ this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, 'image').subscribe({
|
|
|
|
|
+ next: (event) => {
|
|
|
this.zone.run(() => {
|
|
this.zone.run(() => {
|
|
|
- const url = res?.url;
|
|
|
|
|
|
|
+ if (event.state === 'progress') {
|
|
|
|
|
+ this.uploadProgress[placeholderKey] = event.progress;
|
|
|
|
|
+ this.cdr.detectChanges();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const url = event.url || '';
|
|
|
if (!url) {
|
|
if (!url) {
|
|
|
this.uploadError = `${file.name} 上传失败:未返回 URL`;
|
|
this.uploadError = `${file.name} 上传失败:未返回 URL`;
|
|
|
} else {
|
|
} else {
|
|
@@ -194,6 +322,17 @@ export class AssetRemixComponent implements OnDestroy {
|
|
|
mimeType: file.type || 'image/jpeg',
|
|
mimeType: file.type || 'image/jpeg',
|
|
|
};
|
|
};
|
|
|
this.images.push(img);
|
|
this.images.push(img);
|
|
|
|
|
+ // 上传产物入会话
|
|
|
|
|
+ this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
|
|
|
|
|
+ this.session.upsertArtifact(
|
|
|
|
|
+ (a) => a.type === 'image' && a.extras?.['url'] === url,
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'image', url,
|
|
|
|
|
+ title: `素材 ${this.images.length} - ${file.name}`,
|
|
|
|
|
+ extras: { url, source: 'upload' },
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+ this.session.patch({ snapshot: this.toSnapshot() });
|
|
|
// 后台异步把图片转 base64,便于后续 Gemini 视觉识别
|
|
// 后台异步把图片转 base64,便于后续 Gemini 视觉识别
|
|
|
this.llm.fileToBase64(file)
|
|
this.llm.fileToBase64(file)
|
|
|
.then((b64) => this.zone.run(() => {
|
|
.then((b64) => this.zone.run(() => {
|
|
@@ -531,6 +670,65 @@ ${text}
|
|
|
return (this.voiceMode === 'system' ? this.speakerId : this.timbreId).trim();
|
|
return (this.voiceMode === 'system' ? this.speakerId : this.timbreId).trim();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ get currentVoiceSelection(): VoiceSelection | null {
|
|
|
|
|
+ if (this.voiceMode === 'clone' && this.timbreId) {
|
|
|
|
|
+ const matched = this.clonedTimbreList.find((item) => item.objectId === this.timbreId);
|
|
|
|
|
+ return {
|
|
|
|
|
+ source: 'cloned',
|
|
|
|
|
+ name: this.voiceName || matched?.name || this.timbreId,
|
|
|
|
|
+ id: this.timbreId,
|
|
|
|
|
+ timbreId: this.timbreId,
|
|
|
|
|
+ speakerId: matched?.speakerId,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.voiceMode === 'system' && this.speakerId) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ source: 'official',
|
|
|
|
|
+ name: this.voiceName || OFFICIAL_VOICE_LIBRARY.find((item) => item.voiceType === this.speakerId)?.name || this.speakerId,
|
|
|
|
|
+ id: this.speakerId,
|
|
|
|
|
+ voiceType: this.speakerId,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ openVoicePicker(): void {
|
|
|
|
|
+ this.restoreClonedTimbreList();
|
|
|
|
|
+ this.voicePickerOpen = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ closeVoicePicker(): void {
|
|
|
|
|
+ this.voicePickerOpen = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onVoicePicked(sel: VoiceSelection): void {
|
|
|
|
|
+ this.voiceMode = sel.source === 'official' ? 'system' : 'clone';
|
|
|
|
|
+ this.voiceName = sel.name;
|
|
|
|
|
+ if (sel.source === 'official') {
|
|
|
|
|
+ this.speakerId = sel.voiceType || sel.id;
|
|
|
|
|
+ this.timbreId = '';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.timbreId = sel.timbreId || sel.id;
|
|
|
|
|
+ this.speakerId = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ this.errorMsg = '';
|
|
|
|
|
+ this.voicePickerOpen = false;
|
|
|
|
|
+ this.session.patch({ snapshot: this.toSnapshot() });
|
|
|
|
|
+ this.cdr.detectChanges();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private restoreClonedTimbreList(): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const saved = localStorage.getItem(this.timbreListStorageKey);
|
|
|
|
|
+ const parsed = saved ? JSON.parse(saved) : [];
|
|
|
|
|
+ this.clonedTimbreList = Array.isArray(parsed)
|
|
|
|
|
+ ? parsed.filter((item: any) => item && typeof item.objectId === 'string' && item.objectId)
|
|
|
|
|
+ : [];
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ this.clonedTimbreList = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
get canSynthesize(): boolean {
|
|
get canSynthesize(): boolean {
|
|
|
return (
|
|
return (
|
|
|
!this.synthesizing &&
|
|
!this.synthesizing &&
|
|
@@ -546,6 +744,50 @@ ${text}
|
|
|
return this.images.some((i) => i.ttsState === 'failed');
|
|
return this.images.some((i) => i.ttsState === 'failed');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private officialVoiceResourceId(voiceId: string): string {
|
|
|
|
|
+ return voiceId.includes('_uranus_bigtts') ? 'seed-tts-2.0' : 'seed-tts-1.0';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private readErrorText(value: any, fallback: string): string {
|
|
|
|
|
+ const candidates = [
|
|
|
|
|
+ value?.error?.error,
|
|
|
|
|
+ value?.error?.message,
|
|
|
|
|
+ value?.error?.msg,
|
|
|
|
|
+ value?.error,
|
|
|
|
|
+ value?.message,
|
|
|
|
|
+ value?.msg,
|
|
|
|
|
+ value,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ for (const candidate of candidates) {
|
|
|
|
|
+ const text = this.normalizeErrorText(candidate);
|
|
|
|
|
+ if (text) return text;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private normalizeErrorText(value: any, seen = new Set<any>()): string {
|
|
|
|
|
+ if (value == null) return '';
|
|
|
|
|
+ if (typeof value === 'string') return value.trim();
|
|
|
|
|
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
|
|
|
+ if (typeof value !== 'object') return '';
|
|
|
|
|
+ if (seen.has(value)) return '';
|
|
|
|
|
+ seen.add(value);
|
|
|
|
|
+
|
|
|
|
|
+ for (const key of ['message', 'msg', 'error', 'detail', 'reason']) {
|
|
|
|
|
+ const nested = this.normalizeErrorText(value?.[key], seen);
|
|
|
|
|
+ if (nested) return nested;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const serialized = JSON.stringify(value);
|
|
|
|
|
+ return serialized && serialized !== '{}' ? serialized : '';
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 单段 TTS 调用。返回 Observable,包含 3 次指数退避重试。
|
|
* 单段 TTS 调用。返回 Observable,包含 3 次指数退避重试。
|
|
|
* 重试只针对网络错误 / 5xx 上游错误;4xx 通常是参数问题,不重试以免浪费配额。
|
|
* 重试只针对网络错误 / 5xx 上游错误;4xx 通常是参数问题,不重试以免浪费配额。
|
|
@@ -560,7 +802,9 @@ ${text}
|
|
|
audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: 0, loudness_rate: 0 },
|
|
audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: 0, loudness_rate: 0 },
|
|
|
};
|
|
};
|
|
|
if (this.voiceMode === 'system') {
|
|
if (this.voiceMode === 'system') {
|
|
|
- payload.speaker_id = this.speakerId.trim();
|
|
|
|
|
|
|
+ const voiceId = this.speakerId.trim();
|
|
|
|
|
+ payload.volcengine_voice_type = voiceId;
|
|
|
|
|
+ payload.x_api_resource_id = this.officialVoiceResourceId(voiceId);
|
|
|
} else {
|
|
} else {
|
|
|
payload.timbreId = this.timbreId.trim();
|
|
payload.timbreId = this.timbreId.trim();
|
|
|
}
|
|
}
|
|
@@ -573,6 +817,7 @@ ${text}
|
|
|
// 非 HTTP 错误但业务码非 200,也按错误抛出
|
|
// 非 HTTP 错误但业务码非 200,也按错误抛出
|
|
|
const e: any = new Error(res?.error?.message || res?.message || `第 ${idx + 1} 段合成失败`);
|
|
const e: any = new Error(res?.error?.message || res?.message || `第 ${idx + 1} 段合成失败`);
|
|
|
e.bizCode = res?.code;
|
|
e.bizCode = res?.code;
|
|
|
|
|
+ e.message = this.readErrorText(res, `第 ${idx + 1} 段合成失败`);
|
|
|
throw e;
|
|
throw e;
|
|
|
}
|
|
}
|
|
|
return url as string;
|
|
return url as string;
|
|
@@ -600,6 +845,17 @@ ${text}
|
|
|
img.audioUrl = url;
|
|
img.audioUrl = url;
|
|
|
img.ttsState = 'done';
|
|
img.ttsState = 'done';
|
|
|
img.ttsError = undefined;
|
|
img.ttsError = undefined;
|
|
|
|
|
+ // TTS 产物入会话
|
|
|
|
|
+ this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
|
|
|
|
|
+ this.session.upsertArtifact(
|
|
|
|
|
+ (a) => a.type === 'audio' && a.extras?.['imageUrl'] === img.url,
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'audio', url, sceneIndex: idx,
|
|
|
|
|
+ title: `素材 ${idx + 1} - TTS音频`,
|
|
|
|
|
+ extras: { imageUrl: img.url, voiceMode: this.voiceMode, voiceId: this.activeVoiceId },
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+ this.session.patch({ snapshot: this.toSnapshot() });
|
|
|
return img;
|
|
return img;
|
|
|
}),
|
|
}),
|
|
|
catchError((err) => {
|
|
catchError((err) => {
|
|
@@ -611,7 +867,7 @@ ${text}
|
|
|
err?.message ||
|
|
err?.message ||
|
|
|
'TTS 失败';
|
|
'TTS 失败';
|
|
|
const status = err?.status ? `HTTP ${err.status} · ` : '';
|
|
const status = err?.status ? `HTTP ${err.status} · ` : '';
|
|
|
- img.ttsError = `${status}${detail}(已重试 ${img.ttsAttempts || 0} 次)`;
|
|
|
|
|
|
|
+ img.ttsError = `${status}${this.readErrorText(err, detail)}(已重试 ${img.ttsAttempts || 0} 次)`;
|
|
|
console.error(`[asset-remix] TTS 段 ${idx + 1} 失败`, err);
|
|
console.error(`[asset-remix] TTS 段 ${idx + 1} 失败`, err);
|
|
|
return of(img);
|
|
return of(img);
|
|
|
}),
|
|
}),
|
|
@@ -707,13 +963,17 @@ ${text}
|
|
|
this.errorMsg = '';
|
|
this.errorMsg = '';
|
|
|
this.resultVideoUrl = '';
|
|
this.resultVideoUrl = '';
|
|
|
|
|
|
|
|
|
|
+ this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
|
|
|
|
|
+ this.session.markRunning();
|
|
|
|
|
+ this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
|
|
|
|
|
+
|
|
|
const segments = this.images.map((img) => ({
|
|
const segments = this.images.map((img) => ({
|
|
|
id: img.url,
|
|
id: img.url,
|
|
|
imageUrl: img.url,
|
|
imageUrl: img.url,
|
|
|
audioUrl: img.audioUrl!,
|
|
audioUrl: img.audioUrl!,
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
- this.compositeHandle = composeVideoStream({
|
|
|
|
|
|
|
+ this.compositeHandle = this.browserFfmpeg.composeVideo({
|
|
|
segments,
|
|
segments,
|
|
|
title: this.topic.trim() || '素材合成视频',
|
|
title: this.topic.trim() || '素材合成视频',
|
|
|
onStage: (e) => {
|
|
onStage: (e) => {
|
|
@@ -735,30 +995,27 @@ ${text}
|
|
|
.then((res) => {
|
|
.then((res) => {
|
|
|
this.zone.run(() => {
|
|
this.zone.run(() => {
|
|
|
this.compositing = false;
|
|
this.compositing = false;
|
|
|
- this.resultVideoUrl = '/backend' + res.videoUrl;
|
|
|
|
|
|
|
+ // 浏览器端合成后七牛 CDN 返回完整 https URL,直接用
|
|
|
|
|
+ this.resultVideoUrl = res.videoUrl;
|
|
|
this.compositeProgress = 100;
|
|
this.compositeProgress = 100;
|
|
|
this.compositeStatus = `合成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
|
|
this.compositeStatus = `合成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
|
|
|
this.cdr.detectChanges();
|
|
this.cdr.detectChanges();
|
|
|
|
|
+ const composeExtras = {
|
|
|
|
|
+ segments: res.segments,
|
|
|
|
|
+ filename: res.filename,
|
|
|
|
|
+ size: res.size,
|
|
|
|
|
+ style: this.style,
|
|
|
|
|
+ voiceMode: this.voiceMode,
|
|
|
|
|
+ voiceId: this.activeVoiceId,
|
|
|
|
|
+ };
|
|
|
|
|
+ this.session.patch({ snapshot: this.toSnapshot() });
|
|
|
|
|
+ this.session.finalize(this.resultVideoUrl, composeExtras);
|
|
|
this.results.saveResult({
|
|
this.results.saveResult({
|
|
|
type: 'video',
|
|
type: 'video',
|
|
|
url: this.resultVideoUrl,
|
|
url: this.resultVideoUrl,
|
|
|
title: this.topic.trim() || '素材合成视频',
|
|
title: this.topic.trim() || '素材合成视频',
|
|
|
pipelineId: 'asset_remix',
|
|
pipelineId: 'asset_remix',
|
|
|
- extras: {
|
|
|
|
|
- segments: res.segments,
|
|
|
|
|
- filename: res.filename,
|
|
|
|
|
- size: res.size,
|
|
|
|
|
- style: this.style,
|
|
|
|
|
- voiceMode: this.voiceMode,
|
|
|
|
|
- voiceId: this.activeVoiceId,
|
|
|
|
|
- },
|
|
|
|
|
- }).subscribe();
|
|
|
|
|
- this.results.saveHistory({
|
|
|
|
|
- keyword: this.topic.trim() || '素材合成',
|
|
|
|
|
- status: 'completed',
|
|
|
|
|
- resultUrl: this.resultVideoUrl,
|
|
|
|
|
- pipelineId: 'asset_remix',
|
|
|
|
|
- styleName: this.style,
|
|
|
|
|
|
|
+ extras: composeExtras,
|
|
|
}).subscribe();
|
|
}).subscribe();
|
|
|
});
|
|
});
|
|
|
})
|
|
})
|
|
@@ -767,6 +1024,7 @@ ${text}
|
|
|
this.compositing = false;
|
|
this.compositing = false;
|
|
|
this.errorMsg = err?.message || '合成失败';
|
|
this.errorMsg = err?.message || '合成失败';
|
|
|
this.cdr.detectChanges();
|
|
this.cdr.detectChanges();
|
|
|
|
|
+ this.session.fail(this.errorMsg);
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|