|
|
@@ -0,0 +1,14509 @@
|
|
|
+import { Component, signal, NgZone, ChangeDetectorRef } from '@angular/core';
|
|
|
+
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
+
|
|
|
+import { FormsModule } from '@angular/forms';
|
|
|
+
|
|
|
+import { RouterModule } from '@angular/router';
|
|
|
+
|
|
|
+import { TimeFormatPipe } from './pipes/time-format.pipe';
|
|
|
+
|
|
|
+import { NumberFormatPipe } from './pipes/number-format.pipe';
|
|
|
+
|
|
|
+import { FileSizePipe } from './pipes/file-size.pipe';
|
|
|
+
|
|
|
+import { DouyinService } from './services/douyin.service';
|
|
|
+
|
|
|
+import { JimengService } from './services/jimeng.service';
|
|
|
+
|
|
|
+import { LlmService } from './services/llm.service';
|
|
|
+
|
|
|
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
|
+
|
|
|
+import { timeout, finalize } from 'rxjs/operators';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+// 类型定义
|
|
|
+
|
|
|
+interface VideoInfo {
|
|
|
+
|
|
|
+ aweme_id: string;
|
|
|
+
|
|
|
+ desc: string;
|
|
|
+
|
|
|
+ cover_url: string;
|
|
|
+
|
|
|
+ cover_candidates?: string[];
|
|
|
+
|
|
|
+ duration: number;
|
|
|
+
|
|
|
+ create_time?: number;
|
|
|
+
|
|
|
+ publishDate?: Date | null;
|
|
|
+
|
|
|
+ author: {
|
|
|
+
|
|
|
+ nickname: string;
|
|
|
+
|
|
|
+ sec_uid: string;
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ statistics: {
|
|
|
+
|
|
|
+ play_count: number;
|
|
|
+
|
|
|
+ digg_count: number;
|
|
|
+
|
|
|
+ comment_count: number;
|
|
|
+
|
|
|
+ share_count: number;
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ text_extra?: string[];
|
|
|
+
|
|
|
+ // 下载相关
|
|
|
+
|
|
|
+ downloadProgress?: number;
|
|
|
+
|
|
|
+ isDownloading?: boolean;
|
|
|
+
|
|
|
+ downloadUrl?: string;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface Task {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ keyword: string;
|
|
|
+
|
|
|
+ status: 'pending' | 'processing' | 'completed' | 'failed' | 'timeout';
|
|
|
+
|
|
|
+ step: 'search' | 'detail' | 'comments' | 'download' | 'transcribe' | 'generate' | 'complete'
|
|
|
+
|
|
|
+ | 'vg-upload' | 'vg-analyze' | 'vg-script' | 'vg-image' | 'vg-voice' | 'vg-composite';
|
|
|
+
|
|
|
+ progress: number;
|
|
|
+
|
|
|
+ cost: number;
|
|
|
+
|
|
|
+ created_at: Date;
|
|
|
+
|
|
|
+ updated_at: Date;
|
|
|
+
|
|
|
+ result_url?: string;
|
|
|
+
|
|
|
+ error_message?: string;
|
|
|
+
|
|
|
+ original_video_title?: string;
|
|
|
+
|
|
|
+ duration?: string;
|
|
|
+
|
|
|
+ // 任务类型:remix=AI重塑, video-generation=视频生成
|
|
|
+
|
|
|
+ type?: 'remix' | 'video-generation';
|
|
|
+
|
|
|
+ // 视频生成专用持久化数据
|
|
|
+
|
|
|
+ vgData?: {
|
|
|
+
|
|
|
+ videoId: string;
|
|
|
+
|
|
|
+ sourceFileName: string;
|
|
|
+
|
|
|
+ sourceVideoUrl: string;
|
|
|
+
|
|
|
+ step: number;
|
|
|
+
|
|
|
+ transcript: string;
|
|
|
+
|
|
|
+ analysisText: string;
|
|
|
+
|
|
|
+ script: string;
|
|
|
+
|
|
|
+ scriptSegments: { id: string; narration: string; imagePrompt: string; imagePromptCn?: string; }[];
|
|
|
+
|
|
|
+ imageResults: { id: string; prompt: string; imageUrl: string; status: 'pending' | 'generating' | 'done' | 'failed'; }[];
|
|
|
+
|
|
|
+ audioUrls: { segId: string; audioUrl: string; }[];
|
|
|
+
|
|
|
+ voiceMode: 'clone' | 'system';
|
|
|
+
|
|
|
+ voiceSpeakerId: string;
|
|
|
+
|
|
|
+ voiceTimbreId: string;
|
|
|
+
|
|
|
+ finalVideoUrl: string;
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface TaskStats {
|
|
|
+
|
|
|
+ pending: number;
|
|
|
+
|
|
|
+ processing: number;
|
|
|
+
|
|
|
+ completed: number;
|
|
|
+
|
|
|
+ failed: number;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface VoiceProfile {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ name: string;
|
|
|
+
|
|
|
+ creation_mode?: string;
|
|
|
+
|
|
|
+ speaker_id: string;
|
|
|
+
|
|
|
+ timbre_id?: string;
|
|
|
+
|
|
|
+ sample_text: string;
|
|
|
+
|
|
|
+ text_prompt?: string;
|
|
|
+
|
|
|
+ source_audio_text?: string;
|
|
|
+
|
|
|
+ source_audio_name?: string;
|
|
|
+
|
|
|
+ source_audio_format?: string;
|
|
|
+
|
|
|
+ language: number;
|
|
|
+
|
|
|
+ status: number | null;
|
|
|
+
|
|
|
+ status_label: string;
|
|
|
+
|
|
|
+ demo_audio: string;
|
|
|
+
|
|
|
+ available_training_times: number | null;
|
|
|
+
|
|
|
+ image_prompt_name?: string;
|
|
|
+
|
|
|
+ x_api_resource_id?: string;
|
|
|
+
|
|
|
+ model_version?: string;
|
|
|
+
|
|
|
+ icl_speaker_id?: string;
|
|
|
+
|
|
|
+ occupied?: boolean;
|
|
|
+
|
|
|
+ synthesized_audio_url?: string;
|
|
|
+
|
|
|
+ synthesized_work_id?: string;
|
|
|
+
|
|
|
+ last_synthesis_text?: string;
|
|
|
+
|
|
|
+ last_synthesis_ssml?: string;
|
|
|
+
|
|
|
+ last_synthesis_x_api_resource_id?: string;
|
|
|
+
|
|
|
+ last_synthesis_model?: string;
|
|
|
+
|
|
|
+ last_synthesis_format?: string;
|
|
|
+
|
|
|
+ last_synthesis_sample_rate?: number;
|
|
|
+
|
|
|
+ last_synthesis_speech_rate?: number;
|
|
|
+
|
|
|
+ last_synthesis_loudness_rate?: number;
|
|
|
+
|
|
|
+ last_synthesis_emotion?: string;
|
|
|
+
|
|
|
+ last_synthesis_emotion_scale?: number;
|
|
|
+
|
|
|
+ last_synthesis_enable_subtitle?: boolean;
|
|
|
+
|
|
|
+ last_synthesis_silence_duration?: number;
|
|
|
+
|
|
|
+ last_synthesis_enable_language_detector?: boolean;
|
|
|
+
|
|
|
+ last_synthesis_disable_markdown_filter?: boolean;
|
|
|
+
|
|
|
+ last_synthesis_disable_emoji_filter?: boolean;
|
|
|
+
|
|
|
+ last_synthesis_explicit_language?: string;
|
|
|
+
|
|
|
+ latest_audio_url?: string;
|
|
|
+
|
|
|
+ draft_synthesis_text?: string;
|
|
|
+
|
|
|
+ draft_synthesis_ssml?: string;
|
|
|
+
|
|
|
+ draft_synthesis_x_api_resource_id?: string;
|
|
|
+
|
|
|
+ draft_synthesis_model?: string;
|
|
|
+
|
|
|
+ draft_synthesis_format?: string;
|
|
|
+
|
|
|
+ draft_synthesis_sample_rate?: number;
|
|
|
+
|
|
|
+ draft_synthesis_speech_rate?: number;
|
|
|
+
|
|
|
+ draft_synthesis_loudness_rate?: number;
|
|
|
+
|
|
|
+ draft_synthesis_emotion?: string;
|
|
|
+
|
|
|
+ draft_synthesis_emotion_scale?: number;
|
|
|
+
|
|
|
+ draft_synthesis_enable_subtitle?: boolean;
|
|
|
+
|
|
|
+ draft_synthesis_silence_duration?: number;
|
|
|
+
|
|
|
+ draft_synthesis_enable_language_detector?: boolean;
|
|
|
+
|
|
|
+ draft_synthesis_disable_markdown_filter?: boolean;
|
|
|
+
|
|
|
+ draft_synthesis_disable_emoji_filter?: boolean;
|
|
|
+
|
|
|
+ draft_synthesis_explicit_language?: string;
|
|
|
+
|
|
|
+ is_synthesizing?: boolean;
|
|
|
+
|
|
|
+ message?: string;
|
|
|
+
|
|
|
+ request_id?: string;
|
|
|
+
|
|
|
+ created_at: Date;
|
|
|
+
|
|
|
+ updated_at?: Date;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface GeneratedResult {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ type: 'video' | 'image';
|
|
|
+
|
|
|
+ url: string;
|
|
|
+
|
|
|
+ title: string;
|
|
|
+
|
|
|
+ created_at: Date;
|
|
|
+
|
|
|
+ quality: string;
|
|
|
+
|
|
|
+ duration: string;
|
|
|
+
|
|
|
+ cost: number;
|
|
|
+
|
|
|
+ remixId?: string;
|
|
|
+
|
|
|
+ videoId?: string;
|
|
|
+
|
|
|
+ voiceProfileId?: string;
|
|
|
+
|
|
|
+ voiceProfileName?: string;
|
|
|
+
|
|
|
+ audioSourceLabel?: string;
|
|
|
+
|
|
|
+ segments?: { id: string; videoUrl: string; prompt: string; status: string }[];
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface HistoryRecord {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ original_video_title?: string;
|
|
|
+
|
|
|
+ keyword: string;
|
|
|
+
|
|
|
+ status: 'completed' | 'failed';
|
|
|
+
|
|
|
+ cost: number;
|
|
|
+
|
|
|
+ created_at: Date;
|
|
|
+
|
|
|
+ duration?: string;
|
|
|
+
|
|
|
+ remixId?: string;
|
|
|
+
|
|
|
+ videoId?: string;
|
|
|
+
|
|
|
+ styleName?: string;
|
|
|
+
|
|
|
+ segmentCount?: number;
|
|
|
+
|
|
|
+ successCount?: number;
|
|
|
+
|
|
|
+ resultUrl?: string;
|
|
|
+
|
|
|
+ quality?: string;
|
|
|
+
|
|
|
+ voiceProfileId?: string;
|
|
|
+
|
|
|
+ voiceProfileName?: string;
|
|
|
+
|
|
|
+ audioSourceLabel?: string;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface ManagedVideo {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ title: string;
|
|
|
+
|
|
|
+ filename: string;
|
|
|
+
|
|
|
+ filepath: string;
|
|
|
+
|
|
|
+ size: number;
|
|
|
+
|
|
|
+ duration: number;
|
|
|
+
|
|
|
+ created_at: Date;
|
|
|
+
|
|
|
+ modified_at: Date;
|
|
|
+
|
|
|
+ category: string;
|
|
|
+
|
|
|
+ tags: string[];
|
|
|
+
|
|
|
+ description: string;
|
|
|
+
|
|
|
+ thumbnail?: string;
|
|
|
+
|
|
|
+ source: 'downloaded' | 'generated' | 'uploaded';
|
|
|
+
|
|
|
+ metadata: {
|
|
|
+
|
|
|
+ resolution: string;
|
|
|
+
|
|
|
+ format: string;
|
|
|
+
|
|
|
+ bitrate?: number;
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ // Whisper 转录文件路径
|
|
|
+
|
|
|
+ whisper?: {
|
|
|
+
|
|
|
+ transcript?: string;
|
|
|
+
|
|
|
+ srt?: string;
|
|
|
+
|
|
|
+ storyboard?: string;
|
|
|
+
|
|
|
+ beautified?: string;
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ // AI重塑相关字段
|
|
|
+
|
|
|
+ isRemixing?: boolean;
|
|
|
+
|
|
|
+ remixProgress?: number;
|
|
|
+
|
|
|
+ remixStyle?: string;
|
|
|
+
|
|
|
+ originalVideoId?: string;
|
|
|
+
|
|
|
+ remixVersions?: RemixVersion[];
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface RemixVersion {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ style: string;
|
|
|
+
|
|
|
+ created_at: Date;
|
|
|
+
|
|
|
+ file_path: string;
|
|
|
+
|
|
|
+ thumbnail: string;
|
|
|
+
|
|
|
+ description: string;
|
|
|
+
|
|
|
+ parameters: RemixParameters;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface RemixParameters {
|
|
|
+
|
|
|
+ style: string;
|
|
|
+
|
|
|
+ intensity: number;
|
|
|
+
|
|
|
+ preserve_audio: boolean;
|
|
|
+
|
|
|
+ quality: 'standard' | 'high' | 'ultra';
|
|
|
+
|
|
|
+ effects: string[];
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface VideoCategory {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ name: string;
|
|
|
+
|
|
|
+ color: string;
|
|
|
+
|
|
|
+ count: number;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface StoryboardSegment {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ act: string;
|
|
|
+
|
|
|
+ narration: string;
|
|
|
+
|
|
|
+ prompt: string;
|
|
|
+
|
|
|
+ duration: string;
|
|
|
+
|
|
|
+ videoUrl?: string;
|
|
|
+
|
|
|
+ workId?: string;
|
|
|
+
|
|
|
+ status: 'pending' | 'generating' | 'completed' | 'failed';
|
|
|
+
|
|
|
+ error?: string;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface RemixFlowStep {
|
|
|
+
|
|
|
+ step: number;
|
|
|
+
|
|
|
+ label: string;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface MonitorWork {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ desc: string;
|
|
|
+
|
|
|
+ coverUrl: string;
|
|
|
+
|
|
|
+ coverCandidates: string[];
|
|
|
+
|
|
|
+ duration: number;
|
|
|
+
|
|
|
+ createTime?: Date;
|
|
|
+
|
|
|
+ type: 'video' | 'image';
|
|
|
+
|
|
|
+ statistics: {
|
|
|
+
|
|
|
+ play_count: number;
|
|
|
+
|
|
|
+ digg_count: number;
|
|
|
+
|
|
|
+ comment_count: number;
|
|
|
+
|
|
|
+ share_count: number;
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ isDownloading?: boolean;
|
|
|
+
|
|
|
+ downloadProgress?: number;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+interface MonitorAuthor {
|
|
|
+
|
|
|
+ id: string;
|
|
|
+
|
|
|
+ uid: string;
|
|
|
+
|
|
|
+ secUserId: string;
|
|
|
+
|
|
|
+ nickname: string;
|
|
|
+
|
|
|
+ uniqueId: string;
|
|
|
+
|
|
|
+ shortId: string;
|
|
|
+
|
|
|
+ signature: string;
|
|
|
+
|
|
|
+ ipLocation: string;
|
|
|
+
|
|
|
+ verifiedText: string;
|
|
|
+
|
|
|
+ avatarUrl: string;
|
|
|
+
|
|
|
+ avatarCandidates: string[];
|
|
|
+
|
|
|
+ coverUrl: string;
|
|
|
+
|
|
|
+ coverCandidates: string[];
|
|
|
+
|
|
|
+ followerCount: number;
|
|
|
+
|
|
|
+ followingCount: number;
|
|
|
+
|
|
|
+ totalFavorited: number;
|
|
|
+
|
|
|
+ awemeCount: number;
|
|
|
+
|
|
|
+ works: MonitorWork[];
|
|
|
+
|
|
|
+ worksCursor: string;
|
|
|
+
|
|
|
+ hasMoreWorks: boolean;
|
|
|
+
|
|
|
+ loadingWorks: boolean;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+@Component({
|
|
|
+
|
|
|
+ selector: 'app-root',
|
|
|
+
|
|
|
+ imports: [
|
|
|
+
|
|
|
+ CommonModule,
|
|
|
+
|
|
|
+ FormsModule,
|
|
|
+
|
|
|
+ RouterModule,
|
|
|
+
|
|
|
+ TimeFormatPipe,
|
|
|
+
|
|
|
+ NumberFormatPipe,
|
|
|
+
|
|
|
+ FileSizePipe
|
|
|
+
|
|
|
+ ],
|
|
|
+
|
|
|
+ templateUrl: './app.html',
|
|
|
+
|
|
|
+ styleUrl: './app.css'
|
|
|
+
|
|
|
+})
|
|
|
+
|
|
|
+export class App {
|
|
|
+
|
|
|
+ protected readonly title = signal('抖音AI视频生成系统');
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 当前激活的标签页
|
|
|
+
|
|
|
+ currentTab: 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'digital-human' | 'video-generation' = 'search';
|
|
|
+
|
|
|
+ private readonly TAB_STORAGE_KEY = 'tiktok.currentTab';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 主题模式(默认浅色)
|
|
|
+
|
|
|
+ themeMode: 'day' | 'night' = 'day';
|
|
|
+
|
|
|
+ private readonly THEME_STORAGE_KEY = 'tiktok.themeMode';
|
|
|
+
|
|
|
+ private readonly TIMBRE_LIST_STORAGE_KEY = 'tiktok.vsClonedTimbreList';
|
|
|
+
|
|
|
+ private readonly MONITOR_AUTHORS_STORAGE_KEY = 'tiktok.monitorAuthors';
|
|
|
+
|
|
|
+ private readonly MONITOR_SELECTED_AUTHOR_STORAGE_KEY = 'tiktok.monitorSelectedAuthorId';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 搜索相关属性
|
|
|
+
|
|
|
+ searchKeyword: string = '';
|
|
|
+
|
|
|
+ sortType: string = '0';
|
|
|
+
|
|
|
+ publishTime: string = '0';
|
|
|
+
|
|
|
+ filterDuration: string = '0';
|
|
|
+
|
|
|
+ isSearching: boolean = false;
|
|
|
+
|
|
|
+ searchResults: VideoInfo[] = [];
|
|
|
+
|
|
|
+ filteredSearchResults: VideoInfo[] = [];
|
|
|
+
|
|
|
+ hasMore: boolean = false;
|
|
|
+
|
|
|
+ monitorUniqueIdInput: string = '';
|
|
|
+
|
|
|
+ monitorAuthors: MonitorAuthor[] = [];
|
|
|
+
|
|
|
+ selectedMonitorAuthorId: string = '';
|
|
|
+
|
|
|
+ isAddingMonitorAuthor: boolean = false;
|
|
|
+
|
|
|
+ monitorAddError: string = '';
|
|
|
+
|
|
|
+ private readonly voiceApiToken: string = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
|
|
|
+
|
|
|
+ private readonly voiceTtsBaseUrl: string = 'https://server.fmode.cn/api/volcengine/tts';
|
|
|
+
|
|
|
+ private readonly localVoiceTtsBaseUrl: string = '/backend/api/volcengine/tts';
|
|
|
+
|
|
|
+ voiceProfiles: VoiceProfile[] = [];
|
|
|
+
|
|
|
+ isCreatingVoiceProfile: boolean = false;
|
|
|
+
|
|
|
+ voiceDesignError: string = '';
|
|
|
+
|
|
|
+ voiceCreateMode: 'clone' | 'design' | 'synthesize' = 'clone';
|
|
|
+
|
|
|
+ voiceSourceAudioFile: File | null = null;
|
|
|
+
|
|
|
+ voiceSourceAudioName: string = '';
|
|
|
+
|
|
|
+ voiceDesignImageFile: File | null = null;
|
|
|
+
|
|
|
+ voiceDesignImageName: string = '';
|
|
|
+
|
|
|
+ preferredVoiceProfileId: string = '';
|
|
|
+
|
|
|
+ readonly fallbackVoiceSpeakerIdOptions = ['S_1g3vlU702', 'S_OLZKhZWZ1', 'S_PLZKhZWZ1', 'S_QLZKhZWZ1', 'S_ULZKhZWZ1', 'S_TLZKhZWZ1', 'S_SLZKhZWZ1', 'S_RWDjSzXZ1'];
|
|
|
+
|
|
|
+ voiceSpeakerIdOptions: string[] = [...this.fallbackVoiceSpeakerIdOptions];
|
|
|
+
|
|
|
+ voiceForm = {
|
|
|
+
|
|
|
+ displayName: '',
|
|
|
+
|
|
|
+ speakerId: 'S_1g3vlU702',
|
|
|
+
|
|
|
+ textPrompt: '',
|
|
|
+
|
|
|
+ audioText: '',
|
|
|
+
|
|
|
+ sampleText: '',
|
|
|
+
|
|
|
+ synthesisText: '',
|
|
|
+
|
|
|
+ language: 0
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ voiceSynthesisForm = {
|
|
|
+
|
|
|
+ profileId: '',
|
|
|
+
|
|
|
+ speakerId: '',
|
|
|
+
|
|
|
+ text: '',
|
|
|
+
|
|
|
+ ssml: '',
|
|
|
+
|
|
|
+ xApiResourceId: '',
|
|
|
+
|
|
|
+ model: '',
|
|
|
+
|
|
|
+ format: 'mp3',
|
|
|
+
|
|
|
+ sampleRate: 24000,
|
|
|
+
|
|
|
+ speechRate: 0,
|
|
|
+
|
|
|
+ loudnessRate: 0,
|
|
|
+
|
|
|
+ emotion: '',
|
|
|
+
|
|
|
+ emotionScale: 4,
|
|
|
+
|
|
|
+ enableSubtitle: false,
|
|
|
+
|
|
|
+ silenceDuration: 0,
|
|
|
+
|
|
|
+ enableLanguageDetector: false,
|
|
|
+
|
|
|
+ disableMarkdownFilter: false,
|
|
|
+
|
|
|
+ disableEmojiFilter: false,
|
|
|
+
|
|
|
+ explicitLanguage: ''
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ isDirectVoiceSynthesizing: boolean = false;
|
|
|
+
|
|
|
+ directVoiceSynthesisAudioUrl: string = '';
|
|
|
+
|
|
|
+ directVoiceSynthesisWorkId: string = '';
|
|
|
+
|
|
|
+ directVoiceSynthesisSpeakerId: string = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 语音合成标签页状态
|
|
|
+
|
|
|
+ vsTtsMode: 'clone' | 'synthesize' = 'clone';
|
|
|
+
|
|
|
+ vsCloneForm = {
|
|
|
+
|
|
|
+ name: '',
|
|
|
+
|
|
|
+ speakerId: 'S_ULZKhZWZ1',
|
|
|
+
|
|
|
+ audioText: '',
|
|
|
+
|
|
|
+ language: 0,
|
|
|
+
|
|
|
+ demoText: '青山依旧在,几度夕阳红。'
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ vsCloneAudioFile: File | null = null;
|
|
|
+
|
|
|
+ vsCloneAudioName: string = '';
|
|
|
+
|
|
|
+ vsCloneAudioUrl: string = '';
|
|
|
+
|
|
|
+ vsCloneUploading: boolean = false;
|
|
|
+
|
|
|
+ vsCloneLoading: boolean = false;
|
|
|
+
|
|
|
+ vsCloneError: string = '';
|
|
|
+
|
|
|
+ vsCloneResult: any = null;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vsSynthForm = {
|
|
|
+
|
|
|
+ timbreId: '',
|
|
|
+
|
|
|
+ text: '白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢,古今多少事,都付笑谈中。',
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ format: 'mp3',
|
|
|
+
|
|
|
+ sampleRate: 24000,
|
|
|
+
|
|
|
+ speechRate: 0,
|
|
|
+
|
|
|
+ loudnessRate: 0
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ vsSynthLoading: boolean = false;
|
|
|
+
|
|
|
+ vsSynthError: string = '';
|
|
|
+
|
|
|
+ vsSynthAudioUrl: string = '';
|
|
|
+
|
|
|
+ vsSynthWorkId: string = '';
|
|
|
+
|
|
|
+ vsClonedTimbreList: { objectId: string; name: string; speakerId: string }[] = [];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 数字人合成独立页面状态
|
|
|
+
|
|
|
+ dhStep: 'form' | 'generating' | 'done' = 'form';
|
|
|
+
|
|
|
+ dhImageFile: File | null = null;
|
|
|
+
|
|
|
+ dhImageName: string = '';
|
|
|
+
|
|
|
+ dhImagePreviewUrl: string = '';
|
|
|
+
|
|
|
+ dhImageAssetUrl: string = '';
|
|
|
+
|
|
|
+ dhAudioSource: 'tts' | 'upload' = 'tts';
|
|
|
+
|
|
|
+ dhTtsTimbreId: string = '';
|
|
|
+
|
|
|
+ dhTtsText: string = '';
|
|
|
+
|
|
|
+ dhUploadAudioFile: File | null = null;
|
|
|
+
|
|
|
+ dhUploadAudioName: string = '';
|
|
|
+
|
|
|
+ dhUploadAudioUrl: string = '';
|
|
|
+
|
|
|
+ dhUploadingAudio: boolean = false;
|
|
|
+
|
|
|
+ dhPrompt: string = '';
|
|
|
+
|
|
|
+ // 数字人补充提示词预设
|
|
|
+ dhPromptPresets: { label: string; value: string }[] = [
|
|
|
+ { label: '专业播报', value: '以新闻主播的语气朗读,吐字清晰、节奏稳定、表情自信端正。' },
|
|
|
+ { label: '亲切自然', value: '用朋友聊天的语气,亲切自然、面带微笑,语速适中略带轻松感。' },
|
|
|
+ { label: '科技感', value: '冷静理性的科技感口吻,节奏明快、抑扬有度,强调数据与关键词。' },
|
|
|
+ { label: '热情带货', value: '充满活力的带货主播风格,语速略快、情绪饱满,重点处加重音强调。' },
|
|
|
+ { label: '温柔治愈', value: '温柔轻缓的语气,气息柔和、节奏放慢,适合情感与生活类内容。' },
|
|
|
+ { label: '正式商务', value: '商务正式风格,语调克制、用词严谨,仪态端庄,适合企业宣传场景。' },
|
|
|
+ ];
|
|
|
+
|
|
|
+ dhFastMode: boolean = false;
|
|
|
+
|
|
|
+ dhResolution: '720p' | '1080p' = '1080p';
|
|
|
+
|
|
|
+ dhError: string = '';
|
|
|
+
|
|
|
+ dhLoading: boolean = false;
|
|
|
+
|
|
|
+ dhStatusText: string = '';
|
|
|
+
|
|
|
+ dhProgress: number = 0;
|
|
|
+
|
|
|
+ dhResultVideoUrl: string = '';
|
|
|
+
|
|
|
+ dhSynthesizing: boolean = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 数字人合成页:AI 对话助手(用于让用户与 AI 对话调整口播稿风格 / 优化提示词)
|
|
|
+
|
|
|
+ dhChatMessages: Array<{
|
|
|
+
|
|
|
+ role: 'user' | 'assistant';
|
|
|
+
|
|
|
+ content: string; // 给用户看的文本(assistant 端为 explanation;user 端为输入原文)
|
|
|
+
|
|
|
+ suggestedTts?: string; // assistant 建议的新口播文本
|
|
|
+
|
|
|
+ suggestedPrompt?: string; // assistant 建议的新补充提示词
|
|
|
+
|
|
|
+ appliedTts?: boolean;
|
|
|
+
|
|
|
+ appliedPrompt?: boolean;
|
|
|
+
|
|
|
+ }> = [];
|
|
|
+
|
|
|
+ dhChatInput: string = '';
|
|
|
+
|
|
|
+ dhChatLoading: boolean = false;
|
|
|
+
|
|
|
+ dhChatError: string = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 任务管理相关属性
|
|
|
+
|
|
|
+ activeTasks: Task[] = [];
|
|
|
+
|
|
|
+ taskStats: TaskStats = {
|
|
|
+
|
|
|
+ pending: 0,
|
|
|
+
|
|
|
+ processing: 0,
|
|
|
+
|
|
|
+ completed: 0,
|
|
|
+
|
|
|
+ failed: 0
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 历史记录相关属性
|
|
|
+
|
|
|
+ historyRecords: HistoryRecord[] = [];
|
|
|
+
|
|
|
+ historyFilter = {
|
|
|
+
|
|
|
+ status: ''
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 生成结果相关属性
|
|
|
+
|
|
|
+ generatedResults: GeneratedResult[] = [];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频管理相关属性
|
|
|
+
|
|
|
+ managedVideos: ManagedVideo[] = [];
|
|
|
+
|
|
|
+ videoCategories: VideoCategory[] = [];
|
|
|
+
|
|
|
+ selectedVideos: Set<string> = new Set();
|
|
|
+
|
|
|
+ videoFilter = {
|
|
|
+
|
|
|
+ category: '',
|
|
|
+
|
|
|
+ source: '',
|
|
|
+
|
|
|
+ searchTerm: ''
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ isSelectionMode: boolean = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频上传
|
|
|
+
|
|
|
+ showUploadModal: boolean = false;
|
|
|
+
|
|
|
+ uploadTitle: string = '';
|
|
|
+
|
|
|
+ uploadDescription: string = '';
|
|
|
+
|
|
|
+ uploadTags: string = '';
|
|
|
+
|
|
|
+ isUploading: boolean = false;
|
|
|
+
|
|
|
+ uploadProgress: number = 0;
|
|
|
+
|
|
|
+ uploadError: string = '';
|
|
|
+
|
|
|
+ uploadFile: File | null = null;
|
|
|
+
|
|
|
+ uploadFileName: string = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频预览模态框
|
|
|
+
|
|
|
+ showPreviewModal: boolean = false;
|
|
|
+
|
|
|
+ previewVideoUrl: string = '';
|
|
|
+
|
|
|
+ previewDownloadUrl: string = '';
|
|
|
+
|
|
|
+ previewVideoTitle: string = '';
|
|
|
+
|
|
|
+ previewVideoInfo: any = null;
|
|
|
+
|
|
|
+ previewSourceVideo: VideoInfo | null = null;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 搜索游标(分页)
|
|
|
+
|
|
|
+ searchCursor: number = 0;
|
|
|
+
|
|
|
+ private searchId: string = '';
|
|
|
+
|
|
|
+ private searchBacktrace: string = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频文件基础路径
|
|
|
+
|
|
|
+ private readonly VIDEO_BASE_PATH = '/video';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // AI重塑模式选择弹窗(视频管理页 → 选择 标准AI重塑 / 数字人生成)
|
|
|
+
|
|
|
+ showRemixModeChooser: boolean = false;
|
|
|
+
|
|
|
+ remixModeChooserVideo: ManagedVideo | null = null;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ===== 数字人合成页:参考视频(与视频生成页 vg* 完全隔离的独立状态) =====
|
|
|
+
|
|
|
+ dhRefFile: File | null = null;
|
|
|
+
|
|
|
+ dhRefFileName: string = '';
|
|
|
+
|
|
|
+ dhRefFileSize: number = 0;
|
|
|
+
|
|
|
+ dhRefVideoUrl: string = '';
|
|
|
+
|
|
|
+ dhRefVideoId: string = '';
|
|
|
+
|
|
|
+ dhRefUploading: boolean = false;
|
|
|
+
|
|
|
+ dhRefUploadProgress: number = 0;
|
|
|
+
|
|
|
+ dhRefAnalyzing: boolean = false;
|
|
|
+
|
|
|
+ dhRefAnalysisText: string = '';
|
|
|
+
|
|
|
+ dhRefTranscript: string = '';
|
|
|
+
|
|
|
+ dhRefError: string = '';
|
|
|
+
|
|
|
+ dhRefRewriting: boolean = false;
|
|
|
+
|
|
|
+ dhRefRewriteError: string = '';
|
|
|
+
|
|
|
+ // 当通过「AI 重塑 → 数字人生成」入口进入时,标记需要在分析完成后自动重塑
|
|
|
+
|
|
|
+ dhRefAutoRewrite: boolean = false;
|
|
|
+
|
|
|
+
|
|
|
+ // AI重塑模态框 — 多步骤流程
|
|
|
+
|
|
|
+ showRemixModal: boolean = false;
|
|
|
+
|
|
|
+ remixTargetVideo: ManagedVideo | null = null;
|
|
|
+
|
|
|
+ remixStep: number = 1; // 1提取文字稿 2AI美化 3分镜脚本 4生成参数 5生成中 6结果
|
|
|
+
|
|
|
+ remixTranscript: string = '';
|
|
|
+
|
|
|
+ remixBeautifiedTranscript: string = '';
|
|
|
+
|
|
|
+ remixStoryboard: StoryboardSegment[] = [];
|
|
|
+
|
|
|
+ remixStyle: string = 'anime';
|
|
|
+
|
|
|
+ remixQuality: '720p' | '1080p' | 'pro' = '1080p';
|
|
|
+
|
|
|
+ remixFrames: number = 121;
|
|
|
+
|
|
|
+ remixAspectRatio: string = '16:9';
|
|
|
+
|
|
|
+ remixStatusText: string = '';
|
|
|
+
|
|
|
+ isRemixSubmitting: boolean = false;
|
|
|
+
|
|
|
+ remixResults: { total: number; success: number; failed: number } = { total: 0, success: 0, failed: 0 };
|
|
|
+
|
|
|
+ remixStitchStatus: 'idle' | 'stitching' | 'polling' | 'done' | 'failed' = 'idle';
|
|
|
+
|
|
|
+ remixStitchUrl: string = '';
|
|
|
+
|
|
|
+ remixStitchError: string = '';
|
|
|
+
|
|
|
+ remixFinalVideoUrl: string = '';
|
|
|
+
|
|
|
+ remixMode: 'standard' | 'digital-human' = 'standard';
|
|
|
+
|
|
|
+ remixDigitalHumanImageFile: File | null = null;
|
|
|
+
|
|
|
+ remixDigitalHumanImageName: string = '';
|
|
|
+
|
|
|
+ remixDigitalHumanImagePreviewUrl: string = '';
|
|
|
+
|
|
|
+ remixDigitalHumanImageAssetUrl: string = '';
|
|
|
+
|
|
|
+ remixDigitalHumanPrompt: string = '';
|
|
|
+
|
|
|
+ remixDigitalHumanFastMode: boolean = false;
|
|
|
+
|
|
|
+ remixDigitalHumanResolution: '720p' | '1080p' = '1080p';
|
|
|
+
|
|
|
+ remixDigitalHumanError: string = '';
|
|
|
+
|
|
|
+ remixDigitalHumanAudioSource: 'original' | 'tts' = 'tts';
|
|
|
+
|
|
|
+ remixDigitalHumanTtsText: string = '';
|
|
|
+
|
|
|
+ remixDigitalHumanTtsTimbreId: string = '';
|
|
|
+
|
|
|
+ remixDigitalHumanTtsSynthesizing: boolean = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ===== 视频生成工作流 =====
|
|
|
+
|
|
|
+ vgStep: number = 1; // 1上传视频 2视频解析 3脚本生成 4配图生成 5配音 6数字人生成
|
|
|
+
|
|
|
+ readonly vgStepLabels = ['上传视频', '视频解析', '脚本生成', '配图生成', '语音配音', '视频合成'];
|
|
|
+
|
|
|
+ readonly vgStepSubtitles = [
|
|
|
+
|
|
|
+ '选择需要拆解分析的视频源文件',
|
|
|
+
|
|
|
+ 'AI 自动转录与内容理解',
|
|
|
+
|
|
|
+ 'AI 生成分镜旁白与画面提示词',
|
|
|
+
|
|
|
+ '为每个分镜生成 AI 配图',
|
|
|
+
|
|
|
+ '逐段合成旁白音频',
|
|
|
+
|
|
|
+ '汇总素材并合成最终视频'
|
|
|
+
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 用户手动展开的「已完成」步骤集合(active 步骤始终展开,locked 步骤始终折叠)
|
|
|
+
|
|
|
+ vgUserExpandedSteps: Set<number> = new Set<number>();
|
|
|
+
|
|
|
+ vgSourceFile: File | null = null;
|
|
|
+
|
|
|
+ vgSourceFileName: string = '';
|
|
|
+
|
|
|
+ vgSourceVideoUrl: string = '';
|
|
|
+
|
|
|
+ vgUploading: boolean = false;
|
|
|
+
|
|
|
+ vgUploadProgress: number = 0;
|
|
|
+
|
|
|
+ vgVideoId: string = '';
|
|
|
+
|
|
|
+ // Step 2: 视频解析
|
|
|
+
|
|
|
+ vgAnalyzing: boolean = false;
|
|
|
+
|
|
|
+ vgAnalysisText: string = '';
|
|
|
+
|
|
|
+ vgTranscript: string = '';
|
|
|
+
|
|
|
+ // Step 3: 脚本生成
|
|
|
+
|
|
|
+ vgGeneratingScript: boolean = false;
|
|
|
+
|
|
|
+ vgScript: string = '';
|
|
|
+
|
|
|
+ vgScriptSegments: { id: string; narration: string; imagePrompt: string; imagePromptCn?: string; }[] = [];
|
|
|
+
|
|
|
+ // Step 4: 配图生成
|
|
|
+
|
|
|
+ vgGeneratingImages: boolean = false;
|
|
|
+
|
|
|
+ vgImageResults: { id: string; prompt: string; imageUrl: string; status: 'pending' | 'generating' | 'done' | 'failed'; }[] = [];
|
|
|
+
|
|
|
+ vgImageProgress: number = 0;
|
|
|
+
|
|
|
+ // Step 5: 语音配音
|
|
|
+
|
|
|
+ vgVoiceMode: 'clone' | 'system' = 'system';
|
|
|
+
|
|
|
+ vgVoiceTimbreId: string = '';
|
|
|
+
|
|
|
+ vgVoiceSpeakerId: string = 'zh_female_shuangkuai-am_16k';
|
|
|
+
|
|
|
+ vgSynthesizing: boolean = false;
|
|
|
+
|
|
|
+ vgAudioUrls: { segId: string; audioUrl: string; }[] = [];
|
|
|
+
|
|
|
+ // Step 6: 视频合成
|
|
|
+
|
|
|
+ vgCompositing: boolean = false;
|
|
|
+
|
|
|
+ vgCompositingProgress: number = 0;
|
|
|
+
|
|
|
+ vgFinalVideoUrl: string = '';
|
|
|
+
|
|
|
+ vgError: string = '';
|
|
|
+
|
|
|
+ vgTaskId: string = ''; // 关联任务管理中的任务ID
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Whisper 文字稿生成
|
|
|
+
|
|
|
+ isWhisperRunning: boolean = false;
|
|
|
+
|
|
|
+ whisperStatusText: string = '';
|
|
|
+
|
|
|
+ showManualTranscriptInput: boolean = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 当前 AI 重塑关联的任务
|
|
|
+
|
|
|
+ currentRemixTaskId: string = '';
|
|
|
+
|
|
|
+ currentRemixId: string = ''; // 当前重塑会话ID
|
|
|
+
|
|
|
+ private taskSaveReady: Promise<void> = Promise.resolve();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 历史详情模态框
|
|
|
+
|
|
|
+ showHistoryDetailModal: boolean = false;
|
|
|
+
|
|
|
+ historyDetailRecord: HistoryRecord | null = null;
|
|
|
+
|
|
|
+ historyDetailSegments: { id: string; videoUrl: string; prompt: string; status: string; narration?: string }[] = [];
|
|
|
+
|
|
|
+ historyDetailPrimaryVideoUrl: string = '';
|
|
|
+
|
|
|
+ historyDetailLoading: boolean = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Toast 通知
|
|
|
+
|
|
|
+ toastMessage: string = '';
|
|
|
+
|
|
|
+ toastType: 'success' | 'info' | 'warn' | 'error' = 'info';
|
|
|
+
|
|
|
+ toastVisible: boolean = false;
|
|
|
+
|
|
|
+ private toastTimer: any = null;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ readonly remixStepLabels = ['提取文字稿', 'AI美化', '分镜脚本', '生成参数', '视频生成', '结果汇总'];
|
|
|
+
|
|
|
+ readonly digitalHumanStepLabels = ['模式选择', '生成参数', '视频生成', '结果汇总'];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 重塑风格选项
|
|
|
+
|
|
|
+ readonly remixStyleOptions = [
|
|
|
+
|
|
|
+ { id: 'anime', name: '动漫风格', prompt: 'anime style, Japanese animation, vibrant colors, cel shading' },
|
|
|
+
|
|
|
+ { id: 'oil-painting', name: '油画风格', prompt: 'oil painting style, thick brushstrokes, rich textures, classical art' },
|
|
|
+
|
|
|
+ { id: 'watercolor', name: '水彩风格', prompt: 'watercolor painting style, soft colors, fluid brushwork, artistic' },
|
|
|
+
|
|
|
+ { id: 'cyberpunk', name: '赛博朋克', prompt: 'cyberpunk style, neon lights, futuristic city, high tech low life, dark atmosphere' },
|
|
|
+
|
|
|
+ { id: 'cinematic', name: '电影质感', prompt: 'cinematic style, dramatic lighting, shallow depth of field, movie grade color grading' },
|
|
|
+
|
|
|
+ { id: '3d-render', name: '3D渲染', prompt: '3D rendered style, realistic materials, ray tracing, studio lighting' },
|
|
|
+
|
|
|
+ { id: 'ink-wash', name: '水墨风格', prompt: 'Chinese ink wash painting style, minimalist, elegant brushwork, traditional art' },
|
|
|
+
|
|
|
+ { id: 'vintage', name: '复古风格', prompt: 'vintage retro style, film grain, warm tones, nostalgic atmosphere, 1970s aesthetic' }
|
|
|
+
|
|
|
+ ];
|
|
|
+
|
|
|
+ constructor(private douyinService: DouyinService, private jimengService: JimengService, private llmService: LlmService, private http: HttpClient, private ngZone: NgZone, private cdr: ChangeDetectorRef) {
|
|
|
+
|
|
|
+ this.initializeDefaults();
|
|
|
+
|
|
|
+ this.restoreTheme();
|
|
|
+
|
|
|
+ this.restoreCurrentTab();
|
|
|
+
|
|
|
+ this.restoreClonedTimbreList();
|
|
|
+
|
|
|
+ this.restorePersistedMonitorState();
|
|
|
+
|
|
|
+ this.refreshIncompleteMonitorAuthors();
|
|
|
+
|
|
|
+ this.loadVideoManifest();
|
|
|
+
|
|
|
+ this.loadPersistedData();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 初始化默认值(非持久化数据)
|
|
|
+
|
|
|
+ private initializeDefaults(): void {
|
|
|
+
|
|
|
+ this.searchResults = [];
|
|
|
+
|
|
|
+ this.filteredSearchResults = [];
|
|
|
+
|
|
|
+ this.monitorUniqueIdInput = '';
|
|
|
+
|
|
|
+ this.monitorAuthors = [];
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = '';
|
|
|
+
|
|
|
+ this.monitorAddError = '';
|
|
|
+
|
|
|
+ this.voiceProfiles = [];
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ this.voiceCreateMode = 'clone';
|
|
|
+
|
|
|
+ this.voiceSourceAudioFile = null;
|
|
|
+
|
|
|
+ this.voiceSourceAudioName = '';
|
|
|
+
|
|
|
+ this.voiceDesignImageFile = null;
|
|
|
+
|
|
|
+ this.voiceDesignImageName = '';
|
|
|
+
|
|
|
+ this.voiceSpeakerIdOptions = [...this.fallbackVoiceSpeakerIdOptions];
|
|
|
+
|
|
|
+ this.voiceForm = this.buildDefaultVoiceForm();
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildDefaultVoiceSynthesisForm();
|
|
|
+
|
|
|
+ this.taskStats = { pending: 0, processing: 0, completed: 0, failed: 0 };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频分类
|
|
|
+
|
|
|
+ this.videoCategories = [
|
|
|
+
|
|
|
+ { id: 'downloaded', name: '已下载', color: '#2196F3', count: 0 },
|
|
|
+
|
|
|
+ { id: 'generated', name: 'AI生成', color: '#4CAF50', count: 0 },
|
|
|
+
|
|
|
+ { id: 'uploaded', name: '已上传', color: '#FF9800', count: 0 }
|
|
|
+
|
|
|
+ ];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.managedVideos = [];
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildDefaultVoiceForm(): typeof this.voiceForm {
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ displayName: '',
|
|
|
+
|
|
|
+ speakerId: this.voiceSpeakerIdOptions[0] || this.fallbackVoiceSpeakerIdOptions[0] || '',
|
|
|
+
|
|
|
+ textPrompt: '',
|
|
|
+
|
|
|
+ audioText: '',
|
|
|
+
|
|
|
+ sampleText: '',
|
|
|
+
|
|
|
+ synthesisText: '',
|
|
|
+
|
|
|
+ language: 0
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildDefaultVoiceSynthesisForm(profileId = ''): typeof this.voiceSynthesisForm {
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ profileId,
|
|
|
+
|
|
|
+ speakerId: '',
|
|
|
+
|
|
|
+ text: '',
|
|
|
+
|
|
|
+ ssml: '',
|
|
|
+
|
|
|
+ xApiResourceId: '',
|
|
|
+
|
|
|
+ model: '',
|
|
|
+
|
|
|
+ format: 'mp3',
|
|
|
+
|
|
|
+ sampleRate: 24000,
|
|
|
+
|
|
|
+ speechRate: 0,
|
|
|
+
|
|
|
+ loudnessRate: 0,
|
|
|
+
|
|
|
+ emotion: '',
|
|
|
+
|
|
|
+ emotionScale: 4,
|
|
|
+
|
|
|
+ enableSubtitle: false,
|
|
|
+
|
|
|
+ silenceDuration: 0,
|
|
|
+
|
|
|
+ enableLanguageDetector: false,
|
|
|
+
|
|
|
+ disableMarkdownFilter: false,
|
|
|
+
|
|
|
+ disableEmojiFilter: false,
|
|
|
+
|
|
|
+ explicitLanguage: ''
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildVoiceSynthesisFormFromProfile(profile: VoiceProfile | null | undefined): typeof this.voiceSynthesisForm {
|
|
|
+
|
|
|
+ if (!profile) {
|
|
|
+
|
|
|
+ return this.buildDefaultVoiceSynthesisForm();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ profileId: profile.id,
|
|
|
+
|
|
|
+ speakerId: String(profile.icl_speaker_id || profile.speaker_id || ''),
|
|
|
+
|
|
|
+ text: String(profile.draft_synthesis_text || profile.last_synthesis_text || ''),
|
|
|
+
|
|
|
+ ssml: String(profile.draft_synthesis_ssml || ''),
|
|
|
+
|
|
|
+ xApiResourceId: String(profile.draft_synthesis_x_api_resource_id || profile.x_api_resource_id || ''),
|
|
|
+
|
|
|
+ model: String(profile.draft_synthesis_model || ''),
|
|
|
+
|
|
|
+ format: this.normalizeVoiceSynthesisFormat(profile.draft_synthesis_format),
|
|
|
+
|
|
|
+ sampleRate: this.normalizeVoiceSynthesisSampleRate(profile.draft_synthesis_sample_rate),
|
|
|
+
|
|
|
+ speechRate: this.clampVoiceSynthesisRate(profile.draft_synthesis_speech_rate, 0),
|
|
|
+
|
|
|
+ loudnessRate: this.clampVoiceSynthesisRate(profile.draft_synthesis_loudness_rate, 0),
|
|
|
+
|
|
|
+ emotion: String(profile.draft_synthesis_emotion || ''),
|
|
|
+
|
|
|
+ emotionScale: this.clampVoiceSynthesisEmotionScale(profile.draft_synthesis_emotion_scale, 4),
|
|
|
+
|
|
|
+ enableSubtitle: !!profile.draft_synthesis_enable_subtitle,
|
|
|
+
|
|
|
+ silenceDuration: this.clampVoiceSynthesisSilenceDuration(profile.draft_synthesis_silence_duration, 0),
|
|
|
+
|
|
|
+ enableLanguageDetector: !!profile.draft_synthesis_enable_language_detector,
|
|
|
+
|
|
|
+ disableMarkdownFilter: !!profile.draft_synthesis_disable_markdown_filter,
|
|
|
+
|
|
|
+ disableEmojiFilter: !!profile.draft_synthesis_disable_emoji_filter,
|
|
|
+
|
|
|
+ explicitLanguage: String(profile.draft_synthesis_explicit_language || '')
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private syncVoiceSynthesisFormSelection(): void {
|
|
|
+
|
|
|
+ const availableProfiles = this.getSynthesisVoiceProfiles();
|
|
|
+
|
|
|
+ const selectedProfile = availableProfiles.find((profile) => profile.id === this.voiceSynthesisForm.profileId) || null;
|
|
|
+
|
|
|
+ if (selectedProfile) {
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildVoiceSynthesisFormFromProfile(selectedProfile);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (String(this.voiceSynthesisForm.speakerId || '').trim()) {
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = {
|
|
|
+
|
|
|
+ ...this.voiceSynthesisForm,
|
|
|
+
|
|
|
+ profileId: ''
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildVoiceSynthesisFormFromProfile(availableProfiles[0] || null);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private normalizeVoiceSynthesisFormat(value: any): string {
|
|
|
+
|
|
|
+ const format = String(value || '').trim().toLowerCase();
|
|
|
+
|
|
|
+ return ['mp3', 'ogg_opus', 'pcm'].includes(format) ? format : 'mp3';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private normalizeVoiceSynthesisSampleRate(value: any): number {
|
|
|
+
|
|
|
+ const sampleRate = Number(value);
|
|
|
+
|
|
|
+ return [8000, 16000, 22050, 24000, 32000, 44100, 48000].includes(sampleRate) ? sampleRate : 24000;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private clampVoiceSynthesisRate(value: any, fallback = 0): number {
|
|
|
+
|
|
|
+ const numericValue = Number(value);
|
|
|
+
|
|
|
+ if (!Number.isFinite(numericValue)) {
|
|
|
+
|
|
|
+ return fallback;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return Math.min(100, Math.max(-50, numericValue));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private clampVoiceSynthesisEmotionScale(value: any, fallback = 4): number {
|
|
|
+
|
|
|
+ const numericValue = Number(value);
|
|
|
+
|
|
|
+ if (!Number.isFinite(numericValue)) {
|
|
|
+
|
|
|
+ return fallback;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return Math.min(5, Math.max(1, Math.round(numericValue)));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private clampVoiceSynthesisSilenceDuration(value: any, fallback = 0): number {
|
|
|
+
|
|
|
+ const numericValue = Number(value);
|
|
|
+
|
|
|
+ if (!Number.isFinite(numericValue)) {
|
|
|
+
|
|
|
+ return fallback;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return Math.min(30000, Math.max(0, Math.round(numericValue)));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private inferVoiceSourceAudioFormat(file: File): string {
|
|
|
+
|
|
|
+ const mimeType = String(file?.type || '').toLowerCase();
|
|
|
+
|
|
|
+ const fileName = String(file?.name || '').toLowerCase();
|
|
|
+
|
|
|
+ if (mimeType.includes('wav') || fileName.endsWith('.wav')) {
|
|
|
+
|
|
|
+ return 'wav';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mimeType.includes('m4a') || mimeType.includes('mp4') || fileName.endsWith('.m4a')) {
|
|
|
+
|
|
|
+ return 'm4a';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mimeType.includes('aac') || fileName.endsWith('.aac')) {
|
|
|
+
|
|
|
+ return 'aac';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mimeType.includes('flac') || fileName.endsWith('.flac')) {
|
|
|
+
|
|
|
+ return 'flac';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mimeType.includes('opus') || fileName.endsWith('.opus')) {
|
|
|
+
|
|
|
+ return 'opus';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mimeType.includes('ogg') || fileName.endsWith('.ogg')) {
|
|
|
+
|
|
|
+ return 'ogg';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mimeType.includes('pcm') || fileName.endsWith('.pcm')) {
|
|
|
+
|
|
|
+ return 'pcm';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return 'mp3';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private readFileAsBase64(file: File): Promise<string> {
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+
|
|
|
+ const reader = new FileReader();
|
|
|
+
|
|
|
+ reader.onload = () => {
|
|
|
+
|
|
|
+ const result = String(reader.result || '');
|
|
|
+
|
|
|
+ const base64 = result.includes(',') ? result.split(',').pop() || '' : result;
|
|
|
+
|
|
|
+ if (!base64) {
|
|
|
+
|
|
|
+ reject(new Error('音频文件读取失败'));
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ resolve(base64);
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ reader.onerror = () => reject(reader.error || new Error('音频文件读取失败'));
|
|
|
+
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildVoiceSynthesisDraft(profile: any): Partial<VoiceProfile> {
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ draft_synthesis_text: String(profile?.last_synthesis_text || ''),
|
|
|
+
|
|
|
+ draft_synthesis_ssml: String(profile?.last_synthesis_ssml || ''),
|
|
|
+
|
|
|
+ draft_synthesis_x_api_resource_id: String(profile?.last_synthesis_x_api_resource_id || profile?.x_api_resource_id || ''),
|
|
|
+
|
|
|
+ draft_synthesis_model: String(profile?.last_synthesis_model || ''),
|
|
|
+
|
|
|
+ draft_synthesis_format: this.normalizeVoiceSynthesisFormat(profile?.last_synthesis_format),
|
|
|
+
|
|
|
+ draft_synthesis_sample_rate: this.normalizeVoiceSynthesisSampleRate(profile?.last_synthesis_sample_rate),
|
|
|
+
|
|
|
+ draft_synthesis_speech_rate: this.clampVoiceSynthesisRate(profile?.last_synthesis_speech_rate, 0),
|
|
|
+
|
|
|
+ draft_synthesis_loudness_rate: this.clampVoiceSynthesisRate(profile?.last_synthesis_loudness_rate, 0),
|
|
|
+
|
|
|
+ draft_synthesis_emotion: String(profile?.last_synthesis_emotion || ''),
|
|
|
+
|
|
|
+ draft_synthesis_emotion_scale: this.clampVoiceSynthesisEmotionScale(profile?.last_synthesis_emotion_scale, 4),
|
|
|
+
|
|
|
+ draft_synthesis_enable_subtitle: !!profile?.last_synthesis_enable_subtitle,
|
|
|
+
|
|
|
+ draft_synthesis_silence_duration: this.clampVoiceSynthesisSilenceDuration(profile?.last_synthesis_silence_duration, 0),
|
|
|
+
|
|
|
+ draft_synthesis_enable_language_detector: !!profile?.last_synthesis_enable_language_detector,
|
|
|
+
|
|
|
+ draft_synthesis_disable_markdown_filter: !!profile?.last_synthesis_disable_markdown_filter,
|
|
|
+
|
|
|
+ draft_synthesis_disable_emoji_filter: !!profile?.last_synthesis_disable_emoji_filter,
|
|
|
+
|
|
|
+ draft_synthesis_explicit_language: String(profile?.last_synthesis_explicit_language || '')
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private restoreCurrentTab(): void {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const savedTab = localStorage.getItem(this.TAB_STORAGE_KEY);
|
|
|
+
|
|
|
+ if (savedTab === 'search' || savedTab === 'monitor' || savedTab === 'tasks' || savedTab === 'history' || savedTab === 'results' || savedTab === 'videos' || savedTab === 'voice-synthesis' || savedTab === 'digital-human' || savedTab === 'video-generation') {
|
|
|
+
|
|
|
+ this.currentTab = savedTab;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private restoreTheme(): void {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const saved = localStorage.getItem(this.THEME_STORAGE_KEY);
|
|
|
+
|
|
|
+ if (saved === 'day' || saved === 'night') {
|
|
|
+
|
|
|
+ this.themeMode = saved;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ toggleTheme(): void {
|
|
|
+
|
|
|
+ this.themeMode = this.themeMode === 'day' ? 'night' : 'day';
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ localStorage.setItem(this.THEME_STORAGE_KEY, this.themeMode);
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private restoreClonedTimbreList(): void {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const saved = localStorage.getItem(this.TIMBRE_LIST_STORAGE_KEY);
|
|
|
+
|
|
|
+ if (saved) {
|
|
|
+
|
|
|
+ const parsed = JSON.parse(saved);
|
|
|
+
|
|
|
+ if (Array.isArray(parsed)) {
|
|
|
+
|
|
|
+ this.vsClonedTimbreList = parsed.filter(
|
|
|
+
|
|
|
+ (t: any) => t && typeof t.objectId === 'string' && t.objectId
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private saveClonedTimbreList(): void {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ localStorage.setItem(this.TIMBRE_LIST_STORAGE_KEY, JSON.stringify(this.vsClonedTimbreList));
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ setCurrentTab(tab: 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'digital-human' | 'video-generation'): void {
|
|
|
+
|
|
|
+ this.currentTab = tab;
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ localStorage.setItem(this.TAB_STORAGE_KEY, tab);
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private restorePersistedMonitorState(): void {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const savedAuthors = localStorage.getItem(this.MONITOR_AUTHORS_STORAGE_KEY);
|
|
|
+
|
|
|
+ const savedSelectedAuthorStorageKey = localStorage.getItem(this.MONITOR_SELECTED_AUTHOR_STORAGE_KEY) || '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (savedAuthors) {
|
|
|
+
|
|
|
+ const parsedAuthors = JSON.parse(savedAuthors);
|
|
|
+
|
|
|
+ if (Array.isArray(parsedAuthors)) {
|
|
|
+
|
|
|
+ this.monitorAuthors = parsedAuthors.map((author: any) => this.deserializeMonitorAuthor(author));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = this.monitorAuthors.some(author => author.id === savedSelectedAuthorStorageKey)
|
|
|
+
|
|
|
+ ? savedSelectedAuthorStorageKey
|
|
|
+
|
|
|
+ : '';
|
|
|
+
|
|
|
+ } catch {
|
|
|
+
|
|
|
+ this.monitorAuthors = [];
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private persistMonitorState(): void {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ localStorage.setItem(this.MONITOR_AUTHORS_STORAGE_KEY, JSON.stringify(this.monitorAuthors.map(author => this.serializeMonitorAuthor(author))));
|
|
|
+
|
|
|
+ localStorage.setItem(this.MONITOR_SELECTED_AUTHOR_STORAGE_KEY, this.selectedMonitorAuthorId || '');
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 从后端加载持久化数据(任务、历史、结果)
|
|
|
+
|
|
|
+ private loadPersistedData(): void {
|
|
|
+
|
|
|
+ // 加载任务
|
|
|
+
|
|
|
+ this.http.get<any[]>('/backend/api/tasks').subscribe({
|
|
|
+
|
|
|
+ next: (tasks) => {
|
|
|
+
|
|
|
+ this.activeTasks = tasks.map(t => ({ ...t, created_at: new Date(t.created_at), updated_at: new Date(t.updated_at) }));
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ console.log(`📋 已加载 ${tasks.length} 个任务`);
|
|
|
+
|
|
|
+ // 同步:将 processing 但实际已完成的任务修正
|
|
|
+
|
|
|
+ this.syncTaskStatesFromRemixes();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 无法从后端加载任务,使用空列表');
|
|
|
+
|
|
|
+ this.activeTasks = [];
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 加载历史
|
|
|
+
|
|
|
+ this.http.get<any[]>('/backend/api/history').subscribe({
|
|
|
+
|
|
|
+ next: (records) => {
|
|
|
+
|
|
|
+ this.historyRecords = records.map(r => ({ ...r, created_at: new Date(r.created_at) }));
|
|
|
+
|
|
|
+ console.log(`📜 已加载 ${records.length} 条历史记录`);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 无法从后端加载历史,使用空列表');
|
|
|
+
|
|
|
+ this.historyRecords = [];
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 加载结果
|
|
|
+
|
|
|
+ this.http.get<any[]>('/backend/api/results').subscribe({
|
|
|
+
|
|
|
+ next: (results) => {
|
|
|
+
|
|
|
+ this.generatedResults = results.map(r => ({ ...r, created_at: new Date(r.created_at) }));
|
|
|
+
|
|
|
+ console.log(`🎬 已加载 ${results.length} 个生成结果`);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 无法从后端加载结果,使用空列表');
|
|
|
+
|
|
|
+ this.generatedResults = [];
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 从 remix 数据同步修正任务状态(处理页面刷新后任务状态不一致)
|
|
|
+
|
|
|
+ private syncTaskStatesFromRemixes(): void {
|
|
|
+
|
|
|
+ this.http.get<Record<string, any[]>>('/backend/api/remixes').subscribe({
|
|
|
+
|
|
|
+ next: (allRemixes) => {
|
|
|
+
|
|
|
+ const remixByTaskId = new Map<string, any>();
|
|
|
+
|
|
|
+ for (const remixes of Object.values(allRemixes)) {
|
|
|
+
|
|
|
+ for (const r of remixes) {
|
|
|
+
|
|
|
+ if (r.taskId) remixByTaskId.set(r.taskId, r);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ let changed = false;
|
|
|
+
|
|
|
+ for (const task of this.activeTasks) {
|
|
|
+
|
|
|
+ if (task.status === 'processing') {
|
|
|
+
|
|
|
+ const remix = remixByTaskId.get(task.id);
|
|
|
+
|
|
|
+ if (remix) {
|
|
|
+
|
|
|
+ if (remix.status === 'completed' || remix.status === 'failed') {
|
|
|
+
|
|
|
+ task.status = remix.status;
|
|
|
+
|
|
|
+ task.step = remix.status === 'completed' ? 'complete' : 'generate';
|
|
|
+
|
|
|
+ task.progress = 100;
|
|
|
+
|
|
|
+ const successCount = remix.segments?.filter((s: any) => s.status === 'completed').length || 0;
|
|
|
+
|
|
|
+ const totalCount = remix.segments?.length || 0;
|
|
|
+
|
|
|
+ task.error_message = remix.status === 'failed' ? `${successCount}/${totalCount} 片段成功` : undefined;
|
|
|
+
|
|
|
+ task.result_url = remix.segments?.find((s: any) => s.videoUrl)?.videoUrl;
|
|
|
+
|
|
|
+ task.updated_at = new Date(remix.updated_at || remix.created_at);
|
|
|
+
|
|
|
+ this.updateTask(task);
|
|
|
+
|
|
|
+ changed = true;
|
|
|
+
|
|
|
+ } else if (remix.status === 'generating') {
|
|
|
+
|
|
|
+ const successCount = remix.segments?.filter((s: any) => s.status === 'completed').length || 0;
|
|
|
+
|
|
|
+ const failedCount = remix.segments?.filter((s: any) => s.status === 'failed').length || 0;
|
|
|
+
|
|
|
+ const totalCount = remix.segments?.length || 0;
|
|
|
+
|
|
|
+ const doneCount = successCount + failedCount;
|
|
|
+
|
|
|
+ task.progress = Math.round((doneCount / totalCount) * 80);
|
|
|
+
|
|
|
+ task.updated_at = new Date(remix.updated_at || remix.created_at);
|
|
|
+
|
|
|
+ this.updateTask(task);
|
|
|
+
|
|
|
+ changed = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 超过1小时仍为 processing 且无匹配 remix 的任务标记为超时
|
|
|
+
|
|
|
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
|
|
+
|
|
|
+ for (const task of this.activeTasks) {
|
|
|
+
|
|
|
+ if (task.status === 'processing' && !remixByTaskId.has(task.id)) {
|
|
|
+
|
|
|
+ if (new Date(task.updated_at).getTime() < oneHourAgo) {
|
|
|
+
|
|
|
+ task.status = 'timeout';
|
|
|
+
|
|
|
+ task.error_message = '任务超时(无匹配生成记录)';
|
|
|
+
|
|
|
+ task.updated_at = new Date();
|
|
|
+
|
|
|
+ this.updateTask(task);
|
|
|
+
|
|
|
+ changed = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (changed) this.updateTaskStats();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 任务筛选(点击顶部统计卡可缩小到对应状态)
|
|
|
+
|
|
|
+ tasksFilter: '' | 'pending' | 'processing' | 'completed' | 'failed' = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getFilteredTasks(): Task[] {
|
|
|
+
|
|
|
+ if (!this.tasksFilter) return this.activeTasks;
|
|
|
+
|
|
|
+ return this.activeTasks.filter(t => t.status === this.tasksFilter);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ toggleTasksFilter(status: '' | 'pending' | 'processing' | 'completed' | 'failed'): void {
|
|
|
+
|
|
|
+ this.tasksFilter = this.tasksFilter === status ? '' : status;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 计算任务统计
|
|
|
+
|
|
|
+ private updateTaskStats(): void {
|
|
|
+
|
|
|
+ this.taskStats = {
|
|
|
+
|
|
|
+ pending: this.activeTasks.filter(t => t.status === 'pending').length,
|
|
|
+
|
|
|
+ processing: this.activeTasks.filter(t => t.status === 'processing').length,
|
|
|
+
|
|
|
+ completed: this.activeTasks.filter(t => t.status === 'completed').length,
|
|
|
+
|
|
|
+ failed: this.activeTasks.filter(t => t.status === 'failed' || t.status === 'timeout').length
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== 持久化保存方法 ====================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 保存任务到后端(返回 Promise,确保 POST 完成后再允许 PUT)
|
|
|
+
|
|
|
+ saveTask(task: Task): void {
|
|
|
+
|
|
|
+ this.taskSaveReady = new Promise<void>((resolve) => {
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/tasks', task).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.task?.id) task.id = res.task.id;
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ console.log('💾 任务已保存:', task.id);
|
|
|
+
|
|
|
+ resolve();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 任务保存失败:', err);
|
|
|
+
|
|
|
+ resolve(); // 即使失败也 resolve,避免阻塞后续更新
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 更新任务到后端(等待 POST 完成后再 PUT)
|
|
|
+
|
|
|
+ updateTask(task: Task): void {
|
|
|
+
|
|
|
+ this.taskSaveReady.then(() => {
|
|
|
+
|
|
|
+ this.http.put<any>(`/backend/api/tasks/${task.id}`, task).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => console.warn('⚠️ 任务更新失败:', err)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 删除任务(先做本地乐观更新,再持久化;持久化失败也保留本地删除并提示)
|
|
|
+
|
|
|
+ removeTask(taskId: string): void {
|
|
|
+
|
|
|
+ if (!confirm('确定要删除该任务吗?此操作不可恢复。')) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!taskId) {
|
|
|
+
|
|
|
+ // ID 为空的脏数据,直接从本地移除
|
|
|
+
|
|
|
+ this.activeTasks = this.activeTasks.filter(t => !!t.id);
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.showToast('🗑️ 已清除无效任务', 'info');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 乐观删除:立即从本地列表移除,UI 立刻更新
|
|
|
+
|
|
|
+ this.activeTasks = this.activeTasks.filter(t => t.id !== taskId);
|
|
|
+
|
|
|
+ this.canceledTaskIds.add(taskId);
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.delete<any>(`/backend/api/tasks/${taskId}`).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ console.log('🗑️ 任务已删除:', taskId);
|
|
|
+
|
|
|
+ this.showToast('🗑️ 任务已删除', 'success');
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 任务删除失败:', err);
|
|
|
+
|
|
|
+ this.showToast('⚠️ 后端删除失败,已从列表中移除', 'warn');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 保存历史记录到后端
|
|
|
+
|
|
|
+ saveHistoryRecord(record: Partial<HistoryRecord>): void {
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/history', record).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.record) {
|
|
|
+
|
|
|
+ this.historyRecords.unshift({ ...res.record, created_at: new Date(res.record.created_at) });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('💾 历史记录已保存');
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => console.warn('⚠️ 历史保存失败:', err)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 删除历史记录
|
|
|
+
|
|
|
+ removeHistoryRecord(recordId: string): void {
|
|
|
+
|
|
|
+ this.http.delete<any>(`/backend/api/history/${recordId}`).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ this.historyRecords = this.historyRecords.filter(r => r.id !== recordId);
|
|
|
+
|
|
|
+ console.log('🗑️ 历史记录已删除:', recordId);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => console.warn('⚠️ 历史删除失败:', err)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getFilteredHistoryRecords(): HistoryRecord[] {
|
|
|
+
|
|
|
+ return this.historyRecords.filter(record => {
|
|
|
+
|
|
|
+ return !this.historyFilter.status || record.status === this.historyFilter.status;
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 把标题中嵌入的 #标签 解析出来,主标题与标签分开渲染
|
|
|
+
|
|
|
+ parseHistoryRecordTitle(record: HistoryRecord): { title: string; tags: string[] } {
|
|
|
+
|
|
|
+ const raw = (record.original_video_title || record.keyword || '').trim();
|
|
|
+
|
|
|
+ if (!raw) return { title: '未命名记录', tags: [] };
|
|
|
+
|
|
|
+ const tags: string[] = [];
|
|
|
+
|
|
|
+ const cleaned = raw.replace(/#([^\s#]+)/g, (_, tag) => { tags.push(tag); return ''; }).replace(/\s+/g, ' ').trim();
|
|
|
+
|
|
|
+ return { title: cleaned || raw, tags };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getHistoryStatusCount(status: string): number {
|
|
|
+
|
|
|
+ if (!status) return this.historyRecords.length;
|
|
|
+
|
|
|
+ return this.historyRecords.filter(r => r.status === status).length;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 保存生成结果到后端
|
|
|
+
|
|
|
+ saveResult(result: Partial<GeneratedResult>): void {
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/results', result).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.result) {
|
|
|
+
|
|
|
+ this.generatedResults.unshift({ ...res.result, created_at: new Date(res.result.created_at) });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('💾 结果已保存');
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => console.warn('⚠️ 结果保存失败:', err)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 删除生成结果
|
|
|
+
|
|
|
+ removeResult(resultId: string): void {
|
|
|
+
|
|
|
+ this.http.delete<any>(`/backend/api/results/${resultId}`).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ this.generatedResults = this.generatedResults.filter(r => r.id !== resultId);
|
|
|
+
|
|
|
+ console.log('🗑️ 结果已删除:', resultId);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => console.warn('⚠️ 结果删除失败:', err)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Toast 通知
|
|
|
+
|
|
|
+ showToast(message: string, type: 'success' | 'info' | 'warn' | 'error' = 'info', duration: number = 4000): void {
|
|
|
+
|
|
|
+ if (this.toastTimer) clearTimeout(this.toastTimer);
|
|
|
+
|
|
|
+ this.toastMessage = message;
|
|
|
+
|
|
|
+ this.toastType = type;
|
|
|
+
|
|
|
+ this.toastVisible = true;
|
|
|
+
|
|
|
+ this.toastTimer = setTimeout(() => {
|
|
|
+
|
|
|
+ this.toastVisible = false;
|
|
|
+
|
|
|
+ }, duration);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private refreshView(): void {
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 查找并更新任务
|
|
|
+
|
|
|
+ private findRemixTask(): Task | undefined {
|
|
|
+
|
|
|
+ return this.activeTasks.find(t => t.id === this.currentRemixTaskId);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private updateRemixTaskProgress(step: Task['step'], progress: number, statusText?: string): void {
|
|
|
+
|
|
|
+ const task = this.findRemixTask();
|
|
|
+
|
|
|
+ if (!task) return;
|
|
|
+
|
|
|
+ task.step = step;
|
|
|
+
|
|
|
+ task.progress = Math.round(progress);
|
|
|
+
|
|
|
+ task.updated_at = new Date();
|
|
|
+
|
|
|
+ if (statusText) task.error_message = undefined;
|
|
|
+
|
|
|
+ this.updateTask(task);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private completeRemixTask(status: 'completed' | 'failed', resultUrl?: string, errorMsg?: string): void {
|
|
|
+
|
|
|
+ const task = this.findRemixTask();
|
|
|
+
|
|
|
+ if (!task) return;
|
|
|
+
|
|
|
+ task.status = status;
|
|
|
+
|
|
|
+ task.step = status === 'completed' ? 'complete' : task.step;
|
|
|
+
|
|
|
+ task.progress = 100;
|
|
|
+
|
|
|
+ task.result_url = resultUrl;
|
|
|
+
|
|
|
+ task.error_message = errorMsg;
|
|
|
+
|
|
|
+ task.updated_at = new Date();
|
|
|
+
|
|
|
+ this.updateTask(task);
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== Remix 会话持久化 ====================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 保存当前重塑会话到后端(每次片段完成时调用)
|
|
|
+
|
|
|
+ private saveRemixSession(status: 'generating' | 'stitching' | 'completed' | 'failed'): void {
|
|
|
+
|
|
|
+ const video = this.remixTargetVideo;
|
|
|
+
|
|
|
+ if (!video || !this.currentRemixId) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const styleName = this.remixMode === 'digital-human'
|
|
|
+
|
|
|
+ ? '数字人生成'
|
|
|
+
|
|
|
+ : (this.remixStyleOptions.find(s => s.id === this.remixStyle)?.name || this.remixStyle);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const sessionData = {
|
|
|
+
|
|
|
+ remixId: this.currentRemixId,
|
|
|
+
|
|
|
+ videoId: video.id,
|
|
|
+
|
|
|
+ videoTitle: video.title,
|
|
|
+
|
|
|
+ mode: this.remixMode,
|
|
|
+
|
|
|
+ style: this.remixStyle,
|
|
|
+
|
|
|
+ styleName: styleName,
|
|
|
+
|
|
|
+ quality: this.remixMode === 'digital-human' ? this.remixDigitalHumanResolution : this.remixQuality,
|
|
|
+
|
|
|
+ frames: this.remixFrames,
|
|
|
+
|
|
|
+ aspectRatio: this.remixAspectRatio,
|
|
|
+
|
|
|
+ status: status,
|
|
|
+
|
|
|
+ taskId: this.currentRemixTaskId,
|
|
|
+
|
|
|
+ stitchUrl: this.remixStitchUrl || '',
|
|
|
+
|
|
|
+ digitalHumanAudioSource: this.remixMode === 'digital-human' ? this.remixDigitalHumanAudioSource : '',
|
|
|
+
|
|
|
+ digitalHumanVoiceProfileId: '',
|
|
|
+
|
|
|
+ digitalHumanVoiceProfileName: '',
|
|
|
+
|
|
|
+ results: { ...this.remixResults },
|
|
|
+
|
|
|
+ segments: this.remixStoryboard.map(s => ({
|
|
|
+
|
|
|
+ id: s.id,
|
|
|
+
|
|
|
+ act: s.act,
|
|
|
+
|
|
|
+ narration: s.narration,
|
|
|
+
|
|
|
+ prompt: s.prompt,
|
|
|
+
|
|
|
+ duration: s.duration,
|
|
|
+
|
|
|
+ status: s.status,
|
|
|
+
|
|
|
+ videoUrl: s.videoUrl || '',
|
|
|
+
|
|
|
+ workId: s.workId || '',
|
|
|
+
|
|
|
+ error: s.error || ''
|
|
|
+
|
|
|
+ }))
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>(`/backend/api/remixes/${video.id}`, sessionData).subscribe({
|
|
|
+
|
|
|
+ next: () => console.log(`💾 Remix 会话已保存: ${this.currentRemixId} [${status}]`),
|
|
|
+
|
|
|
+ error: (err) => console.warn('⚠️ Remix 保存失败:', err)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 加载某视频的所有重塑记录
|
|
|
+
|
|
|
+ loadVideoRemixes(videoId: string): void {
|
|
|
+
|
|
|
+ this.http.get<any[]>(`/backend/api/remixes/${videoId}`).subscribe({
|
|
|
+
|
|
|
+ next: (remixes) => {
|
|
|
+
|
|
|
+ const video = this.managedVideos.find(v => v.id === videoId);
|
|
|
+
|
|
|
+ if (video && remixes.length > 0) {
|
|
|
+
|
|
|
+ video.remixVersions = remixes.map(r => ({
|
|
|
+
|
|
|
+ id: r.remixId,
|
|
|
+
|
|
|
+ style: r.styleName || r.style,
|
|
|
+
|
|
|
+ created_at: new Date(r.created_at),
|
|
|
+
|
|
|
+ file_path: r.stitchUrl || r.segments?.find((s: any) => s.videoUrl)?.videoUrl || '',
|
|
|
+
|
|
|
+ thumbnail: 'assets/default-remix-thumb.jpg',
|
|
|
+
|
|
|
+ description: `${r.styleName} · ${r.segments?.length || 0}段 · ${r.status === 'completed' ? '已完成' : r.status === 'generating' ? '生成中' : '失败'}`,
|
|
|
+
|
|
|
+ parameters: {
|
|
|
+
|
|
|
+ style: r.style,
|
|
|
+
|
|
|
+ intensity: 0.8,
|
|
|
+
|
|
|
+ preserve_audio: true,
|
|
|
+
|
|
|
+ quality: r.quality === 'pro' ? 'ultra' : 'high',
|
|
|
+
|
|
|
+ effects: [r.style]
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ console.log(`📂 已加载 ${video.title} 的 ${remixes.length} 条重塑记录`);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {}
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 批量加载所有视频的重塑记录
|
|
|
+
|
|
|
+ private loadAllRemixes(): void {
|
|
|
+
|
|
|
+ this.http.get<Record<string, any[]>>('/backend/api/remixes').subscribe({
|
|
|
+
|
|
|
+ next: (allRemixes) => {
|
|
|
+
|
|
|
+ let total = 0;
|
|
|
+
|
|
|
+ for (const [videoId, remixes] of Object.entries(allRemixes)) {
|
|
|
+
|
|
|
+ const video = this.managedVideos.find(v => v.id === videoId);
|
|
|
+
|
|
|
+ if (video && remixes.length > 0) {
|
|
|
+
|
|
|
+ video.remixVersions = remixes.map(r => ({
|
|
|
+
|
|
|
+ id: r.remixId,
|
|
|
+
|
|
|
+ style: r.styleName || r.style,
|
|
|
+
|
|
|
+ created_at: new Date(r.created_at),
|
|
|
+
|
|
|
+ file_path: r.stitchUrl || r.segments?.find((s: any) => s.videoUrl)?.videoUrl || '',
|
|
|
+
|
|
|
+ thumbnail: 'assets/default-remix-thumb.jpg',
|
|
|
+
|
|
|
+ description: `${r.styleName} · ${r.segments?.length || 0}段 · ${r.status === 'completed' ? '已完成' : r.status === 'generating' ? '生成中' : '失败'}`,
|
|
|
+
|
|
|
+ parameters: {
|
|
|
+
|
|
|
+ style: r.style,
|
|
|
+
|
|
|
+ intensity: 0.8,
|
|
|
+
|
|
|
+ preserve_audio: true,
|
|
|
+
|
|
|
+ quality: r.quality === 'pro' ? 'ultra' : 'high',
|
|
|
+
|
|
|
+ effects: [r.style]
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ total += remixes.length;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (total > 0) console.log(`📂 已加载 ${total} 条重塑记录`);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 从 src/video/manifest.json 加载视频清单
|
|
|
+
|
|
|
+ private loadVideoManifest(): void {
|
|
|
+
|
|
|
+ this.http.get<any[]>(`/backend/api/manifest`).subscribe({
|
|
|
+
|
|
|
+ next: (manifest) => {
|
|
|
+
|
|
|
+ this.managedVideos = manifest.map((item: any) => ({
|
|
|
+
|
|
|
+ id: item.id,
|
|
|
+
|
|
|
+ title: item.title || item.filename,
|
|
|
+
|
|
|
+ filename: item.filename,
|
|
|
+
|
|
|
+ filepath: (item.filepath && /^https?:\/\//.test(item.filepath)) ? item.filepath : `/backend/api/video/${item.filename}`,
|
|
|
+
|
|
|
+ size: item.size || 0,
|
|
|
+
|
|
|
+ duration: item.duration || 0,
|
|
|
+
|
|
|
+ created_at: item.created_at ? new Date(item.created_at) : new Date(),
|
|
|
+
|
|
|
+ modified_at: item.modified_at ? new Date(item.modified_at) : new Date(),
|
|
|
+
|
|
|
+ category: item.category || 'downloaded',
|
|
|
+
|
|
|
+ tags: item.tags || [],
|
|
|
+
|
|
|
+ description: item.description || '',
|
|
|
+
|
|
|
+ thumbnail: item.thumbnail || '',
|
|
|
+
|
|
|
+ source: item.source || 'downloaded',
|
|
|
+
|
|
|
+ metadata: item.metadata || { resolution: '', format: 'mp4' },
|
|
|
+
|
|
|
+ whisper: item.whisper || undefined
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+ console.log(`📁 已加载 ${this.managedVideos.length} 个视频`);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 加载每个视频的重塑记录
|
|
|
+
|
|
|
+ this.loadAllRemixes();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 无法加载视频清单,使用空列表:', err);
|
|
|
+
|
|
|
+ this.managedVideos = [];
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 将下载的视频添加到管理列表
|
|
|
+
|
|
|
+ private addToManagedVideos(video: VideoInfo, filename: string): void {
|
|
|
+
|
|
|
+ const newVideo: ManagedVideo = {
|
|
|
+
|
|
|
+ id: `VID-${Date.now()}`,
|
|
|
+
|
|
|
+ title: video.desc || filename,
|
|
|
+
|
|
|
+ filename: filename,
|
|
|
+
|
|
|
+ filepath: `${this.VIDEO_BASE_PATH}/${filename}`,
|
|
|
+
|
|
|
+ size: 0,
|
|
|
+
|
|
|
+ duration: video.duration || 0,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ modified_at: new Date(),
|
|
|
+
|
|
|
+ category: 'downloaded',
|
|
|
+
|
|
|
+ tags: video.text_extra || [],
|
|
|
+
|
|
|
+ description: video.desc || '',
|
|
|
+
|
|
|
+ thumbnail: video.cover_url || '',
|
|
|
+
|
|
|
+ source: 'downloaded',
|
|
|
+
|
|
|
+ metadata: {
|
|
|
+
|
|
|
+ resolution: '1920x1080',
|
|
|
+
|
|
|
+ format: 'mp4'
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.managedVideos.unshift(newVideo);
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+ console.log(`✅ 视频已添加到管理列表: ${filename}`);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ addMonitorAuthor(): void {
|
|
|
+
|
|
|
+ const uniqueId = this.monitorUniqueIdInput.trim();
|
|
|
+
|
|
|
+ if (!uniqueId || this.isAddingMonitorAuthor) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const existingAuthor = this.monitorAuthors.find(author => author.uid === uniqueId || author.id === uniqueId || author.uniqueId === uniqueId || author.shortId === uniqueId);
|
|
|
+
|
|
|
+ if (existingAuthor) {
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = existingAuthor.id;
|
|
|
+
|
|
|
+ this.persistMonitorState();
|
|
|
+
|
|
|
+ if (existingAuthor.followerCount === 0) {
|
|
|
+
|
|
|
+ this.refreshMonitorAuthorProfile(existingAuthor, false);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.showToast(`⚠️ 博主「${existingAuthor.nickname}」已在监测列表中`, 'warn');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.isAddingMonitorAuthor = true;
|
|
|
+
|
|
|
+ this.monitorAddError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.buildCompleteMonitorAuthor(uniqueId, (author, usedFallback) => {
|
|
|
+
|
|
|
+ this.monitorAuthors = [author, ...this.monitorAuthors.filter(item => item.id !== author.id && item.uid !== author.uid)];
|
|
|
+
|
|
|
+ this.monitorUniqueIdInput = '';
|
|
|
+
|
|
|
+ this.isAddingMonitorAuthor = false;
|
|
|
+
|
|
|
+ this.persistMonitorState();
|
|
|
+
|
|
|
+ this.showToast(
|
|
|
+
|
|
|
+ usedFallback
|
|
|
+
|
|
|
+ ? `⚠️ 已添加博主「${author.nickname}」,但完整粉丝数据暂未取到`
|
|
|
+
|
|
|
+ : `✅ 已添加博主「${author.nickname}」`,
|
|
|
+
|
|
|
+ usedFallback ? 'warn' : 'success'
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ this.fetchMonitorAuthorWorks(author, false);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }, (message) => {
|
|
|
+
|
|
|
+ this.isAddingMonitorAuthor = false;
|
|
|
+
|
|
|
+ this.monitorAddError = message;
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.monitorAddError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildCompleteMonitorAuthor(
|
|
|
+
|
|
|
+ uniqueId: string,
|
|
|
+
|
|
|
+ onSuccess: (author: MonitorAuthor, usedFallback: boolean) => void,
|
|
|
+
|
|
|
+ onError: (message: string) => void
|
|
|
+
|
|
|
+ ): void {
|
|
|
+
|
|
|
+ this.douyinService.getUserProfileByUniqueId(uniqueId).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ const basicProfile = this.douyinService.extractUserProfileData(res) || {};
|
|
|
+
|
|
|
+ const basicAuthor = this.mapMonitorAuthor(basicProfile, uniqueId);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!basicAuthor.secUserId) {
|
|
|
+
|
|
|
+ onError('未能从抖音号解析出 sec_user_id,暂时无法加载主页作品');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.douyinService.getUserProfileBySecUserId(basicAuthor.secUserId).subscribe({
|
|
|
+
|
|
|
+ next: (detailRes: any) => {
|
|
|
+
|
|
|
+ const detailProfile = this.douyinService.extractUserProfileData(detailRes) || {};
|
|
|
+
|
|
|
+ const author = this.mapMonitorAuthor({
|
|
|
+
|
|
|
+ ...basicProfile,
|
|
|
+
|
|
|
+ ...detailProfile,
|
|
|
+
|
|
|
+ sec_uid: detailProfile?.sec_uid || detailProfile?.sec_user_id || basicProfile?.sec_uid || basicProfile?.sec_user_id || basicAuthor.secUserId,
|
|
|
+
|
|
|
+ sec_user_id: detailProfile?.sec_user_id || detailProfile?.sec_uid || basicProfile?.sec_user_id || basicProfile?.sec_uid || basicAuthor.secUserId,
|
|
|
+
|
|
|
+ unique_id: detailProfile?.unique_id || basicProfile?.unique_id || uniqueId,
|
|
|
+
|
|
|
+ short_id: detailProfile?.short_id || basicProfile?.short_id || '',
|
|
|
+
|
|
|
+ douyin_id: detailProfile?.douyin_id || basicProfile?.douyin_id || ''
|
|
|
+
|
|
|
+ }, uniqueId);
|
|
|
+
|
|
|
+ onSuccess(author, false);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ onSuccess(basicAuthor, true);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ onError(this.getErrorText(error, '获取博主信息失败,请稍后重试'));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private refreshIncompleteMonitorAuthors(): void {
|
|
|
+
|
|
|
+ this.monitorAuthors
|
|
|
+
|
|
|
+ .filter(author => author.followerCount === 0)
|
|
|
+
|
|
|
+ .forEach(author => this.refreshMonitorAuthorProfile(author, true));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private refreshMonitorAuthorProfile(author: MonitorAuthor, silent: boolean): void {
|
|
|
+
|
|
|
+ const lookupId = (author.uniqueId || author.shortId || author.uid || '').trim();
|
|
|
+
|
|
|
+ if (!lookupId) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.buildCompleteMonitorAuthor(lookupId, (nextAuthor, usedFallback) => {
|
|
|
+
|
|
|
+ const mergedAuthor: MonitorAuthor = {
|
|
|
+
|
|
|
+ ...author,
|
|
|
+
|
|
|
+ ...nextAuthor,
|
|
|
+
|
|
|
+ works: author.works,
|
|
|
+
|
|
|
+ worksCursor: author.worksCursor,
|
|
|
+
|
|
|
+ hasMoreWorks: author.hasMoreWorks,
|
|
|
+
|
|
|
+ loadingWorks: author.loadingWorks,
|
|
|
+
|
|
|
+ coverUrl: author.coverUrl || nextAuthor.coverUrl
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.monitorAuthors = this.monitorAuthors.map(item => (
|
|
|
+
|
|
|
+ item.id === author.id
|
|
|
+
|
|
|
+ || item.uid === author.uid
|
|
|
+
|
|
|
+ || (!!author.secUserId && item.secUserId === author.secUserId)
|
|
|
+
|
|
|
+ || (!!lookupId && (item.uniqueId === lookupId || item.shortId === lookupId || item.uid === lookupId))
|
|
|
+
|
|
|
+ ? mergedAuthor
|
|
|
+
|
|
|
+ : item
|
|
|
+
|
|
|
+ ));
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.selectedMonitorAuthorId === author.id) {
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = mergedAuthor.id;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.persistMonitorState();
|
|
|
+
|
|
|
+ if (!silent) {
|
|
|
+
|
|
|
+ this.showToast(
|
|
|
+
|
|
|
+ usedFallback
|
|
|
+
|
|
|
+ ? `⚠️ 博主「${mergedAuthor.nickname}」资料已刷新,但完整粉丝数据暂未取到`
|
|
|
+
|
|
|
+ : `✅ 博主「${mergedAuthor.nickname}」资料已刷新`,
|
|
|
+
|
|
|
+ usedFallback ? 'warn' : 'success'
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }, (message) => {
|
|
|
+
|
|
|
+ if (!silent) {
|
|
|
+
|
|
|
+ this.showToast(`❌ ${message}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ openMonitorAuthor(author: MonitorAuthor): void {
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = author.id;
|
|
|
+
|
|
|
+ this.persistMonitorState();
|
|
|
+
|
|
|
+ if (author.followerCount === 0) {
|
|
|
+
|
|
|
+ this.refreshMonitorAuthorProfile(author, true);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!author.loadingWorks && author.works.length === 0) {
|
|
|
+
|
|
|
+ this.fetchMonitorAuthorWorks(author, false);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ backToMonitorList(): void {
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = '';
|
|
|
+
|
|
|
+ this.persistMonitorState();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ removeMonitorAuthor(author: MonitorAuthor): void {
|
|
|
+
|
|
|
+ if (!confirm(`确定要删除博主「${author.nickname}」吗?`)) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.monitorAuthors = this.monitorAuthors.filter(item => item.id !== author.id);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.selectedMonitorAuthorId === author.id) {
|
|
|
+
|
|
|
+ this.selectedMonitorAuthorId = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.persistMonitorState();
|
|
|
+
|
|
|
+ this.showToast(`🗑️ 已移除博主「${author.nickname}」`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getSelectedMonitorAuthor(): MonitorAuthor | null {
|
|
|
+
|
|
|
+ return this.monitorAuthors.find(author => author.id === this.selectedMonitorAuthorId) || null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getTotalMonitorWorksCount(): number {
|
|
|
+
|
|
|
+ return this.monitorAuthors.reduce((total, author) => total + author.works.length, 0);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ reloadMonitorAuthorWorks(author: MonitorAuthor): void {
|
|
|
+
|
|
|
+ this.fetchMonitorAuthorWorks(author, false);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ loadMoreMonitorWorks(author: MonitorAuthor): void {
|
|
|
+
|
|
|
+ if (!author.hasMoreWorks || author.loadingWorks) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.fetchMonitorAuthorWorks(author, true);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ handleMonitorAuthorAvatarError(author: MonitorAuthor): void {
|
|
|
+
|
|
|
+ const currentIndex = author.avatarCandidates.indexOf(author.avatarUrl);
|
|
|
+
|
|
|
+ const nextIndex = currentIndex >= 0 ? currentIndex + 1 : 0;
|
|
|
+
|
|
|
+ author.avatarUrl = author.avatarCandidates[nextIndex] || this.getMonitorAvatarFallback(author.nickname);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ handleMonitorWorkCoverError(work: MonitorWork): void {
|
|
|
+
|
|
|
+ const currentIndex = work.coverCandidates.indexOf(work.coverUrl);
|
|
|
+
|
|
|
+ const nextIndex = currentIndex >= 0 ? currentIndex + 1 : 0;
|
|
|
+
|
|
|
+ work.coverUrl = work.coverCandidates[nextIndex] || this.getVideoCoverFallback();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ previewMonitorWork(author: MonitorAuthor, work: MonitorWork): void {
|
|
|
+
|
|
|
+ if (work.type !== 'video') {
|
|
|
+
|
|
|
+ window.open(work.coverUrl, '_blank');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.viewVideoDetail(this.buildMonitorVideoInfo(author, work));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ downloadMonitorWork(author: MonitorAuthor, work: MonitorWork): void {
|
|
|
+
|
|
|
+ if (work.type !== 'video' || work.isDownloading) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ work.isDownloading = true;
|
|
|
+
|
|
|
+ work.downloadProgress = 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const video = this.buildMonitorVideoInfo(author, work);
|
|
|
+
|
|
|
+ this.douyinService.getVideoDetail(work.id).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ const detailData = this.douyinService.extractVideoDetailData(res) || {};
|
|
|
+
|
|
|
+ const downloadUrls = this.douyinService.extractVideoDownloadUrls(res);
|
|
|
+
|
|
|
+ const downloadUrl = downloadUrls[0] || '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!downloadUrl) {
|
|
|
+
|
|
|
+ work.isDownloading = false;
|
|
|
+
|
|
|
+ work.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast('❌ 无法获取下载地址,请稍后重试', 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const filename = this.buildVideoFilename(video);
|
|
|
+
|
|
|
+ this.douyinService.startManagedDownload({
|
|
|
+
|
|
|
+ url: downloadUrl,
|
|
|
+
|
|
|
+ urls: downloadUrls,
|
|
|
+
|
|
|
+ filename,
|
|
|
+
|
|
|
+ title: work.desc || work.id,
|
|
|
+
|
|
|
+ description: detailData?.desc || work.desc || '',
|
|
|
+
|
|
|
+ tags: [],
|
|
|
+
|
|
|
+ thumbnail: work.coverUrl || '',
|
|
|
+
|
|
|
+ duration: work.duration || 0,
|
|
|
+
|
|
|
+ resolution: detailData?.video?.ratio || '',
|
|
|
+
|
|
|
+ awemeId: work.id,
|
|
|
+
|
|
|
+ authorName: author.nickname || ''
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (taskRes: any) => {
|
|
|
+
|
|
|
+ if (!taskRes?.taskId) {
|
|
|
+
|
|
|
+ work.isDownloading = false;
|
|
|
+
|
|
|
+ work.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast('❌ 下载任务创建失败,请稍后重试', 'error');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.showToast(`📥 正在下载「${work.desc || work.id}」到视频库`, 'info');
|
|
|
+
|
|
|
+ this.pollMonitorWorkDownload(work, taskRes.taskId);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ work.isDownloading = false;
|
|
|
+
|
|
|
+ work.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast(`❌ 创建下载任务失败:${error?.error?.error || error?.message || '请稍后重试'}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ work.isDownloading = false;
|
|
|
+
|
|
|
+ work.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast(`❌ 获取视频信息失败:${error?.error?.error || error?.message || '请稍后重试'}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getMonitorHeroBackground(author: MonitorAuthor): string {
|
|
|
+
|
|
|
+ return author.coverUrl
|
|
|
+
|
|
|
+ ? `linear-gradient(90deg, rgba(11, 15, 25, 0.92), rgba(11, 15, 25, 0.55)), url(${author.coverUrl})`
|
|
|
+
|
|
|
+ : 'linear-gradient(135deg, #161a27, #232a3d)';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 搜索视频
|
|
|
+
|
|
|
+ searchVideos(): void {
|
|
|
+
|
|
|
+ if (!this.searchKeyword.trim()) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.isSearching = true;
|
|
|
+
|
|
|
+ this.searchCursor = 0;
|
|
|
+
|
|
|
+ this.searchId = '';
|
|
|
+
|
|
|
+ this.searchBacktrace = '';
|
|
|
+
|
|
|
+ this.searchResults = [];
|
|
|
+
|
|
|
+ this.filteredSearchResults = [];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.douyinService.searchVideos(this.searchKeyword, '0', 0, '0', '0', '1')
|
|
|
+
|
|
|
+ .subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ this.isSearching = false;
|
|
|
+
|
|
|
+ const parsed = this.parseSearchResponse(res);
|
|
|
+
|
|
|
+ this.searchResults = parsed.items
|
|
|
+
|
|
|
+ .map((item: any) => this.mapVideoItem(item))
|
|
|
+
|
|
|
+ .filter((item: VideoInfo) => !!item.aweme_id || !!item.desc || !!item.cover_url);
|
|
|
+
|
|
|
+ this.applySearchFilters();
|
|
|
+
|
|
|
+ this.hasMore = parsed.hasMore;
|
|
|
+
|
|
|
+ this.searchCursor = parsed.cursor;
|
|
|
+
|
|
|
+ this.searchId = parsed.searchId;
|
|
|
+
|
|
|
+ this.searchBacktrace = parsed.backtrace;
|
|
|
+
|
|
|
+ console.log('🔍 搜索结果解析:', { raw: res, parsed, mapped: this.searchResults });
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.filteredSearchResults.length === 0) {
|
|
|
+
|
|
|
+ alert('未找到相关视频,请尝试其他关键词');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ console.error('搜索失败:', error);
|
|
|
+
|
|
|
+ this.isSearching = false;
|
|
|
+
|
|
|
+ let errorMessage = '搜索失败,请稍后重试';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (error.status === 401) {
|
|
|
+
|
|
|
+ errorMessage = '认证失败,Token可能已失效';
|
|
|
+
|
|
|
+ } else if (error.status === 429) {
|
|
|
+
|
|
|
+ errorMessage = '请求过于频繁,请稍后重试';
|
|
|
+
|
|
|
+ } else if (error.status === 0) {
|
|
|
+
|
|
|
+ errorMessage = '网络连接失败,请检查网络设置';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ alert(errorMessage);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 映射API返回的视频数据
|
|
|
+
|
|
|
+ private mapVideoItem(item: any): VideoInfo {
|
|
|
+
|
|
|
+ const info = item?.aweme_info || item?.data?.aweme_info || item?.data?.challenge_info || item;
|
|
|
+
|
|
|
+ const coverCandidates = this.buildCoverCandidates(info);
|
|
|
+
|
|
|
+ const coverUrl = coverCandidates[0] || this.getVideoCoverFallback();
|
|
|
+
|
|
|
+ const duration = Math.floor((info.video?.duration || info.duration || 0) / ((info.video?.duration || info.duration || 0) > 1000 ? 1000 : 1));
|
|
|
+
|
|
|
+ const tags = Array.isArray(info.text_extra)
|
|
|
+
|
|
|
+ ? info.text_extra
|
|
|
+
|
|
|
+ .map((tag: any) => tag?.hashtag_name || tag?.cha_name || tag?.tag_name || tag?.hash_tag_name || tag)
|
|
|
+
|
|
|
+ .filter((tag: any) => typeof tag === 'string' && tag.trim())
|
|
|
+
|
|
|
+ : Array.isArray(info.cha_list)
|
|
|
+
|
|
|
+ ? info.cha_list
|
|
|
+
|
|
|
+ .map((tag: any) => tag?.cha_name || tag?.hashtag_name || tag?.tag_name || tag)
|
|
|
+
|
|
|
+ .filter((tag: any) => typeof tag === 'string' && tag.trim())
|
|
|
+
|
|
|
+ : (info.desc ? [info.desc] : []);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ aweme_id: info.aweme_id || info.group_id || info.cid || item.data_id || '',
|
|
|
+
|
|
|
+ desc: info.desc || info.cha_name || info.title || info.content || '',
|
|
|
+
|
|
|
+ cover_url: coverUrl,
|
|
|
+
|
|
|
+ cover_candidates: coverCandidates,
|
|
|
+
|
|
|
+ duration: duration,
|
|
|
+
|
|
|
+ create_time: info.create_time || item.create_time || 0,
|
|
|
+
|
|
|
+ publishDate: (info.create_time || item.create_time) ? new Date((info.create_time || item.create_time) * 1000) : null,
|
|
|
+
|
|
|
+ author: {
|
|
|
+
|
|
|
+ nickname: info.author?.nickname || info.nickname || '未知作者',
|
|
|
+
|
|
|
+ sec_uid: info.author?.sec_uid || info.author?.uid || ''
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ statistics: {
|
|
|
+
|
|
|
+ play_count: info.statistics?.play_count || info.view_count || 0,
|
|
|
+
|
|
|
+ digg_count: info.statistics?.digg_count || info.user_count || 0,
|
|
|
+
|
|
|
+ comment_count: info.statistics?.comment_count || 0,
|
|
|
+
|
|
|
+ share_count: info.statistics?.share_count || 0
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ text_extra: tags
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildCoverCandidates(info: any): string[] {
|
|
|
+
|
|
|
+ const candidates = [
|
|
|
+
|
|
|
+ info.video?.origin_cover?.url_list?.[0],
|
|
|
+
|
|
|
+ info.video?.cover?.url_list?.[0],
|
|
|
+
|
|
|
+ info.video?.dynamic_cover?.url_list?.[0],
|
|
|
+
|
|
|
+ info.video?.animated_cover?.url_list?.[0],
|
|
|
+
|
|
|
+ info.author?.avatar_larger?.url_list?.[0],
|
|
|
+
|
|
|
+ info.author?.avatar_medium?.url_list?.[0],
|
|
|
+
|
|
|
+ info.author?.avatar_thumb?.url_list?.[0],
|
|
|
+
|
|
|
+ info.hashtag_profile,
|
|
|
+
|
|
|
+ info.cover_url
|
|
|
+
|
|
|
+ ];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return candidates.filter((url, index, list) => typeof url === 'string' && url.trim() && list.indexOf(url) === index);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ handleVideoCoverError(video: VideoInfo): void {
|
|
|
+
|
|
|
+ const candidates = video.cover_candidates || [];
|
|
|
+
|
|
|
+ const currentIndex = candidates.indexOf(video.cover_url);
|
|
|
+
|
|
|
+ const nextIndex = currentIndex >= 0 ? currentIndex + 1 : 0;
|
|
|
+
|
|
|
+ const nextCover = candidates[nextIndex];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (nextCover) {
|
|
|
+
|
|
|
+ video.cover_url = nextCover;
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ video.cover_url = this.getVideoCoverFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private getVideoCoverFallback(): string {
|
|
|
+
|
|
|
+ return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270" viewBox="0 0 480 270"><rect width="480" height="270" fill="#f3f4f6"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#9ca3af" font-size="20">封面加载失败</text></svg>');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private parseSearchResponse(res: any): { items: any[]; hasMore: boolean; cursor: number; searchId: string; backtrace: string } {
|
|
|
+
|
|
|
+ const normalizedResponse = this.parseJsonPayload(res);
|
|
|
+
|
|
|
+ const normalizedData = this.parseJsonPayload(normalizedResponse?.data);
|
|
|
+
|
|
|
+ const nestedData = this.parseJsonPayload(normalizedData?.data);
|
|
|
+
|
|
|
+ const businessConfig = this.parseJsonPayload(normalizedData?.business_config) || {};
|
|
|
+
|
|
|
+ const nextPage = this.parseJsonPayload(businessConfig?.next_page) || {};
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const directData = Array.isArray(normalizedData) ? normalizedData : [];
|
|
|
+
|
|
|
+ const nestedArray = Array.isArray(nestedData) ? nestedData : [];
|
|
|
+
|
|
|
+ const businessData = Array.isArray(normalizedData?.business_data) ? normalizedData.business_data : [];
|
|
|
+
|
|
|
+ const items = directData.length > 0 ? directData : (nestedArray.length > 0 ? nestedArray : businessData);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const hasMore =
|
|
|
+
|
|
|
+ normalizedResponse?.has_more === 1 ||
|
|
|
+
|
|
|
+ normalizedData?.has_more === 1 ||
|
|
|
+
|
|
|
+ businessConfig?.has_more === 1 ||
|
|
|
+
|
|
|
+ false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const cursor =
|
|
|
+
|
|
|
+ normalizedResponse?.cursor ??
|
|
|
+
|
|
|
+ normalizedData?.cursor ??
|
|
|
+
|
|
|
+ nextPage?.cursor ??
|
|
|
+
|
|
|
+ items.length;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const searchId =
|
|
|
+
|
|
|
+ normalizedResponse?.search_id ||
|
|
|
+
|
|
|
+ normalizedData?.search_id ||
|
|
|
+
|
|
|
+ nextPage?.search_id ||
|
|
|
+
|
|
|
+ nextPage?.search_request_id ||
|
|
|
+
|
|
|
+ '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const backtrace =
|
|
|
+
|
|
|
+ normalizedResponse?.backtrace ||
|
|
|
+
|
|
|
+ normalizedData?.backtrace ||
|
|
|
+
|
|
|
+ nextPage?.backtrace ||
|
|
|
+
|
|
|
+ '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ items,
|
|
|
+
|
|
|
+ hasMore,
|
|
|
+
|
|
|
+ cursor: typeof cursor === 'number' ? cursor : Number(cursor || items.length),
|
|
|
+
|
|
|
+ searchId,
|
|
|
+
|
|
|
+ backtrace
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private parseJsonPayload(value: any): any {
|
|
|
+
|
|
|
+ let current = value;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ while (typeof current === 'string') {
|
|
|
+
|
|
|
+ const trimmed = current.trim();
|
|
|
+
|
|
|
+ if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) {
|
|
|
+
|
|
|
+ break;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ current = JSON.parse(trimmed);
|
|
|
+
|
|
|
+ } catch {
|
|
|
+
|
|
|
+ break;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return current;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private normalizeMonitorLabelText(value: any): string {
|
|
|
+
|
|
|
+ const normalized = this.parseJsonPayload(value);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (typeof normalized === 'string') {
|
|
|
+
|
|
|
+ const trimmed = normalized.trim();
|
|
|
+
|
|
|
+ if (!trimmed) {
|
|
|
+
|
|
|
+ return '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const labelTextMatch = trimmed.match(/"label_text"\s*:\s*"([^"]+)"/);
|
|
|
+
|
|
|
+ if (labelTextMatch?.[1]) {
|
|
|
+
|
|
|
+ return labelTextMatch[1].trim();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return trimmed;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (Array.isArray(normalized)) {
|
|
|
+
|
|
|
+ return normalized
|
|
|
+
|
|
|
+ .map(item => this.normalizeMonitorLabelText(item))
|
|
|
+
|
|
|
+ .find(item => !!item) || '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (normalized && typeof normalized === 'object') {
|
|
|
+
|
|
|
+ const candidateKeys = ['label_text', 'text', 'name', 'title', 'desc'];
|
|
|
+
|
|
|
+ for (const key of candidateKeys) {
|
|
|
+
|
|
|
+ const text = this.normalizeMonitorLabelText(normalized[key]);
|
|
|
+
|
|
|
+ if (text) {
|
|
|
+
|
|
|
+ return text;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getMonitorIpLocationText(author: MonitorAuthor | null | undefined): string {
|
|
|
+
|
|
|
+ const location = this.normalizeMonitorIpLocation(author?.ipLocation);
|
|
|
+
|
|
|
+ return location ? `IP 属地:${location}` : 'IP 属地未抓取';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private normalizeMonitorIpLocation(value: any): string {
|
|
|
+
|
|
|
+ const normalized = this.normalizeMonitorLabelText(value);
|
|
|
+
|
|
|
+ if (!normalized) {
|
|
|
+
|
|
|
+ return '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return normalized.replace(/^IP\s*属地\s*[::]?\s*/i, '').trim();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private pickMonitorCountValue(values: any[]): number {
|
|
|
+
|
|
|
+ for (const value of values) {
|
|
|
+
|
|
|
+ const parsed = this.parseMonitorCountValue(value);
|
|
|
+
|
|
|
+ if (parsed !== null) {
|
|
|
+
|
|
|
+ return parsed;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return 0;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private parseMonitorCountValue(value: any): number | null {
|
|
|
+
|
|
|
+ const normalized = this.parseJsonPayload(value);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (typeof normalized === 'number') {
|
|
|
+
|
|
|
+ return Number.isFinite(normalized) ? normalized : null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (typeof normalized === 'string') {
|
|
|
+
|
|
|
+ const trimmed = normalized.trim();
|
|
|
+
|
|
|
+ if (!trimmed) {
|
|
|
+
|
|
|
+ return null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const compact = trimmed.replace(/,/g, '').replace(/\s+/g, '');
|
|
|
+
|
|
|
+ const unitMatch = compact.match(/^([\d.]+)(亿|万|[kKmMwW])$/);
|
|
|
+
|
|
|
+ if (unitMatch) {
|
|
|
+
|
|
|
+ const base = Number(unitMatch[1]);
|
|
|
+
|
|
|
+ if (!Number.isFinite(base)) {
|
|
|
+
|
|
|
+ return null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const unit = unitMatch[2].toLowerCase();
|
|
|
+
|
|
|
+ const multiplier = unit === '亿'
|
|
|
+
|
|
|
+ ? 100000000
|
|
|
+
|
|
|
+ : (unit === '万' || unit === 'w')
|
|
|
+
|
|
|
+ ? 10000
|
|
|
+
|
|
|
+ : unit === 'm'
|
|
|
+
|
|
|
+ ? 1000000
|
|
|
+
|
|
|
+ : 1000;
|
|
|
+
|
|
|
+ return Math.round(base * multiplier);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const directNumber = Number(compact);
|
|
|
+
|
|
|
+ return Number.isFinite(directNumber) ? directNumber : null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (normalized && typeof normalized === 'object') {
|
|
|
+
|
|
|
+ const candidateKeys = ['count', 'value', 'total', 'num'];
|
|
|
+
|
|
|
+ for (const key of candidateKeys) {
|
|
|
+
|
|
|
+ const parsed = this.parseMonitorCountValue(normalized[key]);
|
|
|
+
|
|
|
+ if (parsed !== null) {
|
|
|
+
|
|
|
+ return parsed;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onSearchFilterChange(): void {
|
|
|
+
|
|
|
+ this.applySearchFilters();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private applySearchFilters(): void {
|
|
|
+
|
|
|
+ let items = [...this.searchResults];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.publishTime !== '0') {
|
|
|
+
|
|
|
+ const nowSeconds = Math.floor(Date.now() / 1000);
|
|
|
+
|
|
|
+ const limitSeconds = Number(this.publishTime) * 24 * 60 * 60;
|
|
|
+
|
|
|
+ items = items.filter((video) => !video.create_time || (nowSeconds - video.create_time) <= limitSeconds);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.filterDuration !== '0') {
|
|
|
+
|
|
|
+ const [minMinutes, maxMinutes] = this.filterDuration.split('-').map(value => Number(value || 0));
|
|
|
+
|
|
|
+ items = items.filter((video) => {
|
|
|
+
|
|
|
+ const durationMinutes = (video.duration || 0) / 60;
|
|
|
+
|
|
|
+ return durationMinutes >= minMinutes && durationMinutes <= maxMinutes;
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.sortType === '1') {
|
|
|
+
|
|
|
+ items.sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0));
|
|
|
+
|
|
|
+ } else if (this.sortType === '2') {
|
|
|
+
|
|
|
+ items.sort((a, b) => (b.create_time || 0) - (a.create_time || 0));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.filteredSearchResults = items;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private fetchMonitorAuthorWorks(author: MonitorAuthor, append: boolean): void {
|
|
|
+
|
|
|
+ if (!author.secUserId || author.loadingWorks) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ author.loadingWorks = true;
|
|
|
+
|
|
|
+ const cursor = append ? author.worksCursor : '0';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.douyinService.getUserPostVideos(author.secUserId, cursor).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ const parsed = this.douyinService.extractUserPostVideos(res);
|
|
|
+
|
|
|
+ const mappedWorks = parsed.items
|
|
|
+
|
|
|
+ .map((item: any) => this.mapMonitorWork(item))
|
|
|
+
|
|
|
+ .filter((item: MonitorWork) => !!item.id || !!item.desc || !!item.coverUrl);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ author.works = append
|
|
|
+
|
|
|
+ ? [...author.works, ...mappedWorks.filter(item => !author.works.some(existing => existing.id === item.id))]
|
|
|
+
|
|
|
+ : mappedWorks;
|
|
|
+
|
|
|
+ author.worksCursor = parsed.cursor;
|
|
|
+
|
|
|
+ author.hasMoreWorks = parsed.hasMore;
|
|
|
+
|
|
|
+ author.loadingWorks = false;
|
|
|
+
|
|
|
+ author.coverUrl = author.coverUrl || author.works[0]?.coverUrl || author.avatarUrl;
|
|
|
+
|
|
|
+ this.persistMonitorState();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ author.loadingWorks = false;
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.getErrorText(error, '加载博主作品失败,请稍后重试')}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private mapMonitorAuthor(profile: any, uid: string): MonitorAuthor {
|
|
|
+
|
|
|
+ const avatarCandidates = this.pickUniqueStringValues([
|
|
|
+
|
|
|
+ profile?.avatar_larger?.url_list,
|
|
|
+
|
|
|
+ profile?.avatar_medium?.url_list,
|
|
|
+
|
|
|
+ profile?.avatar_thumb?.url_list,
|
|
|
+
|
|
|
+ profile?.avatar_168x168?.url_list,
|
|
|
+
|
|
|
+ profile?.avatar_url
|
|
|
+
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const coverCandidates = this.pickUniqueStringValues([
|
|
|
+
|
|
|
+ profile?.cover_url,
|
|
|
+
|
|
|
+ profile?.cover_urls,
|
|
|
+
|
|
|
+ profile?.cover_thumb?.url_list,
|
|
|
+
|
|
|
+ profile?.video_cover?.url_list,
|
|
|
+
|
|
|
+ avatarCandidates
|
|
|
+
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const nickname = profile?.nickname || profile?.display_name || `UID ${uid}`;
|
|
|
+
|
|
|
+ const verifiedText = [
|
|
|
+
|
|
|
+ profile?.custom_verify,
|
|
|
+
|
|
|
+ profile?.enterprise_verify_reason,
|
|
|
+
|
|
|
+ profile?.account_cert_info,
|
|
|
+
|
|
|
+ profile?.verification_info
|
|
|
+
|
|
|
+ ].map((item: any) => this.normalizeMonitorLabelText(item)).find((item: string) => !!item) || '';
|
|
|
+
|
|
|
+ const ipLocation = this.normalizeMonitorIpLocation(
|
|
|
+
|
|
|
+ [
|
|
|
+
|
|
|
+ profile?.ip_location,
|
|
|
+
|
|
|
+ profile?.ip_label,
|
|
|
+
|
|
|
+ profile?.ipLocation,
|
|
|
+
|
|
|
+ profile?.ip_info?.location,
|
|
|
+
|
|
|
+ profile?.ip_info?.ip_location,
|
|
|
+
|
|
|
+ profile?.ip_region,
|
|
|
+
|
|
|
+ profile?.location
|
|
|
+
|
|
|
+ ].find((item: any) => !!this.normalizeMonitorLabelText(item)) || ''
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ const followerCount = this.pickMonitorCountValue([
|
|
|
+
|
|
|
+ profile?.follower_count,
|
|
|
+
|
|
|
+ profile?.followerCount,
|
|
|
+
|
|
|
+ profile?.fans_count,
|
|
|
+
|
|
|
+ profile?.fansCount,
|
|
|
+
|
|
|
+ profile?.public_fans_count,
|
|
|
+
|
|
|
+ profile?.statistics?.follower_count,
|
|
|
+
|
|
|
+ profile?.statistics?.fans_count,
|
|
|
+
|
|
|
+ profile?.author_stats?.follower_count,
|
|
|
+
|
|
|
+ profile?.author_stats?.fans_count,
|
|
|
+
|
|
|
+ profile?.user_stats?.follower_count,
|
|
|
+
|
|
|
+ profile?.user_stats?.fans_count,
|
|
|
+
|
|
|
+ profile?.user_statistics?.follower_count,
|
|
|
+
|
|
|
+ profile?.user_statistics?.fans_count,
|
|
|
+
|
|
|
+ profile?.stats?.follower_count,
|
|
|
+
|
|
|
+ profile?.stats?.fans_count
|
|
|
+
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const followingCount = this.pickMonitorCountValue([
|
|
|
+
|
|
|
+ profile?.following_count,
|
|
|
+
|
|
|
+ profile?.followingCount,
|
|
|
+
|
|
|
+ profile?.statistics?.following_count,
|
|
|
+
|
|
|
+ profile?.author_stats?.following_count,
|
|
|
+
|
|
|
+ profile?.user_stats?.following_count,
|
|
|
+
|
|
|
+ profile?.user_statistics?.following_count,
|
|
|
+
|
|
|
+ profile?.stats?.following_count
|
|
|
+
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const totalFavorited = this.pickMonitorCountValue([
|
|
|
+
|
|
|
+ profile?.total_favorited,
|
|
|
+
|
|
|
+ profile?.favoriting_count,
|
|
|
+
|
|
|
+ profile?.totalFavorited,
|
|
|
+
|
|
|
+ profile?.liked_count,
|
|
|
+
|
|
|
+ profile?.statistics?.total_favorited,
|
|
|
+
|
|
|
+ profile?.statistics?.favoriting_count,
|
|
|
+
|
|
|
+ profile?.author_stats?.total_favorited,
|
|
|
+
|
|
|
+ profile?.user_stats?.total_favorited,
|
|
|
+
|
|
|
+ profile?.user_statistics?.total_favorited,
|
|
|
+
|
|
|
+ profile?.stats?.total_favorited
|
|
|
+
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const awemeCount = this.pickMonitorCountValue([
|
|
|
+
|
|
|
+ profile?.aweme_count,
|
|
|
+
|
|
|
+ profile?.video_count,
|
|
|
+
|
|
|
+ profile?.awemeCount,
|
|
|
+
|
|
|
+ profile?.statistics?.aweme_count,
|
|
|
+
|
|
|
+ profile?.statistics?.video_count,
|
|
|
+
|
|
|
+ profile?.author_stats?.aweme_count,
|
|
|
+
|
|
|
+ profile?.user_stats?.aweme_count,
|
|
|
+
|
|
|
+ profile?.user_statistics?.aweme_count,
|
|
|
+
|
|
|
+ profile?.stats?.aweme_count
|
|
|
+
|
|
|
+ ]);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ id: String(profile?.uid || uid),
|
|
|
+
|
|
|
+ uid: String(uid),
|
|
|
+
|
|
|
+ secUserId: String(profile?.sec_uid || profile?.sec_user_id || profile?.secUid || ''),
|
|
|
+
|
|
|
+ nickname,
|
|
|
+
|
|
|
+ uniqueId: String(profile?.unique_id || profile?.short_id || profile?.douyin_id || ''),
|
|
|
+
|
|
|
+ shortId: String(profile?.short_id || profile?.douyin_id || ''),
|
|
|
+
|
|
|
+ signature: profile?.signature || profile?.bio_description || '',
|
|
|
+
|
|
|
+ ipLocation,
|
|
|
+
|
|
|
+ verifiedText,
|
|
|
+
|
|
|
+ avatarUrl: avatarCandidates[0] || this.getMonitorAvatarFallback(nickname),
|
|
|
+
|
|
|
+ avatarCandidates,
|
|
|
+
|
|
|
+ coverUrl: coverCandidates[0] || '',
|
|
|
+
|
|
|
+ coverCandidates,
|
|
|
+
|
|
|
+ followerCount,
|
|
|
+
|
|
|
+ followingCount,
|
|
|
+
|
|
|
+ totalFavorited,
|
|
|
+
|
|
|
+ awemeCount,
|
|
|
+
|
|
|
+ works: [],
|
|
|
+
|
|
|
+ worksCursor: '0',
|
|
|
+
|
|
|
+ hasMoreWorks: false,
|
|
|
+
|
|
|
+ loadingWorks: false
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private mapMonitorWork(item: any): MonitorWork {
|
|
|
+
|
|
|
+ const coverCandidates = this.pickUniqueStringValues([
|
|
|
+
|
|
|
+ item?.video?.origin_cover?.url_list,
|
|
|
+
|
|
|
+ item?.video?.cover?.url_list,
|
|
|
+
|
|
|
+ item?.video?.dynamic_cover?.url_list,
|
|
|
+
|
|
|
+ item?.cover?.url_list,
|
|
|
+
|
|
|
+ item?.images?.map((image: any) => image?.url_list?.[0] || image?.download_url_list?.[0]),
|
|
|
+
|
|
|
+ item?.image_infos?.map((image: any) => image?.label_large?.url_list?.[0] || image?.label_thumb?.url_list?.[0]),
|
|
|
+
|
|
|
+ item?.cover_url
|
|
|
+
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const rawDuration = Number(item?.video?.duration || item?.duration || 0);
|
|
|
+
|
|
|
+ const createTimestamp = Number(item?.create_time || 0);
|
|
|
+
|
|
|
+ const statistics = item?.statistics || item?.statis || {};
|
|
|
+
|
|
|
+ const hasImageGroup = (Array.isArray(item?.images) && item.images.length > 0) || (Array.isArray(item?.image_infos) && item.image_infos.length > 0);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ id: String(item?.aweme_id || item?.group_id || item?.item_id || `${Date.now()}-${Math.random()}`),
|
|
|
+
|
|
|
+ desc: item?.desc || item?.title || '未命名作品',
|
|
|
+
|
|
|
+ coverUrl: coverCandidates[0] || this.getVideoCoverFallback(),
|
|
|
+
|
|
|
+ coverCandidates,
|
|
|
+
|
|
|
+ duration: Math.floor(rawDuration / (rawDuration > 1000 ? 1000 : 1)),
|
|
|
+
|
|
|
+ createTime: createTimestamp ? new Date(createTimestamp > 1000000000000 ? createTimestamp : createTimestamp * 1000) : undefined,
|
|
|
+
|
|
|
+ type: hasImageGroup ? 'image' : 'video',
|
|
|
+
|
|
|
+ statistics: {
|
|
|
+
|
|
|
+ play_count: Number(statistics?.play_count || statistics?.playCount || 0),
|
|
|
+
|
|
|
+ digg_count: Number(statistics?.digg_count || 0),
|
|
|
+
|
|
|
+ comment_count: Number(statistics?.comment_count || 0),
|
|
|
+
|
|
|
+ share_count: Number(statistics?.share_count || 0)
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private pickUniqueStringValues(values: any[]): string[] {
|
|
|
+
|
|
|
+ const uniqueValues: string[] = [];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const collect = (value: any) => {
|
|
|
+
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+
|
|
|
+ value.forEach(collect);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (typeof value === 'string' && value.trim() && !uniqueValues.includes(value)) {
|
|
|
+
|
|
|
+ uniqueValues.push(value);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ values.forEach(collect);
|
|
|
+
|
|
|
+ return uniqueValues;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private getMonitorAvatarFallback(name: string): string {
|
|
|
+
|
|
|
+ const label = (name || '博主').trim().slice(0, 2) || '博主';
|
|
|
+
|
|
|
+ return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160"><rect width="160" height="160" rx="80" fill="#ff0050"/><text x="50%" y="54%" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="48" font-family="Arial, sans-serif">${label}</text></svg>`);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private getErrorText(error: any, fallback: string): string {
|
|
|
+
|
|
|
+ return error?.error?.error || error?.error?.msg || error?.message || fallback;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private async extractHttpBlobErrorMessage(error: any, fallback: string): Promise<string> {
|
|
|
+
|
|
|
+ if (error instanceof HttpErrorResponse && error.error instanceof Blob) {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const text = await error.error.text();
|
|
|
+
|
|
|
+ if (text) {
|
|
|
+
|
|
|
+ const parsed = JSON.parse(text);
|
|
|
+
|
|
|
+ return parsed?.error || parsed?.message || fallback;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch {
|
|
|
+
|
|
|
+ return fallback;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return error?.error?.error || error?.error?.message || error?.message || fallback;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private serializeMonitorAuthor(author: MonitorAuthor): any {
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ ...author,
|
|
|
+
|
|
|
+ loadingWorks: false,
|
|
|
+
|
|
|
+ works: author.works.map(work => ({
|
|
|
+
|
|
|
+ ...work,
|
|
|
+
|
|
|
+ createTime: work.createTime ? work.createTime.toISOString() : null,
|
|
|
+
|
|
|
+ isDownloading: false,
|
|
|
+
|
|
|
+ downloadProgress: 0
|
|
|
+
|
|
|
+ }))
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private deserializeMonitorAuthor(author: any): MonitorAuthor {
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ id: String(author?.id || ''),
|
|
|
+
|
|
|
+ uid: String(author?.uid || ''),
|
|
|
+
|
|
|
+ secUserId: String(author?.secUserId || ''),
|
|
|
+
|
|
|
+ nickname: String(author?.nickname || ''),
|
|
|
+
|
|
|
+ uniqueId: String(author?.uniqueId || ''),
|
|
|
+
|
|
|
+ shortId: String(author?.shortId || ''),
|
|
|
+
|
|
|
+ signature: String(author?.signature || ''),
|
|
|
+
|
|
|
+ ipLocation: String(author?.ipLocation || ''),
|
|
|
+
|
|
|
+ verifiedText: this.normalizeMonitorLabelText(author?.verifiedText),
|
|
|
+
|
|
|
+ avatarUrl: String(author?.avatarUrl || this.getMonitorAvatarFallback(author?.nickname || '博主')),
|
|
|
+
|
|
|
+ avatarCandidates: Array.isArray(author?.avatarCandidates) ? author.avatarCandidates : [],
|
|
|
+
|
|
|
+ coverUrl: String(author?.coverUrl || ''),
|
|
|
+
|
|
|
+ coverCandidates: Array.isArray(author?.coverCandidates) ? author.coverCandidates : [],
|
|
|
+
|
|
|
+ followerCount: Number(author?.followerCount || 0),
|
|
|
+
|
|
|
+ followingCount: Number(author?.followingCount || 0),
|
|
|
+
|
|
|
+ totalFavorited: Number(author?.totalFavorited || 0),
|
|
|
+
|
|
|
+ awemeCount: Number(author?.awemeCount || 0),
|
|
|
+
|
|
|
+ works: Array.isArray(author?.works)
|
|
|
+
|
|
|
+ ? author.works.map((work: any) => ({
|
|
|
+
|
|
|
+ id: String(work?.id || ''),
|
|
|
+
|
|
|
+ desc: String(work?.desc || ''),
|
|
|
+
|
|
|
+ coverUrl: String(work?.coverUrl || this.getVideoCoverFallback()),
|
|
|
+
|
|
|
+ coverCandidates: Array.isArray(work?.coverCandidates) ? work.coverCandidates : [],
|
|
|
+
|
|
|
+ duration: Number(work?.duration || 0),
|
|
|
+
|
|
|
+ createTime: work?.createTime ? new Date(work.createTime) : undefined,
|
|
|
+
|
|
|
+ type: work?.type === 'image' ? 'image' : 'video',
|
|
|
+
|
|
|
+ statistics: {
|
|
|
+
|
|
|
+ play_count: Number(work?.statistics?.play_count || 0),
|
|
|
+
|
|
|
+ digg_count: Number(work?.statistics?.digg_count || 0),
|
|
|
+
|
|
|
+ comment_count: Number(work?.statistics?.comment_count || 0),
|
|
|
+
|
|
|
+ share_count: Number(work?.statistics?.share_count || 0)
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ isDownloading: false,
|
|
|
+
|
|
|
+ downloadProgress: 0
|
|
|
+
|
|
|
+ }))
|
|
|
+
|
|
|
+ : [],
|
|
|
+
|
|
|
+ worksCursor: String(author?.worksCursor || '0'),
|
|
|
+
|
|
|
+ hasMoreWorks: !!author?.hasMoreWorks,
|
|
|
+
|
|
|
+ loadingWorks: false
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 加载更多搜索结果
|
|
|
+
|
|
|
+ loadMore(): void {
|
|
|
+
|
|
|
+ if (!this.hasMore || this.isSearching) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.isSearching = true;
|
|
|
+
|
|
|
+ this.douyinService.searchVideos(
|
|
|
+
|
|
|
+ this.searchKeyword, '0', this.searchCursor, '0',
|
|
|
+
|
|
|
+ '0', '1', this.searchId, this.searchBacktrace
|
|
|
+
|
|
|
+ ).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ this.isSearching = false;
|
|
|
+
|
|
|
+ const parsed = this.parseSearchResponse(res);
|
|
|
+
|
|
|
+ const newItems = parsed.items
|
|
|
+
|
|
|
+ .map((item: any) => this.mapVideoItem(item))
|
|
|
+
|
|
|
+ .filter((item: VideoInfo) => !!item.aweme_id || !!item.desc || !!item.cover_url);
|
|
|
+
|
|
|
+ this.searchResults.push(...newItems);
|
|
|
+
|
|
|
+ this.applySearchFilters();
|
|
|
+
|
|
|
+ this.hasMore = parsed.hasMore;
|
|
|
+
|
|
|
+ this.searchCursor = parsed.cursor;
|
|
|
+
|
|
|
+ this.searchId = parsed.searchId || this.searchId;
|
|
|
+
|
|
|
+ this.searchBacktrace = parsed.backtrace || this.searchBacktrace;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ console.error('加载更多失败:', error);
|
|
|
+
|
|
|
+ this.isSearching = false;
|
|
|
+
|
|
|
+ let errorMessage = '加载更多失败,请稍后重试';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (error.status === 401) {
|
|
|
+
|
|
|
+ errorMessage = '认证失败,Token可能已失效';
|
|
|
+
|
|
|
+ } else if (error.status === 429) {
|
|
|
+
|
|
|
+ errorMessage = '请求过于频繁,请稍后重试';
|
|
|
+
|
|
|
+ } else if (error.status === 0) {
|
|
|
+
|
|
|
+ errorMessage = '网络连接失败,请检查网络设置';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ alert(errorMessage);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 查看视频详情
|
|
|
+
|
|
|
+ viewVideoDetail(video: VideoInfo): void {
|
|
|
+
|
|
|
+ this.openSearchVideoPreview(video, null, '', true);
|
|
|
+
|
|
|
+ this.douyinService.getVideoDetail(video.aweme_id).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ const detailData = this.douyinService.extractVideoDetailData(res) || {};
|
|
|
+
|
|
|
+ this.openSearchVideoPreview(video, detailData, this.douyinService.extractVideoPreviewUrl(res) || '', false);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ console.error('获取视频详情失败:', error);
|
|
|
+
|
|
|
+ this.openSearchVideoPreview(video, null, '', false);
|
|
|
+
|
|
|
+ this.showToast('⚠️ 获取实时详情失败,已展示基础信息', 'warn');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private openSearchVideoPreview(video: VideoInfo, detailData: any, previewUrl: string, loading: boolean = false): void {
|
|
|
+
|
|
|
+ const info = detailData || {};
|
|
|
+
|
|
|
+ const tags = this.extractVideoTags(info, video.text_extra || []);
|
|
|
+
|
|
|
+ const rawDuration = info.video?.duration || info.duration || video.duration || 0;
|
|
|
+
|
|
|
+ const duration = Math.floor(rawDuration / (rawDuration > 1000 ? 1000 : 1));
|
|
|
+
|
|
|
+ const coverCandidates = detailData ? this.buildCoverCandidates(info) : [];
|
|
|
+
|
|
|
+ const poster = coverCandidates[0] || video.cover_url || this.getVideoCoverFallback();
|
|
|
+
|
|
|
+ const createTime = info.create_time ? new Date(info.create_time * 1000) : undefined;
|
|
|
+
|
|
|
+ const filename = this.buildVideoFilename(video);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.previewVideoUrl = previewUrl ? this.buildVideoProxyUrl(previewUrl, filename) : '';
|
|
|
+
|
|
|
+ this.previewDownloadUrl = '';
|
|
|
+
|
|
|
+ this.previewVideoTitle = info.desc || video.desc || video.aweme_id;
|
|
|
+
|
|
|
+ this.previewSourceVideo = video;
|
|
|
+
|
|
|
+ this.previewVideoInfo = {
|
|
|
+
|
|
|
+ duration: duration || video.duration || 0,
|
|
|
+
|
|
|
+ resolution: info.video?.ratio || '',
|
|
|
+
|
|
|
+ format: 'mp4',
|
|
|
+
|
|
|
+ source: 'douyin',
|
|
|
+
|
|
|
+ description: info.desc || video.desc || '',
|
|
|
+
|
|
|
+ tags,
|
|
|
+
|
|
|
+ created_at: createTime,
|
|
|
+
|
|
|
+ poster,
|
|
|
+
|
|
|
+ authorName: info.author?.nickname || video.author.nickname || '',
|
|
|
+
|
|
|
+ playCount: info.statistics?.play_count ?? video.statistics.play_count,
|
|
|
+
|
|
|
+ diggCount: info.statistics?.digg_count ?? video.statistics.digg_count,
|
|
|
+
|
|
|
+ commentCount: info.statistics?.comment_count ?? video.statistics.comment_count,
|
|
|
+
|
|
|
+ shareCount: info.statistics?.share_count ?? video.statistics.share_count,
|
|
|
+
|
|
|
+ loading
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.showPreviewModal = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private extractVideoTags(info: any, fallbackTags: string[] = []): string[] {
|
|
|
+
|
|
|
+ const textExtraTags = Array.isArray(info?.text_extra)
|
|
|
+
|
|
|
+ ? info.text_extra
|
|
|
+
|
|
|
+ .map((tag: any) => tag?.hashtag_name || tag?.cha_name || tag?.tag_name || tag?.hash_tag_name || tag)
|
|
|
+
|
|
|
+ .filter((tag: any) => typeof tag === 'string' && tag.trim())
|
|
|
+
|
|
|
+ : [];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const chaListTags = Array.isArray(info?.cha_list)
|
|
|
+
|
|
|
+ ? info.cha_list
|
|
|
+
|
|
|
+ .map((tag: any) => tag?.cha_name || tag?.hashtag_name || tag?.tag_name || tag)
|
|
|
+
|
|
|
+ .filter((tag: any) => typeof tag === 'string' && tag.trim())
|
|
|
+
|
|
|
+ : [];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return [...textExtraTags, ...chaListTags, ...fallbackTags].filter((tag, index, list) => !!tag && list.indexOf(tag) === index);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildVideoFilename(video: VideoInfo): string {
|
|
|
+
|
|
|
+ return `${video.author.nickname}_${video.desc || video.aweme_id}`
|
|
|
+
|
|
|
+ .replace(/[<>:"/\\|?*]/g, '_')
|
|
|
+
|
|
|
+ .substring(0, 60) + '.mp4';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildVideoProxyUrl(videoUrl: string, filename: string, download: boolean = false): string {
|
|
|
+
|
|
|
+ const params = new URLSearchParams({
|
|
|
+
|
|
|
+ url: videoUrl,
|
|
|
+
|
|
|
+ filename,
|
|
|
+
|
|
|
+ download: download ? '1' : '0'
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return `/backend/api/video-proxy?${params.toString()}`;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 下载抖音视频到本地
|
|
|
+
|
|
|
+ downloadVideo(video: VideoInfo): void {
|
|
|
+
|
|
|
+ if (video.isDownloading) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ video.isDownloading = true;
|
|
|
+
|
|
|
+ video.downloadProgress = 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 先获取视频详情拿到下载地址
|
|
|
+
|
|
|
+ this.douyinService.getVideoDetail(video.aweme_id).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ const detailData = this.douyinService.extractVideoDetailData(res) || {};
|
|
|
+
|
|
|
+ const downloadUrls = this.douyinService.extractVideoDownloadUrls(res);
|
|
|
+
|
|
|
+ const downloadUrl = downloadUrls[0] || '';
|
|
|
+
|
|
|
+ if (!downloadUrl) {
|
|
|
+
|
|
|
+ console.warn('未找到可用下载地址:', detailData);
|
|
|
+
|
|
|
+ video.isDownloading = false;
|
|
|
+
|
|
|
+ this.showToast('❌ 无法获取下载地址,请稍后重试', 'error');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const filename = this.buildVideoFilename(video);
|
|
|
+
|
|
|
+ this.douyinService.startManagedDownload({
|
|
|
+
|
|
|
+ url: downloadUrl,
|
|
|
+
|
|
|
+ urls: downloadUrls,
|
|
|
+
|
|
|
+ filename,
|
|
|
+
|
|
|
+ title: video.desc || video.aweme_id,
|
|
|
+
|
|
|
+ description: detailData?.desc || video.desc || '',
|
|
|
+
|
|
|
+ tags: video.text_extra || [],
|
|
|
+
|
|
|
+ thumbnail: video.cover_url || '',
|
|
|
+
|
|
|
+ duration: video.duration || 0,
|
|
|
+
|
|
|
+ resolution: detailData?.video?.ratio || '',
|
|
|
+
|
|
|
+ awemeId: video.aweme_id,
|
|
|
+
|
|
|
+ authorName: video.author.nickname || ''
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (taskRes: any) => {
|
|
|
+
|
|
|
+ if (!taskRes?.taskId) {
|
|
|
+
|
|
|
+ video.isDownloading = false;
|
|
|
+
|
|
|
+ video.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast('❌ 下载任务创建失败,请稍后重试', 'error');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.showToast(`📥 正在下载「${video.desc || video.aweme_id}」到视频库`, 'info');
|
|
|
+
|
|
|
+ this.pollManagedDownload(video, taskRes.taskId);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ console.error('创建下载任务失败:', error);
|
|
|
+
|
|
|
+ video.isDownloading = false;
|
|
|
+
|
|
|
+ video.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast(`❌ 创建下载任务失败:${error?.error?.error || error?.message || '请稍后重试'}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ console.error('获取视频信息失败:', error);
|
|
|
+
|
|
|
+ video.isDownloading = false;
|
|
|
+
|
|
|
+ this.showToast(`❌ 获取视频信息失败:${error?.error?.error || error?.message || '请稍后重试'}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ downloadPreviewVideo(): void {
|
|
|
+
|
|
|
+ if (!this.previewSourceVideo) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.downloadVideo(this.previewSourceVideo);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private pollManagedDownload(video: VideoInfo, taskId: string, retryCount: number = 0): void {
|
|
|
+
|
|
|
+ this.douyinService.getManagedDownloadTask(taskId).subscribe({
|
|
|
+
|
|
|
+ next: (task: any) => {
|
|
|
+
|
|
|
+ video.downloadProgress = task?.progress || 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (task?.status === 'completed') {
|
|
|
+
|
|
|
+ video.isDownloading = false;
|
|
|
+
|
|
|
+ video.downloadProgress = 100;
|
|
|
+
|
|
|
+ this.loadVideoManifest();
|
|
|
+
|
|
|
+ this.showToast(`✅ 视频「${video.desc || video.aweme_id}」已下载到本地视频库`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (task?.status === 'failed') {
|
|
|
+
|
|
|
+ video.isDownloading = false;
|
|
|
+
|
|
|
+ video.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast(`❌ 下载失败:${task?.error || '请稍后重试'}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ setTimeout(() => this.pollManagedDownload(video, taskId), 1000);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (error: any) => {
|
|
|
+
|
|
|
+ console.error('查询下载任务失败:', error);
|
|
|
+
|
|
|
+ if (retryCount < 3) {
|
|
|
+
|
|
|
+ setTimeout(() => this.pollManagedDownload(video, taskId, retryCount + 1), 1500);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ video.isDownloading = false;
|
|
|
+
|
|
|
+ video.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast('❌ 下载状态查询失败,请稍后重试', 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private pollMonitorWorkDownload(work: MonitorWork, taskId: string, retryCount: number = 0): void {
|
|
|
+
|
|
|
+ this.douyinService.getManagedDownloadTask(taskId).subscribe({
|
|
|
+
|
|
|
+ next: (task: any) => {
|
|
|
+
|
|
|
+ work.downloadProgress = task?.progress || 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (task?.status === 'completed') {
|
|
|
+
|
|
|
+ work.isDownloading = false;
|
|
|
+
|
|
|
+ work.downloadProgress = 100;
|
|
|
+
|
|
|
+ this.loadVideoManifest();
|
|
|
+
|
|
|
+ this.showToast(`✅ 视频「${work.desc || work.id}」已下载到本地视频库`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (task?.status === 'failed') {
|
|
|
+
|
|
|
+ work.isDownloading = false;
|
|
|
+
|
|
|
+ work.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast(`❌ 下载失败:${task?.error || '请稍后重试'}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ setTimeout(() => this.pollMonitorWorkDownload(work, taskId), 1000);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ if (retryCount < 3) {
|
|
|
+
|
|
|
+ setTimeout(() => this.pollMonitorWorkDownload(work, taskId, retryCount + 1), 1500);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ work.isDownloading = false;
|
|
|
+
|
|
|
+ work.downloadProgress = 0;
|
|
|
+
|
|
|
+ this.showToast('❌ 下载状态查询失败,请稍后重试', 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildMonitorVideoInfo(author: MonitorAuthor, work: MonitorWork): VideoInfo {
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ aweme_id: work.id,
|
|
|
+
|
|
|
+ desc: work.desc,
|
|
|
+
|
|
|
+ cover_url: work.coverUrl,
|
|
|
+
|
|
|
+ cover_candidates: work.coverCandidates,
|
|
|
+
|
|
|
+ duration: work.duration,
|
|
|
+
|
|
|
+ create_time: work.createTime ? Math.floor(work.createTime.getTime() / 1000) : 0,
|
|
|
+
|
|
|
+ author: {
|
|
|
+
|
|
|
+ nickname: author.nickname,
|
|
|
+
|
|
|
+ sec_uid: author.secUserId
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ statistics: {
|
|
|
+
|
|
|
+ play_count: 0,
|
|
|
+
|
|
|
+ digg_count: work.statistics.digg_count,
|
|
|
+
|
|
|
+ comment_count: work.statistics.comment_count,
|
|
|
+
|
|
|
+ share_count: work.statistics.share_count
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ text_extra: [],
|
|
|
+
|
|
|
+ downloadProgress: work.downloadProgress,
|
|
|
+
|
|
|
+ isDownloading: work.isDownloading
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 开始AI生成
|
|
|
+
|
|
|
+ startAIGeneration(video: VideoInfo): void {
|
|
|
+
|
|
|
+ console.log('开始AI生成:', video);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 创建新任务
|
|
|
+
|
|
|
+ const newTask: Task = {
|
|
|
+
|
|
|
+ id: `TASK-${Date.now()}`,
|
|
|
+
|
|
|
+ keyword: this.searchKeyword,
|
|
|
+
|
|
|
+ status: 'pending',
|
|
|
+
|
|
|
+ step: 'search',
|
|
|
+
|
|
|
+ progress: 0,
|
|
|
+
|
|
|
+ cost: 0,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ updated_at: new Date(),
|
|
|
+
|
|
|
+ original_video_title: video.desc
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.activeTasks.unshift(newTask);
|
|
|
+
|
|
|
+ this.setCurrentTab('tasks');
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 模拟任务进度更新
|
|
|
+
|
|
|
+ this.simulateTaskProgress(newTask.id);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 模拟任务进度
|
|
|
+
|
|
|
+ private simulateTaskProgress(taskId: string): void {
|
|
|
+
|
|
|
+ const task = this.activeTasks.find(t => t.id === taskId);
|
|
|
+
|
|
|
+ if (!task) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const steps: Task['step'][] = ['search', 'detail', 'download', 'transcribe', 'generate'];
|
|
|
+
|
|
|
+ let currentStepIndex = 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const interval = setInterval(() => {
|
|
|
+
|
|
|
+ if (currentStepIndex < steps.length) {
|
|
|
+
|
|
|
+ task.step = steps[currentStepIndex];
|
|
|
+
|
|
|
+ task.progress = Math.min((currentStepIndex + 1) * 20, 100);
|
|
|
+
|
|
|
+ task.status = currentStepIndex === steps.length - 1 ? 'completed' : 'processing';
|
|
|
+
|
|
|
+ task.updated_at = new Date();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ currentStepIndex++;
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ task.status = 'completed';
|
|
|
+
|
|
|
+ task.progress = 100;
|
|
|
+
|
|
|
+ clearInterval(interval);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 添加到生成结果
|
|
|
+
|
|
|
+ this.generatedResults.unshift({
|
|
|
+
|
|
|
+ id: `RESULT-${Date.now()}`,
|
|
|
+
|
|
|
+ type: 'video',
|
|
|
+
|
|
|
+ url: 'https://example.com/generated-video.mp4',
|
|
|
+
|
|
|
+ title: `AI重塑 - ${task.keyword}`,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ quality: '1080P',
|
|
|
+
|
|
|
+ duration: '2分钟',
|
|
|
+
|
|
|
+ cost: 2.5
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }, 2000);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取任务状态颜色
|
|
|
+
|
|
|
+ getTaskStatusColor(status: string): string {
|
|
|
+
|
|
|
+ switch (status) {
|
|
|
+
|
|
|
+ case 'pending': return 'warn';
|
|
|
+
|
|
|
+ case 'processing': return 'accent';
|
|
|
+
|
|
|
+ case 'completed': return 'primary';
|
|
|
+
|
|
|
+ case 'failed': return 'warn';
|
|
|
+
|
|
|
+ default: return '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取任务状态文本
|
|
|
+
|
|
|
+ getTaskStatusText(status: string): string {
|
|
|
+
|
|
|
+ switch (status) {
|
|
|
+
|
|
|
+ case 'pending': return '等待中';
|
|
|
+
|
|
|
+ case 'processing': return '处理中';
|
|
|
+
|
|
|
+ case 'completed': return '已完成';
|
|
|
+
|
|
|
+ case 'failed': return '失败';
|
|
|
+
|
|
|
+ case 'timeout': return '超时';
|
|
|
+
|
|
|
+ default: return status;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取任务步骤文本
|
|
|
+
|
|
|
+ getTaskStepText(step: string): string {
|
|
|
+
|
|
|
+ switch (step) {
|
|
|
+
|
|
|
+ case 'search': return '视频搜索';
|
|
|
+
|
|
|
+ case 'detail': return '详情获取';
|
|
|
+
|
|
|
+ case 'download': return '视频下载';
|
|
|
+
|
|
|
+ case 'transcribe': return '语音转文字';
|
|
|
+
|
|
|
+ case 'generate': return 'AI生成';
|
|
|
+
|
|
|
+ case 'complete': return '完成';
|
|
|
+
|
|
|
+ case 'vg-upload': return '上传视频';
|
|
|
+
|
|
|
+ case 'vg-analyze': return '视频解析';
|
|
|
+
|
|
|
+ case 'vg-script': return '脚本生成';
|
|
|
+
|
|
|
+ case 'vg-image': return '配图生成';
|
|
|
+
|
|
|
+ case 'vg-voice': return '语音配音';
|
|
|
+
|
|
|
+ case 'vg-composite': return '视频合成';
|
|
|
+
|
|
|
+ default: return step;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 判断步骤是否完成
|
|
|
+
|
|
|
+ isStepCompleted(currentStep: string, targetStep: string): boolean {
|
|
|
+
|
|
|
+ const steps = ['search', 'detail', 'download', 'transcribe', 'generate'];
|
|
|
+
|
|
|
+ const currentIndex = steps.indexOf(currentStep);
|
|
|
+
|
|
|
+ const targetIndex = steps.indexOf(targetStep);
|
|
|
+
|
|
|
+ return currentIndex > targetIndex;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 查看任务详情
|
|
|
+
|
|
|
+ viewTaskDetail(task: Task): void {
|
|
|
+
|
|
|
+ console.log('查看任务详情:', task);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 已被用户取消的任务 ID 集合:用于中止后续 vg 流程更新进度时覆盖回 processing 状态
|
|
|
+
|
|
|
+ private canceledTaskIds: Set<string> = new Set<string>();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 取消任务(实际生效:标记为 failed 并阻止后续 vg 进度回写;UI 立即刷新)
|
|
|
+
|
|
|
+ cancelTask(task: Task): void {
|
|
|
+
|
|
|
+ if (!task) return;
|
|
|
+
|
|
|
+ task.status = 'failed';
|
|
|
+
|
|
|
+ task.error_message = '用户已取消';
|
|
|
+
|
|
|
+ task.updated_at = new Date();
|
|
|
+
|
|
|
+ if (task.id) this.canceledTaskIds.add(task.id);
|
|
|
+
|
|
|
+ // 若取消的是当前进行中的视频生成任务,清空 vg 运行状态,防止继续触发新请求
|
|
|
+
|
|
|
+ if (task.type === 'video-generation' && this.vgTaskId === task.id) {
|
|
|
+
|
|
|
+ this.vgUploading = false;
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.vgGeneratingScript = false;
|
|
|
+
|
|
|
+ this.vgGeneratingImages = false;
|
|
|
+
|
|
|
+ this.vgSynthesizing = false;
|
|
|
+
|
|
|
+ this.vgCompositing = false;
|
|
|
+
|
|
|
+ this.vgError = '已取消';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.updateTask(task);
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.showToast('🛑 任务已取消', 'info');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取状态颜色
|
|
|
+
|
|
|
+ getStatusColor(status: string): string {
|
|
|
+
|
|
|
+ switch (status) {
|
|
|
+
|
|
|
+ case 'completed': return 'primary';
|
|
|
+
|
|
|
+ case 'failed': return 'warn';
|
|
|
+
|
|
|
+ default: return '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取状态文本
|
|
|
+
|
|
|
+ getStatusText(status: string): string {
|
|
|
+
|
|
|
+ switch (status) {
|
|
|
+
|
|
|
+ case 'completed': return '已完成';
|
|
|
+
|
|
|
+ case 'failed': return '失败';
|
|
|
+
|
|
|
+ default: return status;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 查看历史记录详情
|
|
|
+
|
|
|
+ viewHistoryDetail(record: HistoryRecord): void {
|
|
|
+
|
|
|
+ this.historyDetailRecord = record;
|
|
|
+
|
|
|
+ this.historyDetailSegments = [];
|
|
|
+
|
|
|
+ this.historyDetailPrimaryVideoUrl = this.findHistoryPrimaryVideoUrl(record);
|
|
|
+
|
|
|
+ this.historyDetailLoading = !!record.remixId;
|
|
|
+
|
|
|
+ this.showHistoryDetailModal = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const finalizeHistoryDetail = (remix: any = null) => {
|
|
|
+
|
|
|
+ this.historyDetailSegments = this.buildHistoryDetailSegments(record, remix);
|
|
|
+
|
|
|
+ this.historyDetailPrimaryVideoUrl = this.findHistoryPrimaryVideoUrl(record, remix);
|
|
|
+
|
|
|
+ this.historyDetailLoading = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (record.remixId && record.videoId) {
|
|
|
+
|
|
|
+ // 从 remix 数据加载片段详情
|
|
|
+
|
|
|
+ this.http.get<any[]>(`/backend/api/remixes/${record.videoId}`).subscribe({
|
|
|
+
|
|
|
+ next: (remixes) => {
|
|
|
+
|
|
|
+ finalizeHistoryDetail(remixes.find(r => r.remixId === record.remixId));
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ finalizeHistoryDetail();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else if (record.remixId) {
|
|
|
+
|
|
|
+ // 尝试从所有 remix 数据中查找
|
|
|
+
|
|
|
+ this.http.get<Record<string, any[]>>('/backend/api/remixes').subscribe({
|
|
|
+
|
|
|
+ next: (allRemixes) => {
|
|
|
+
|
|
|
+ let matchedRemix: any = null;
|
|
|
+
|
|
|
+ for (const remixes of Object.values(allRemixes)) {
|
|
|
+
|
|
|
+ const remix = remixes.find(r => r.remixId === record.remixId);
|
|
|
+
|
|
|
+ if (remix) {
|
|
|
+
|
|
|
+ matchedRemix = remix;
|
|
|
+
|
|
|
+ break;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ finalizeHistoryDetail(matchedRemix);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ finalizeHistoryDetail();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ finalizeHistoryDetail();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ closeHistoryDetail(): void {
|
|
|
+
|
|
|
+ this.showHistoryDetailModal = false;
|
|
|
+
|
|
|
+ this.historyDetailRecord = null;
|
|
|
+
|
|
|
+ this.historyDetailSegments = [];
|
|
|
+
|
|
|
+ this.historyDetailPrimaryVideoUrl = '';
|
|
|
+
|
|
|
+ this.historyDetailLoading = false;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private findGeneratedResultForHistory(record: HistoryRecord): GeneratedResult | undefined {
|
|
|
+
|
|
|
+ return this.generatedResults.find((result) => {
|
|
|
+
|
|
|
+ if (record.remixId && result.remixId === record.remixId) return true;
|
|
|
+
|
|
|
+ if (record.resultUrl && result.url === record.resultUrl) return true;
|
|
|
+
|
|
|
+ if (record.videoId && result.videoId === record.videoId && record.styleName) {
|
|
|
+
|
|
|
+ return result.title?.includes(record.styleName);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private findHistoryPrimaryVideoUrl(record: HistoryRecord, remix?: any): string {
|
|
|
+
|
|
|
+ const result = this.findGeneratedResultForHistory(record);
|
|
|
+
|
|
|
+ return record.resultUrl
|
|
|
+
|
|
|
+ || remix?.stitchUrl
|
|
|
+
|
|
|
+ || result?.url
|
|
|
+
|
|
|
+ || remix?.segments?.find((segment: any) => segment?.videoUrl)?.videoUrl
|
|
|
+
|
|
|
+ || result?.segments?.find((segment: any) => segment?.videoUrl)?.videoUrl
|
|
|
+
|
|
|
+ || '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildHistoryDetailSegments(record: HistoryRecord, remix?: any): { id: string; videoUrl: string; prompt: string; status: string; narration?: string }[] {
|
|
|
+
|
|
|
+ const remixSegments = Array.isArray(remix?.segments) ? remix.segments : [];
|
|
|
+
|
|
|
+ const resultSegments = Array.isArray(this.findGeneratedResultForHistory(record)?.segments)
|
|
|
+
|
|
|
+ ? this.findGeneratedResultForHistory(record)?.segments || []
|
|
|
+
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const sourceSegments = remixSegments.length > 0 ? remixSegments : resultSegments;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return sourceSegments.map((segment: any, index: number) => ({
|
|
|
+
|
|
|
+ id: segment?.id || `SEG-${index + 1}`,
|
|
|
+
|
|
|
+ videoUrl: segment?.videoUrl || '',
|
|
|
+
|
|
|
+ prompt: segment?.prompt || '',
|
|
|
+
|
|
|
+ status: segment?.status || 'completed',
|
|
|
+
|
|
|
+ narration: segment?.narration || segment?.prompt || ''
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 重新生成任务
|
|
|
+
|
|
|
+ regenerateTask(record: HistoryRecord): void {
|
|
|
+
|
|
|
+ console.log('重新生成任务:', record);
|
|
|
+
|
|
|
+ this.searchKeyword = record.keyword;
|
|
|
+
|
|
|
+ this.setCurrentTab('search');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 导出历史记录
|
|
|
+
|
|
|
+ exportHistory(): void {
|
|
|
+
|
|
|
+ const dataStr = JSON.stringify(this.historyRecords, null, 2);
|
|
|
+
|
|
|
+ const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const link = document.createElement('a');
|
|
|
+
|
|
|
+ link.href = URL.createObjectURL(dataBlob);
|
|
|
+
|
|
|
+ link.download = `history-${Date.now()}.json`;
|
|
|
+
|
|
|
+ link.click();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 预览结果
|
|
|
+
|
|
|
+ previewResult(result: GeneratedResult): void {
|
|
|
+
|
|
|
+ if (result.type === 'video') {
|
|
|
+
|
|
|
+ this.previewVideoUrl = result.url;
|
|
|
+
|
|
|
+ this.previewDownloadUrl = result.url;
|
|
|
+
|
|
|
+ this.previewVideoTitle = result.title;
|
|
|
+
|
|
|
+ this.previewSourceVideo = null;
|
|
|
+
|
|
|
+ this.previewVideoInfo = {
|
|
|
+
|
|
|
+ quality: result.quality,
|
|
|
+
|
|
|
+ duration: result.duration,
|
|
|
+
|
|
|
+ cost: result.cost,
|
|
|
+
|
|
|
+ created_at: result.created_at
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.showPreviewModal = true;
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ window.open(result.url, '_blank');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 下载结果
|
|
|
+
|
|
|
+ downloadResult(result: GeneratedResult): void {
|
|
|
+
|
|
|
+ console.log('下载结果:', result);
|
|
|
+
|
|
|
+ const link = document.createElement('a');
|
|
|
+
|
|
|
+ link.href = result.url;
|
|
|
+
|
|
|
+ link.download = `${result.title}.${result.type === 'video' ? 'mp4' : 'jpg'}`;
|
|
|
+
|
|
|
+ link.click();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 分享结果
|
|
|
+
|
|
|
+ shareResult(result: GeneratedResult): void {
|
|
|
+
|
|
|
+ if (navigator.share) {
|
|
|
+
|
|
|
+ navigator.share({
|
|
|
+
|
|
|
+ title: result.title,
|
|
|
+
|
|
|
+ text: '查看这个由AI生成的精彩内容!',
|
|
|
+
|
|
|
+ url: result.url
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ navigator.clipboard.writeText(result.url).then(() => {
|
|
|
+
|
|
|
+ alert('链接已复制到剪贴板');
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频管理相关方法
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取过滤后的视频列表
|
|
|
+
|
|
|
+ getFilteredVideos(): ManagedVideo[] {
|
|
|
+
|
|
|
+ return this.managedVideos.filter(video => {
|
|
|
+
|
|
|
+ const matchesCategory = !this.videoFilter.category || video.category === this.videoFilter.category;
|
|
|
+
|
|
|
+ const matchesSource = !this.videoFilter.source || video.source === this.videoFilter.source;
|
|
|
+
|
|
|
+ const matchesSearch = !this.videoFilter.searchTerm ||
|
|
|
+
|
|
|
+ video.title.toLowerCase().includes(this.videoFilter.searchTerm.toLowerCase()) ||
|
|
|
+
|
|
|
+ video.description.toLowerCase().includes(this.videoFilter.searchTerm.toLowerCase()) ||
|
|
|
+
|
|
|
+ video.tags.some(tag => tag.toLowerCase().includes(this.videoFilter.searchTerm.toLowerCase()));
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return matchesCategory && matchesSource && matchesSearch;
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 切换视频选择
|
|
|
+
|
|
|
+ toggleVideoSelection(videoId: string): void {
|
|
|
+
|
|
|
+ if (this.selectedVideos.has(videoId)) {
|
|
|
+
|
|
|
+ this.selectedVideos.delete(videoId);
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.selectedVideos.add(videoId);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取视频可播放 URL(用于封面截帧等)
|
|
|
+
|
|
|
+ getVideoPlayUrl(video: ManagedVideo): string {
|
|
|
+
|
|
|
+ if (!video) return '';
|
|
|
+
|
|
|
+ // 远程 URL(生成的视频)
|
|
|
+
|
|
|
+ if (video.filepath && /^https?:\/\//.test(video.filepath)) return video.filepath;
|
|
|
+
|
|
|
+ // 本地文件
|
|
|
+
|
|
|
+ if (video.filepath) return video.filepath;
|
|
|
+
|
|
|
+ if (video.filename) return `/backend/api/video/${video.filename}`;
|
|
|
+
|
|
|
+ return '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取来源文本
|
|
|
+
|
|
|
+ getSourceText(source: string): string {
|
|
|
+
|
|
|
+ switch (source) {
|
|
|
+
|
|
|
+ case 'downloaded': return '下载';
|
|
|
+
|
|
|
+ case 'generated': return 'AI生成';
|
|
|
+
|
|
|
+ case 'uploaded': return '上传';
|
|
|
+
|
|
|
+ default: return source;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取分类名称
|
|
|
+
|
|
|
+ getCategoryName(categoryId: string): string {
|
|
|
+
|
|
|
+ const category = this.videoCategories.find(cat => cat.id === categoryId);
|
|
|
+
|
|
|
+ return category ? category.name : '未分类';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取分类颜色
|
|
|
+
|
|
|
+ getCategoryColor(categoryId: string): string {
|
|
|
+
|
|
|
+ const category = this.videoCategories.find(cat => cat.id === categoryId);
|
|
|
+
|
|
|
+ return category ? category.color : '#757575';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 预览视频
|
|
|
+
|
|
|
+ previewVideo(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ this.previewVideoUrl = video.filepath;
|
|
|
+
|
|
|
+ this.previewDownloadUrl = video.filepath;
|
|
|
+
|
|
|
+ this.previewVideoTitle = video.title;
|
|
|
+
|
|
|
+ this.previewSourceVideo = null;
|
|
|
+
|
|
|
+ this.previewVideoInfo = {
|
|
|
+
|
|
|
+ filename: video.filename,
|
|
|
+
|
|
|
+ size: video.size,
|
|
|
+
|
|
|
+ duration: video.duration,
|
|
|
+
|
|
|
+ resolution: video.metadata?.resolution || '',
|
|
|
+
|
|
|
+ format: video.metadata?.format || 'mp4',
|
|
|
+
|
|
|
+ source: video.source,
|
|
|
+
|
|
|
+ description: video.description,
|
|
|
+
|
|
|
+ tags: video.tags,
|
|
|
+
|
|
|
+ created_at: video.created_at
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.showPreviewModal = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 关闭预览
|
|
|
+
|
|
|
+ closePreview(): void {
|
|
|
+
|
|
|
+ this.showPreviewModal = false;
|
|
|
+
|
|
|
+ this.previewVideoUrl = '';
|
|
|
+
|
|
|
+ this.previewDownloadUrl = '';
|
|
|
+
|
|
|
+ this.previewVideoTitle = '';
|
|
|
+
|
|
|
+ this.previewVideoInfo = null;
|
|
|
+
|
|
|
+ this.previewSourceVideo = null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 编辑视频
|
|
|
+
|
|
|
+ editVideo(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ console.log('编辑视频:', video);
|
|
|
+
|
|
|
+ // 这里可以打开视频编辑模态框
|
|
|
+
|
|
|
+ const newTitle = prompt('编辑视频标题:', video.title);
|
|
|
+
|
|
|
+ if (newTitle && newTitle !== video.title) {
|
|
|
+
|
|
|
+ video.title = newTitle;
|
|
|
+
|
|
|
+ video.modified_at = new Date();
|
|
|
+
|
|
|
+ console.log('视频标题已更新');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 删除单个视频
|
|
|
+
|
|
|
+ deleteVideo(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ if (confirm(`确定要删除视频「${video.title}」吗?`)) {
|
|
|
+
|
|
|
+ this.http.delete<any>(`/backend/api/manifest/${video.id}`).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ this.managedVideos = this.managedVideos.filter(v => v.id !== video.id);
|
|
|
+
|
|
|
+ this.selectedVideos.delete(video.id);
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+ this.showToast(`🗑️ 视频「${video.title}」已删除`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ console.log('视频已删除:', video.title);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ const message = err?.error?.error || '删除视频失败';
|
|
|
+
|
|
|
+ this.showToast(`❌ ${message}`, 'error');
|
|
|
+
|
|
|
+ console.warn('⚠️ 删除视频失败:', err);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 批量删除视频
|
|
|
+
|
|
|
+ batchDelete(): void {
|
|
|
+
|
|
|
+ if (this.selectedVideos.size === 0) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (confirm(`确定要删除选中的 ${this.selectedVideos.size} 个视频吗?`)) {
|
|
|
+
|
|
|
+ const selectedIds = Array.from(this.selectedVideos);
|
|
|
+
|
|
|
+ const selectedVideos = this.managedVideos.filter(v => this.selectedVideos.has(v.id));
|
|
|
+
|
|
|
+ let completed = 0;
|
|
|
+
|
|
|
+ let successCount = 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ selectedVideos.forEach((video) => {
|
|
|
+
|
|
|
+ this.http.delete<any>(`/backend/api/manifest/${video.id}`).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ successCount += 1;
|
|
|
+
|
|
|
+ this.managedVideos = this.managedVideos.filter(v => v.id !== video.id);
|
|
|
+
|
|
|
+ this.selectedVideos.delete(video.id);
|
|
|
+
|
|
|
+ completed += 1;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (completed === selectedIds.length) {
|
|
|
+
|
|
|
+ this.isSelectionMode = false;
|
|
|
+
|
|
|
+ this.selectedVideos.clear();
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+ this.showToast(`🗑️ 已删除 ${successCount} 个视频`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ console.log('批量视频已删除');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ completed += 1;
|
|
|
+
|
|
|
+ console.warn(`⚠️ 删除视频失败: ${video.title}`, err);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (completed === selectedIds.length) {
|
|
|
+
|
|
|
+ this.isSelectionMode = false;
|
|
|
+
|
|
|
+ this.selectedVideos.clear();
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+ if (successCount > 0) {
|
|
|
+
|
|
|
+ this.showToast(`⚠️ 部分删除成功,已删除 ${successCount}/${selectedIds.length} 个视频`, 'warn');
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ const message = err?.error?.error || '批量删除失败';
|
|
|
+
|
|
|
+ this.showToast(`❌ ${message}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 批量导出视频
|
|
|
+
|
|
|
+ batchExport(): void {
|
|
|
+
|
|
|
+ if (this.selectedVideos.size === 0) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const selectedVideos = this.managedVideos.filter(v => this.selectedVideos.has(v.id));
|
|
|
+
|
|
|
+ console.log('批量导出视频:', selectedVideos.map(v => v.title));
|
|
|
+
|
|
|
+ alert(`正在导出 ${selectedVideos.length} 个视频...`);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 批量分类
|
|
|
+
|
|
|
+ batchCategorize(): void {
|
|
|
+
|
|
|
+ if (this.selectedVideos.size === 0) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const categoryId = prompt(`选择分类:\n${this.videoCategories.map((cat, index) =>
|
|
|
+
|
|
|
+ `${index + 1}. ${cat.name}`).join('\n')}`);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (categoryId) {
|
|
|
+
|
|
|
+ const categoryIndex = parseInt(categoryId) - 1;
|
|
|
+
|
|
|
+ if (categoryIndex >= 0 && categoryIndex < this.videoCategories.length) {
|
|
|
+
|
|
|
+ const selectedCategory = this.videoCategories[categoryIndex];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.managedVideos.forEach(video => {
|
|
|
+
|
|
|
+ if (this.selectedVideos.has(video.id)) {
|
|
|
+
|
|
|
+ video.category = selectedCategory.id;
|
|
|
+
|
|
|
+ video.modified_at = new Date();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+ this.selectedVideos.clear();
|
|
|
+
|
|
|
+ this.isSelectionMode = false;
|
|
|
+
|
|
|
+ console.log('批量分类完成');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 更新分类计数
|
|
|
+
|
|
|
+ private updateCategoryCounts(): void {
|
|
|
+
|
|
|
+ this.videoCategories.forEach(category => {
|
|
|
+
|
|
|
+ category.count = this.managedVideos.filter(video => video.source === category.id || video.category === category.id).length;
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== 视频上传 ====================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ openUploadModal(): void {
|
|
|
+
|
|
|
+ this.showUploadModal = true;
|
|
|
+
|
|
|
+ this.uploadTitle = '';
|
|
|
+
|
|
|
+ this.uploadDescription = '';
|
|
|
+
|
|
|
+ this.uploadTags = '';
|
|
|
+
|
|
|
+ this.uploadFile = null;
|
|
|
+
|
|
|
+ this.uploadFileName = '';
|
|
|
+
|
|
|
+ this.uploadProgress = 0;
|
|
|
+
|
|
|
+ this.uploadError = '';
|
|
|
+
|
|
|
+ this.isUploading = false;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ closeUploadModal(): void {
|
|
|
+
|
|
|
+ if (this.isUploading) return;
|
|
|
+
|
|
|
+ this.showUploadModal = false;
|
|
|
+
|
|
|
+ this.uploadFile = null;
|
|
|
+
|
|
|
+ this.uploadFileName = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onFileSelected(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ if (input.files && input.files.length > 0) {
|
|
|
+
|
|
|
+ const file = input.files[0];
|
|
|
+
|
|
|
+ const allowedExts = ['.mp4', '.mov', '.avi', '.webm', '.mkv'];
|
|
|
+
|
|
|
+ const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!allowedExts.includes(ext)) {
|
|
|
+
|
|
|
+ this.uploadError = '不支持的文件格式,仅支持: mp4, mov, avi, webm, mkv';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (file.size > 500 * 1024 * 1024) {
|
|
|
+
|
|
|
+ this.uploadError = '文件大小超过限制(最大 500MB)';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.uploadFile = file;
|
|
|
+
|
|
|
+ this.uploadFileName = file.name;
|
|
|
+
|
|
|
+ this.uploadError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 自动填充标题(去掉扩展名)
|
|
|
+
|
|
|
+ if (!this.uploadTitle) {
|
|
|
+
|
|
|
+ this.uploadTitle = file.name.replace(/\.[^.]+$/, '');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ uploadVideo(): void {
|
|
|
+
|
|
|
+ if (!this.uploadFile || this.isUploading) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.isUploading = true;
|
|
|
+
|
|
|
+ this.uploadProgress = 0;
|
|
|
+
|
|
|
+ this.uploadError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const formData = new FormData();
|
|
|
+
|
|
|
+ formData.append('video', this.uploadFile);
|
|
|
+
|
|
|
+ formData.append('title', this.uploadTitle || this.uploadFile.name.replace(/\.[^.]+$/, ''));
|
|
|
+
|
|
|
+ formData.append('description', this.uploadDescription || '用户上传的视频');
|
|
|
+
|
|
|
+ if (this.uploadTags.trim()) {
|
|
|
+
|
|
|
+ const tagsArr = this.uploadTags.split(/[,,、\s]+/).filter(t => t.trim());
|
|
|
+
|
|
|
+ formData.append('tags', JSON.stringify(tagsArr));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const xhr = new XMLHttpRequest();
|
|
|
+
|
|
|
+ xhr.open('POST', '/backend/api/upload/video', true);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.upload.onprogress = (e) => {
|
|
|
+
|
|
|
+ if (e.lengthComputable) {
|
|
|
+
|
|
|
+ this.ngZone.run(() => {
|
|
|
+
|
|
|
+ this.uploadProgress = Math.round((e.loaded / e.total) * 100);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.onload = () => {
|
|
|
+
|
|
|
+ this.ngZone.run(() => {
|
|
|
+
|
|
|
+ this.isUploading = false;
|
|
|
+
|
|
|
+ if (xhr.status === 200) {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const res = JSON.parse(xhr.responseText);
|
|
|
+
|
|
|
+ if (res.success && res.video) {
|
|
|
+
|
|
|
+ this.uploadProgress = 100;
|
|
|
+
|
|
|
+ const newVideo: ManagedVideo = {
|
|
|
+
|
|
|
+ id: res.video.id,
|
|
|
+
|
|
|
+ title: res.video.title,
|
|
|
+
|
|
|
+ filename: res.video.filename,
|
|
|
+
|
|
|
+ filepath: res.filepath || `/backend/api/video/${res.video.filename}`,
|
|
|
+
|
|
|
+ size: res.video.size,
|
|
|
+
|
|
|
+ duration: res.video.duration || 0,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ modified_at: new Date(),
|
|
|
+
|
|
|
+ category: 'uploaded',
|
|
|
+
|
|
|
+ tags: res.video.tags || [],
|
|
|
+
|
|
|
+ description: res.video.description || '',
|
|
|
+
|
|
|
+ thumbnail: '',
|
|
|
+
|
|
|
+ source: 'uploaded',
|
|
|
+
|
|
|
+ metadata: res.video.metadata || { resolution: '未知', format: 'mp4' }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.managedVideos.unshift(newVideo);
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.showUploadModal = false;
|
|
|
+
|
|
|
+ this.uploadFile = null;
|
|
|
+
|
|
|
+ this.uploadFileName = '';
|
|
|
+
|
|
|
+ this.showToast(`✅ 视频「${newVideo.title}」上传成功!`, 'success');
|
|
|
+
|
|
|
+ console.log('📤 视频上传成功:', newVideo);
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.uploadError = res.error || '上传失败';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch {
|
|
|
+
|
|
|
+ this.uploadError = '解析响应失败';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const errRes = JSON.parse(xhr.responseText);
|
|
|
+
|
|
|
+ this.uploadError = errRes.error || `上传失败 (${xhr.status})`;
|
|
|
+
|
|
|
+ } catch {
|
|
|
+
|
|
|
+ this.uploadError = `上传失败 (HTTP ${xhr.status})`;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.onerror = () => {
|
|
|
+
|
|
|
+ this.ngZone.run(() => {
|
|
|
+
|
|
|
+ this.isUploading = false;
|
|
|
+
|
|
|
+ this.uploadError = '网络错误,请检查后端服务是否运行';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.send(formData);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== AI重塑 — 多步骤流程 ====================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 1: 打开AI重塑模态框
|
|
|
+
|
|
|
+ aiRemixVideo(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ if (video.isRemixing) return;
|
|
|
+
|
|
|
+ this.remixTargetVideo = video;
|
|
|
+
|
|
|
+ this.remixStep = 1;
|
|
|
+
|
|
|
+ this.remixTranscript = '';
|
|
|
+
|
|
|
+ this.remixBeautifiedTranscript = '';
|
|
|
+
|
|
|
+ this.remixStoryboard = [];
|
|
|
+
|
|
|
+ this.remixStyle = 'anime';
|
|
|
+
|
|
|
+ this.remixQuality = '720p';
|
|
|
+
|
|
|
+ this.remixFrames = 121;
|
|
|
+
|
|
|
+ this.remixAspectRatio = '16:9';
|
|
|
+
|
|
|
+ this.remixStatusText = '';
|
|
|
+
|
|
|
+ this.isRemixSubmitting = false;
|
|
|
+
|
|
|
+ this.remixResults = { total: 0, success: 0, failed: 0 };
|
|
|
+
|
|
|
+ this.remixStitchStatus = 'idle';
|
|
|
+
|
|
|
+ this.remixStitchUrl = '';
|
|
|
+
|
|
|
+ this.remixStitchError = '';
|
|
|
+
|
|
|
+ this.remixFinalVideoUrl = '';
|
|
|
+
|
|
|
+ this.remixMode = 'standard';
|
|
|
+
|
|
|
+ this.resetDigitalHumanState();
|
|
|
+
|
|
|
+ this.isWhisperRunning = false;
|
|
|
+
|
|
|
+ this.whisperStatusText = '';
|
|
|
+
|
|
|
+ this.showManualTranscriptInput = false;
|
|
|
+
|
|
|
+ this.showRemixModal = true;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 自动尝试加载Whisper处理后的文件
|
|
|
+
|
|
|
+ this.loadWhisperFiles(video);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 关闭重塑模态框
|
|
|
+
|
|
|
+ closeRemixModal(): void {
|
|
|
+
|
|
|
+ if (this.isRemixSubmitting) return;
|
|
|
+
|
|
|
+ this.showRemixModal = false;
|
|
|
+
|
|
|
+ this.remixTargetVideo = null;
|
|
|
+
|
|
|
+ this.resetDigitalHumanState();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ========== AI重塑模式选择弹窗 ==========
|
|
|
+
|
|
|
+ openRemixModeChooser(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ if (!video || video.isRemixing) return;
|
|
|
+
|
|
|
+ this.remixModeChooserVideo = video;
|
|
|
+
|
|
|
+ this.showRemixModeChooser = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ closeRemixModeChooser(): void {
|
|
|
+
|
|
|
+ this.showRemixModeChooser = false;
|
|
|
+
|
|
|
+ this.remixModeChooserVideo = null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ selectRemixMode(mode: 'standard' | 'digital-human'): void {
|
|
|
+
|
|
|
+ const video = this.remixModeChooserVideo;
|
|
|
+
|
|
|
+ this.showRemixModeChooser = false;
|
|
|
+
|
|
|
+ this.remixModeChooserVideo = null;
|
|
|
+
|
|
|
+ if (!video) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (mode === 'digital-human') {
|
|
|
+
|
|
|
+ // 数字人生成:跳转到数字人合成页,并把该视频作为「参考视频」自动加载 + 分析
|
|
|
+
|
|
|
+ this.setCurrentTab('digital-human');
|
|
|
+
|
|
|
+ this.startDhRefFromManagedVideo(video);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 标准AI重塑:跳转到视频生成页,并将该视频作为已上传文件自动完成第一步
|
|
|
+
|
|
|
+ this.setCurrentTab('video-generation');
|
|
|
+
|
|
|
+ this.startVgFromManagedVideo(video);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private async startVgFromManagedVideo(video: ManagedVideo): Promise<void> {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ this.vgReset();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const playUrl = this.getVideoPlayUrl(video);
|
|
|
+
|
|
|
+ const displayName = video.title || video.filename || `video-${video.id}`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 优先把已存在的视频抓取为 File 对象,便于 ≤20MB 走 Gemini 直接识别
|
|
|
+
|
|
|
+ let file: File | null = null;
|
|
|
+
|
|
|
+ if (playUrl) {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const resp = await fetch(playUrl);
|
|
|
+
|
|
|
+ if (resp.ok) {
|
|
|
+
|
|
|
+ const blob = await resp.blob();
|
|
|
+
|
|
|
+ const mime = blob.type || 'video/mp4';
|
|
|
+
|
|
|
+ const fileName = video.filename || `${displayName}.mp4`;
|
|
|
+
|
|
|
+ file = new File([blob], fileName, { type: mime });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (err) {
|
|
|
+
|
|
|
+ console.warn('从已存在视频构造 File 失败,将走音频提取链路:', err);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.vgSourceFile = file;
|
|
|
+
|
|
|
+ this.vgSourceFileName = displayName;
|
|
|
+
|
|
|
+ this.vgSourceVideoUrl = playUrl;
|
|
|
+
|
|
|
+ this.vgVideoId = video.id;
|
|
|
+
|
|
|
+ this.vgStep = 2;
|
|
|
+
|
|
|
+ this.vgUploading = false;
|
|
|
+
|
|
|
+ this.vgUploadProgress = 100;
|
|
|
+
|
|
|
+ this.vgError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 复用上传成功后的后续流程:建任务 + 启动解析
|
|
|
+
|
|
|
+ this.vgCreateTask();
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-analyze', 15);
|
|
|
+
|
|
|
+ this.vgStartAnalysis();
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ } catch (err: any) {
|
|
|
+
|
|
|
+ console.error('启动标准AI重塑失败:', err);
|
|
|
+
|
|
|
+ this.showToast(`❌ 启动标准AI重塑失败:${err?.message || '请重试'}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ========================================================================
|
|
|
+
|
|
|
+ // 数字人合成页:参考视频(独立的上传 + 分析流程,与 vg* 完全隔离)
|
|
|
+
|
|
|
+ // ========================================================================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 选择本地参考视频文件
|
|
|
+
|
|
|
+ dhRefSelectFile(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ const file = input?.files?.[0];
|
|
|
+
|
|
|
+ if (!file) return;
|
|
|
+
|
|
|
+ if (!file.type.startsWith('video/')) {
|
|
|
+
|
|
|
+ this.showToast('请选择视频文件', 'warn');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.dhRefRevokeUrl();
|
|
|
+
|
|
|
+ this.dhRefFile = file;
|
|
|
+
|
|
|
+ this.dhRefFileName = file.name;
|
|
|
+
|
|
|
+ this.dhRefFileSize = file.size;
|
|
|
+
|
|
|
+ this.dhRefVideoUrl = URL.createObjectURL(file);
|
|
|
+
|
|
|
+ this.dhRefVideoId = '';
|
|
|
+
|
|
|
+ this.dhRefError = '';
|
|
|
+
|
|
|
+ this.dhRefTranscript = '';
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 清除参考视频与重塑状态
|
|
|
+
|
|
|
+ dhRefClear(): void {
|
|
|
+
|
|
|
+ this.dhRefRevokeUrl();
|
|
|
+
|
|
|
+ this.dhRefFile = null;
|
|
|
+
|
|
|
+ this.dhRefFileName = '';
|
|
|
+
|
|
|
+ this.dhRefFileSize = 0;
|
|
|
+
|
|
|
+ this.dhRefVideoUrl = '';
|
|
|
+
|
|
|
+ this.dhRefVideoId = '';
|
|
|
+
|
|
|
+ this.dhRefUploading = false;
|
|
|
+
|
|
|
+ this.dhRefUploadProgress = 0;
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '';
|
|
|
+
|
|
|
+ this.dhRefTranscript = '';
|
|
|
+
|
|
|
+ this.dhRefError = '';
|
|
|
+
|
|
|
+ this.dhRefRewriting = false;
|
|
|
+
|
|
|
+ this.dhRefRewriteError = '';
|
|
|
+
|
|
|
+ this.dhRefAutoRewrite = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhRefRevokeUrl(): void {
|
|
|
+
|
|
|
+ if (this.dhRefVideoUrl && this.dhRefVideoUrl.startsWith('blob:')) {
|
|
|
+
|
|
|
+ try { URL.revokeObjectURL(this.dhRefVideoUrl); } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 从视频管理页选中视频后进入:直接复用已存在的视频文件,跳过上传,自动开始分析 + 重塑
|
|
|
+
|
|
|
+ private async startDhRefFromManagedVideo(video: ManagedVideo): Promise<void> {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ this.dhRefClear();
|
|
|
+
|
|
|
+ this.dhRefAutoRewrite = true;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const playUrl = this.getVideoPlayUrl(video);
|
|
|
+
|
|
|
+ const displayName = video.title || video.filename || `video-${video.id}`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 优先把已存在视频抓为 File(≤20MB 可走 Gemini 直传)
|
|
|
+
|
|
|
+ let file: File | null = null;
|
|
|
+
|
|
|
+ if (playUrl) {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const resp = await fetch(playUrl);
|
|
|
+
|
|
|
+ if (resp.ok) {
|
|
|
+
|
|
|
+ const blob = await resp.blob();
|
|
|
+
|
|
|
+ const mime = blob.type || 'video/mp4';
|
|
|
+
|
|
|
+ const fileName = video.filename || `${displayName}.mp4`;
|
|
|
+
|
|
|
+ file = new File([blob], fileName, { type: mime });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (err) {
|
|
|
+
|
|
|
+ console.warn('⚠️ 数字人参考视频:从已存在视频构造 File 失败:', err);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.dhRefFile = file;
|
|
|
+
|
|
|
+ this.dhRefFileName = displayName;
|
|
|
+
|
|
|
+ this.dhRefFileSize = file ? file.size : 0;
|
|
|
+
|
|
|
+ this.dhRefVideoUrl = playUrl || '';
|
|
|
+
|
|
|
+ this.dhRefVideoId = video.id;
|
|
|
+
|
|
|
+ this.dhRefError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.dhRefStartAnalysis();
|
|
|
+
|
|
|
+ } catch (err: any) {
|
|
|
+
|
|
|
+ console.error('启动数字人参考视频失败:', err);
|
|
|
+
|
|
|
+ this.showToast(`❌ 启动数字人参考视频失败:${err?.message || '请重试'}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 上传本地参考视频,再触发分析
|
|
|
+
|
|
|
+ dhRefUploadAndAnalyze(): void {
|
|
|
+
|
|
|
+ if (!this.dhRefFile || this.dhRefUploading) return;
|
|
|
+
|
|
|
+ this.dhRefUploading = true;
|
|
|
+
|
|
|
+ this.dhRefUploadProgress = 0;
|
|
|
+
|
|
|
+ this.dhRefError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const formData = new FormData();
|
|
|
+
|
|
|
+ formData.append('video', this.dhRefFile);
|
|
|
+
|
|
|
+ formData.append('title', `数字人参考视频 - ${new Date().toLocaleString('zh-CN')}`);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const xhr = new XMLHttpRequest();
|
|
|
+
|
|
|
+ xhr.open('POST', '/backend/api/upload/video');
|
|
|
+
|
|
|
+ xhr.upload.onprogress = (e) => {
|
|
|
+
|
|
|
+ if (e.lengthComputable) {
|
|
|
+
|
|
|
+ this.dhRefUploadProgress = Math.round((e.loaded / e.total) * 100);
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ xhr.onload = () => {
|
|
|
+
|
|
|
+ this.ngZone.run(() => {
|
|
|
+
|
|
|
+ this.dhRefUploading = false;
|
|
|
+
|
|
|
+ if (xhr.status === 200) {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const res = JSON.parse(xhr.responseText);
|
|
|
+
|
|
|
+ if (res?.success && res?.video?.id) {
|
|
|
+
|
|
|
+ this.dhRefVideoId = res.video.id;
|
|
|
+
|
|
|
+ this.dhRefStartAnalysis();
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhRefError = '上传返回异常';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch { this.dhRefError = '解析上传结果失败'; }
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhRefError = `上传失败 (${xhr.status})`;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ xhr.onerror = () => {
|
|
|
+
|
|
|
+ this.ngZone.run(() => {
|
|
|
+
|
|
|
+ this.dhRefUploading = false;
|
|
|
+
|
|
|
+ this.dhRefError = '网络错误,上传失败';
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ xhr.send(formData);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 启动分析:≤20MB 直传 Gemini,>20MB 提取音轨给 Gemini 音频识别,最后降级 Whisper
|
|
|
+
|
|
|
+ private dhRefStartAnalysis(): void {
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = true;
|
|
|
+
|
|
|
+ this.dhRefTranscript = '';
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const sizeMB = this.dhRefFile ? this.dhRefFile.size / 1024 / 1024 : 0;
|
|
|
+
|
|
|
+ if (this.dhRefFile && sizeMB <= 20) {
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = `正在将视频 (${sizeMB.toFixed(1)}MB) 发送给 Gemini 进行视频理解...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunGeminiVideo();
|
|
|
+
|
|
|
+ } else if (this.dhRefVideoId) {
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = `视频 ${sizeMB.toFixed(1)}MB 超过 Gemini 视频限制,正在提取音轨...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunAudioGemini();
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '使用 Whisper 语音识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private async dhRefRunGeminiVideo(): Promise<void> {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const base64 = await this.llmService.fileToBase64(this.dhRefFile!);
|
|
|
+
|
|
|
+ const mimeType = this.dhRefFile!.type || 'video/mp4';
|
|
|
+
|
|
|
+ const prompt = this.buildAnalysisPrompt('video');
|
|
|
+
|
|
|
+ this.llmService.analyzeVideo(base64, mimeType, prompt, {
|
|
|
+
|
|
|
+ model: 'gemini-2.5-flash',
|
|
|
+
|
|
|
+ generationConfig: { maxOutputTokens: 8192 }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ this.dhRefParseGeminiResult(result);
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefMaybeAutoRewrite();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ Gemini 视频识别失败,降级 Whisper:', err);
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = 'Gemini 识别失败,正在使用 Whisper 语音识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (err) {
|
|
|
+
|
|
|
+ console.warn('⚠️ 视频文件读取失败:', err);
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '视频文件读取失败,正在使用 Whisper 语音识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhRefRunAudioGemini(): void {
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/extract-audio-mp3', { videoId: this.dhRefVideoId }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.success && res?.audio?.base64) {
|
|
|
+
|
|
|
+ const audioSizeMB = res.audio.sizeMB || 0;
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = `音轨提取完成 (${audioSizeMB.toFixed(1)}MB),正在 Gemini 音频识别...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ if (audioSizeMB > 20) {
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '音频文件仍较大,改用 Whisper 识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunWhisperFallback();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ const prompt = this.buildAnalysisPrompt('audio');
|
|
|
+
|
|
|
+ this.llmService.analyzeAudio(res.audio.base64, res.audio.mimeType || 'audio/mp4', prompt, {
|
|
|
+
|
|
|
+ model: 'gemini-2.5-flash',
|
|
|
+
|
|
|
+ generationConfig: { maxOutputTokens: 8192 }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ this.dhRefParseGeminiResult(result);
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefMaybeAutoRewrite();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ Gemini 音频识别失败,降级 Whisper:', err);
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = 'Gemini 音频识别失败,改用 Whisper...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhRefRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ const detail = err?.error?.error || err?.message || `HTTP ${err?.status}`;
|
|
|
+
|
|
|
+ console.warn('⚠️ 音轨提取失败:', detail, err);
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = `音轨提取失败 (${detail}),改用 Whisper...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhRefRunWhisperFallback(): void {
|
|
|
+
|
|
|
+ if (!this.dhRefVideoId) {
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '无法降级到 Whisper:参考视频尚未上传到服务端';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/whisper/transcribe', {
|
|
|
+
|
|
|
+ videoId: this.dhRefVideoId,
|
|
|
+
|
|
|
+ language: 'Chinese',
|
|
|
+
|
|
|
+ model: 'large'
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.success && res?.transcript) {
|
|
|
+
|
|
|
+ this.dhRefTranscript = res.transcript;
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = 'Whisper 转录完成,正在 AI 分析内容...';
|
|
|
+
|
|
|
+ this.dhRefAutoFillTts();
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefDoTextAnalysis();
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '语音识别完成但未返回文字稿,请手动输入';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = `Whisper 识别失败: ${err?.error?.error || err?.message || '未知错误'}。请手动输入文字稿。`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhRefDoTextAnalysis(): void {
|
|
|
+
|
|
|
+ const systemPrompt = `你是一个专业的视频内容分析师。请基于以下视频转录文字稿,提取:
|
|
|
+
|
|
|
+1. 视频主题和核心观点
|
|
|
+
|
|
|
+2. 目标受众
|
|
|
+
|
|
|
+3. 内容风格
|
|
|
+
|
|
|
+4. 关键知识点(3-5条)
|
|
|
+
|
|
|
+5. 改进方向
|
|
|
+
|
|
|
+请用简洁中文回复。`;
|
|
|
+
|
|
|
+ this.llmService.askWithSystem(systemPrompt, this.dhRefTranscript, { model: 'gemini-2.5-flash', max_tokens: 1024 }).subscribe({
|
|
|
+
|
|
|
+ next: (analysis) => {
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = analysis || '分析完成';
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefMaybeAutoRewrite();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = '(AI 文本分析暂不可用,已完成 Whisper 转录)';
|
|
|
+
|
|
|
+ this.dhRefAnalyzing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhRefMaybeAutoRewrite();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhRefParseGeminiResult(raw: string): void {
|
|
|
+
|
|
|
+ const m = raw.match(/---TRANSCRIPT_START---([\s\S]*?)---TRANSCRIPT_END---/);
|
|
|
+
|
|
|
+ if (m) {
|
|
|
+
|
|
|
+ this.dhRefTranscript = m[1].trim();
|
|
|
+
|
|
|
+ const analysisPart = raw.substring(0, raw.indexOf('---TRANSCRIPT_START---')).trim();
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = analysisPart.replace(/^【内容分析】\s*/, '') || '分析完成';
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhRefAnalysisText = raw || '分析完成';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.dhRefAutoFillTts();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 把参考视频识别得到的文字稿自动填入 Step 2 口播文本(不覆盖用户已手输的内容) */
|
|
|
+
|
|
|
+ private dhRefAutoFillTts(): void {
|
|
|
+
|
|
|
+ const transcript = (this.dhRefTranscript || '').trim();
|
|
|
+
|
|
|
+ if (!transcript) return;
|
|
|
+
|
|
|
+ // 仅在口播文本为空时自动填入;用户已输入则保留
|
|
|
+
|
|
|
+ if (!this.dhTtsText || !this.dhTtsText.trim()) {
|
|
|
+
|
|
|
+ this.dhTtsText = transcript.slice(0, 600);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 文字稿手动编辑时,同步反映到口播文本(前提:当前口播仍等于上一次同步值) */
|
|
|
+
|
|
|
+ dhRefSyncToTts(newTranscript: string): void {
|
|
|
+
|
|
|
+ const next = (newTranscript || '').slice(0, 600);
|
|
|
+
|
|
|
+ this.dhTtsText = next;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 应用补充提示词预设;二次点击同一个则取消 */
|
|
|
+
|
|
|
+ dhApplyPromptPreset(value: string): void {
|
|
|
+
|
|
|
+ if (this.dhPrompt === value) {
|
|
|
+
|
|
|
+ this.dhPrompt = '';
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhPrompt = value;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildAnalysisPrompt(kind: 'video' | 'audio'): string {
|
|
|
+
|
|
|
+ const taskOne = kind === 'video'
|
|
|
+
|
|
|
+ ? `从画面和音频两个维度分析:1.主题/核心观点 2.画面描述 3.目标受众 4.内容风格 5.亮点(3-5条) 6.视觉风格 7.改进建议`
|
|
|
+
|
|
|
+ : `从音频分析:1.主题/核心观点 2.目标受众 3.内容风格 4.亮点(3-5条) 5.改进建议`;
|
|
|
+
|
|
|
+ return `你是一个专业的内容分析师。请完成两个任务:
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+===== 任务一:内容分析 =====
|
|
|
+
|
|
|
+${taskOne}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+===== 任务二:完整语音转录 =====
|
|
|
+
|
|
|
+请将所有语音内容逐字转录为中文,忠实还原、不遗漏、不改写、不总结,按自然段落分段。
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+请严格按以下格式输出(用分隔线区分两部分):
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+【内容分析】
|
|
|
+
|
|
|
+(任务一结果)
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+---TRANSCRIPT_START---
|
|
|
+
|
|
|
+(任务二完整转录)
|
|
|
+
|
|
|
+---TRANSCRIPT_END---`;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频分析完成后自动触发(仅在通过 AI重塑入口或显式标记时)
|
|
|
+
|
|
|
+ private dhRefMaybeAutoRewrite(): void {
|
|
|
+
|
|
|
+ if (!this.dhRefAutoRewrite) return;
|
|
|
+
|
|
|
+ if (!this.dhRefTranscript || !this.dhRefTranscript.trim()) return;
|
|
|
+
|
|
|
+ this.dhRefAutoRewrite = false; // 仅触发一次
|
|
|
+
|
|
|
+ this.dhRefRunRewrite();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 把当前文字稿 + 内容分析交给 AI 重写为口播文本与补充提示词
|
|
|
+
|
|
|
+ dhRefRunRewrite(): void {
|
|
|
+
|
|
|
+ if (!this.dhRefTranscript || !this.dhRefTranscript.trim()) {
|
|
|
+
|
|
|
+ this.dhRefRewriteError = '暂无可重塑的视频文字稿';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.dhRefRewriting = true;
|
|
|
+
|
|
|
+ this.dhRefRewriteError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const systemPrompt = `你是一位短视频数字人编剧。基于用户提供的【原始视频文字稿】和【内容分析】,请输出严格的 JSON:
|
|
|
+
|
|
|
+{
|
|
|
+
|
|
|
+ "ttsScript": "重塑后的口播文本,第一人称、自然口语、节奏适合数字人朗读,控制在 200 字以内",
|
|
|
+
|
|
|
+ "dhPrompt": "数字人生成的补充提示词,描述人物动作 / 镜头语言 / 风格要求,控制在 80 字以内"
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+只返回纯 JSON 对象,不要使用 markdown 代码块、不要添加解释或前后缀。注意:JSON 字符串内部禁止出现真实换行,必须使用 \\n 转义。`;
|
|
|
+
|
|
|
+ const userInput = `【原始文字稿】\n${this.dhRefTranscript}\n\n【内容分析】\n${this.dhRefAnalysisText || '(无)'}`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.llmService.askWithSystem(systemPrompt, userInput, { model: 'gemini-2.5-flash', max_tokens: 2048 }).subscribe({
|
|
|
+
|
|
|
+ next: (text) => {
|
|
|
+
|
|
|
+ this.dhRefRewriting = false;
|
|
|
+
|
|
|
+ const cleaned = (text || '').replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
|
|
|
+
|
|
|
+ const parsed = this.parseRewriteJson(cleaned);
|
|
|
+
|
|
|
+ if (parsed) {
|
|
|
+
|
|
|
+ const tts = typeof parsed.ttsScript === 'string' ? parsed.ttsScript.trim() : '';
|
|
|
+
|
|
|
+ const prompt = typeof parsed.dhPrompt === 'string' ? parsed.dhPrompt.trim() : '';
|
|
|
+
|
|
|
+ if (tts) this.dhTtsText = tts.slice(0, 600);
|
|
|
+
|
|
|
+ if (prompt) this.dhPrompt = prompt.slice(0, 200);
|
|
|
+
|
|
|
+ if (tts || prompt) {
|
|
|
+
|
|
|
+ this.showToast('✅ 已自动填充口播文本与补充提示词', 'success');
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhRefRewriteError = 'AI 未返回有效字段,请手动整理';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ console.warn('⚠️ 解析重塑结果失败,原始返回:', cleaned);
|
|
|
+
|
|
|
+ this.dhRefRewriteError = '解析重塑结果失败,已将原始文本填入口播文本,请手动整理';
|
|
|
+
|
|
|
+ this.dhTtsText = (text || '').slice(0, 600);
|
|
|
+
|
|
|
+ this.showToast('⚠️ 重塑结果解析失败,已退回原文', 'warn');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.dhRefRewriting = false;
|
|
|
+
|
|
|
+ this.dhRefRewriteError = `AI 重塑失败:${this.getErrorText(err, '请重试')}`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 容错解析 LLM 返回的 JSON:支持 fenced 代码块、未转义换行、字段正则兜底 */
|
|
|
+
|
|
|
+ private parseRewriteJson(raw: string): { ttsScript?: string; dhPrompt?: string } | null {
|
|
|
+
|
|
|
+ if (!raw) return null;
|
|
|
+
|
|
|
+ // 1) 严格解析
|
|
|
+
|
|
|
+ try { return JSON.parse(raw); } catch {}
|
|
|
+
|
|
|
+ // 2) 截取首个 { ... } 区段
|
|
|
+
|
|
|
+ const braceStart = raw.indexOf('{');
|
|
|
+
|
|
|
+ const braceEnd = raw.lastIndexOf('}');
|
|
|
+
|
|
|
+ let candidate = braceStart >= 0 && braceEnd > braceStart ? raw.slice(braceStart, braceEnd + 1) : raw;
|
|
|
+
|
|
|
+ // 3) 把字符串内部的裸换行/回车/制表符转义
|
|
|
+
|
|
|
+ const sanitized = this.escapeRawControlCharsInJsonStrings(candidate);
|
|
|
+
|
|
|
+ try { return JSON.parse(sanitized); } catch {}
|
|
|
+
|
|
|
+ // 4) 正则兜底,逐字段抽取(兼容 "tts": "..." 中含 \" 转义)
|
|
|
+
|
|
|
+ const fieldRegex = (key: string) => new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`, 'i');
|
|
|
+
|
|
|
+ const ttsMatch = candidate.match(fieldRegex('ttsScript'));
|
|
|
+
|
|
|
+ const promptMatch = candidate.match(fieldRegex('dhPrompt'));
|
|
|
+
|
|
|
+ if (ttsMatch || promptMatch) {
|
|
|
+
|
|
|
+ const unescape = (s: string) => s.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ ttsScript: ttsMatch ? unescape(ttsMatch[1]) : undefined,
|
|
|
+
|
|
|
+ dhPrompt: promptMatch ? unescape(promptMatch[1]) : undefined,
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5) 截断兜底:若仅有 ttsScript 开头但被截断,抓到截断处为止
|
|
|
+
|
|
|
+ const truncatedTts = candidate.match(/"ttsScript"\s*:\s*"((?:\\.|[^"\\])*)$/i);
|
|
|
+
|
|
|
+ if (truncatedTts) {
|
|
|
+
|
|
|
+ return { ttsScript: truncatedTts[1].replace(/\\n/g, '\n').replace(/\\"/g, '"') };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 在 JSON 字符串字面量内部把裸 \n \r \t 替换为转义形式,括号外的不动 */
|
|
|
+
|
|
|
+ private escapeRawControlCharsInJsonStrings(input: string): string {
|
|
|
+
|
|
|
+ let out = '';
|
|
|
+
|
|
|
+ let inString = false;
|
|
|
+
|
|
|
+ let escape = false;
|
|
|
+
|
|
|
+ for (let i = 0; i < input.length; i++) {
|
|
|
+
|
|
|
+ const ch = input[i];
|
|
|
+
|
|
|
+ if (inString) {
|
|
|
+
|
|
|
+ if (escape) { out += ch; escape = false; continue; }
|
|
|
+
|
|
|
+ if (ch === '\\') { out += ch; escape = true; continue; }
|
|
|
+
|
|
|
+ if (ch === '"') { inString = false; out += ch; continue; }
|
|
|
+
|
|
|
+ if (ch === '\n') { out += '\\n'; continue; }
|
|
|
+
|
|
|
+ if (ch === '\r') { out += '\\r'; continue; }
|
|
|
+
|
|
|
+ if (ch === '\t') { out += '\\t'; continue; }
|
|
|
+
|
|
|
+ out += ch;
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ if (ch === '"') { inString = true; out += ch; continue; }
|
|
|
+
|
|
|
+ out += ch;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return out;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getActiveRemixSteps(): RemixFlowStep[] {
|
|
|
+
|
|
|
+ if (this.remixMode === 'digital-human') {
|
|
|
+
|
|
|
+ return [
|
|
|
+
|
|
|
+ { step: 1, label: this.digitalHumanStepLabels[0] },
|
|
|
+
|
|
|
+ { step: 4, label: this.digitalHumanStepLabels[1] },
|
|
|
+
|
|
|
+ { step: 5, label: this.digitalHumanStepLabels[2] },
|
|
|
+
|
|
|
+ { step: 6, label: this.digitalHumanStepLabels[3] }
|
|
|
+
|
|
|
+ ];
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return this.remixStepLabels.map((label, index) => ({ step: index + 1, label }));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ setRemixMode(mode: 'standard' | 'digital-human'): void {
|
|
|
+
|
|
|
+ this.remixMode = mode;
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '';
|
|
|
+
|
|
|
+ if (mode === 'standard' && this.remixStep === 4 && this.remixStoryboard.length === 0) {
|
|
|
+
|
|
|
+ this.remixStep = 1;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private hydrateVoiceProfile(profile: any): VoiceProfile {
|
|
|
+
|
|
|
+ const status = profile?.status === null || profile?.status === undefined || profile?.status === ''
|
|
|
+
|
|
|
+ ? null
|
|
|
+
|
|
|
+ : Number(profile.status);
|
|
|
+
|
|
|
+ const synthesizedAudioUrl = String(profile?.synthesized_audio_url || '');
|
|
|
+
|
|
|
+ const demoAudio = String(profile?.demo_audio || '');
|
|
|
+
|
|
|
+ const latestAudioUrl = String(profile?.latest_audio_url || synthesizedAudioUrl || demoAudio || '');
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ ...profile,
|
|
|
+
|
|
|
+ creation_mode: String(profile?.creation_mode || (profile?.timbre_id ? 'clone' : 'design')),
|
|
|
+
|
|
|
+ speaker_id: String(profile?.speaker_id || ''),
|
|
|
+
|
|
|
+ timbre_id: String(profile?.timbre_id || profile?.timbreId || ''),
|
|
|
+
|
|
|
+ sample_text: String(profile?.sample_text || ''),
|
|
|
+
|
|
|
+ text_prompt: String(profile?.text_prompt || ''),
|
|
|
+
|
|
|
+ source_audio_text: String(profile?.source_audio_text || ''),
|
|
|
+
|
|
|
+ source_audio_name: String(profile?.source_audio_name || ''),
|
|
|
+
|
|
|
+ source_audio_format: String(profile?.source_audio_format || ''),
|
|
|
+
|
|
|
+ language: Number(profile?.language ?? 0),
|
|
|
+
|
|
|
+ status,
|
|
|
+
|
|
|
+ status_label: String(profile?.status_label || (demoAudio || synthesizedAudioUrl ? '可用' : '未知')),
|
|
|
+
|
|
|
+ demo_audio: demoAudio,
|
|
|
+
|
|
|
+ available_training_times: profile?.available_training_times === null || profile?.available_training_times === undefined
|
|
|
+
|
|
|
+ ? null
|
|
|
+
|
|
|
+ : Number(profile.available_training_times),
|
|
|
+
|
|
|
+ image_prompt_name: String(profile?.image_prompt_name || ''),
|
|
|
+
|
|
|
+ x_api_resource_id: String(profile?.x_api_resource_id || ''),
|
|
|
+
|
|
|
+ model_version: String(profile?.model_version || ''),
|
|
|
+
|
|
|
+ icl_speaker_id: String(profile?.icl_speaker_id || ''),
|
|
|
+
|
|
|
+ occupied: !!profile?.occupied,
|
|
|
+
|
|
|
+ synthesized_audio_url: synthesizedAudioUrl,
|
|
|
+
|
|
|
+ synthesized_work_id: String(profile?.synthesized_work_id || ''),
|
|
|
+
|
|
|
+ last_synthesis_text: String(profile?.last_synthesis_text || ''),
|
|
|
+
|
|
|
+ latest_audio_url: latestAudioUrl,
|
|
|
+
|
|
|
+ ...this.buildVoiceSynthesisDraft(profile),
|
|
|
+
|
|
|
+ is_synthesizing: false,
|
|
|
+
|
|
|
+ message: String(profile?.message || ''),
|
|
|
+
|
|
|
+ request_id: String(profile?.request_id || ''),
|
|
|
+
|
|
|
+ created_at: new Date(profile?.created_at || Date.now()),
|
|
|
+
|
|
|
+ updated_at: profile?.updated_at ? new Date(profile.updated_at) : undefined
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private upsertLocalVoiceProfile(profile: VoiceProfile): void {
|
|
|
+
|
|
|
+ const nextProfiles = [...this.voiceProfiles];
|
|
|
+
|
|
|
+ const existingIndex = nextProfiles.findIndex((item) => (
|
|
|
+
|
|
|
+ item.id === profile.id
|
|
|
+
|
|
|
+ || (!!profile.timbre_id && item.timbre_id === profile.timbre_id)
|
|
|
+
|
|
|
+ ));
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (existingIndex >= 0) {
|
|
|
+
|
|
|
+ nextProfiles[existingIndex] = {
|
|
|
+
|
|
|
+ ...nextProfiles[existingIndex],
|
|
|
+
|
|
|
+ ...profile,
|
|
|
+
|
|
|
+ draft_synthesis_text: profile.draft_synthesis_text ?? nextProfiles[existingIndex].draft_synthesis_text,
|
|
|
+
|
|
|
+ draft_synthesis_ssml: profile.draft_synthesis_ssml ?? nextProfiles[existingIndex].draft_synthesis_ssml,
|
|
|
+
|
|
|
+ draft_synthesis_x_api_resource_id: profile.draft_synthesis_x_api_resource_id ?? nextProfiles[existingIndex].draft_synthesis_x_api_resource_id,
|
|
|
+
|
|
|
+ draft_synthesis_model: profile.draft_synthesis_model ?? nextProfiles[existingIndex].draft_synthesis_model,
|
|
|
+
|
|
|
+ draft_synthesis_format: profile.draft_synthesis_format ?? nextProfiles[existingIndex].draft_synthesis_format,
|
|
|
+
|
|
|
+ draft_synthesis_sample_rate: profile.draft_synthesis_sample_rate ?? nextProfiles[existingIndex].draft_synthesis_sample_rate,
|
|
|
+
|
|
|
+ draft_synthesis_speech_rate: profile.draft_synthesis_speech_rate ?? nextProfiles[existingIndex].draft_synthesis_speech_rate,
|
|
|
+
|
|
|
+ draft_synthesis_loudness_rate: profile.draft_synthesis_loudness_rate ?? nextProfiles[existingIndex].draft_synthesis_loudness_rate,
|
|
|
+
|
|
|
+ draft_synthesis_emotion: profile.draft_synthesis_emotion ?? nextProfiles[existingIndex].draft_synthesis_emotion,
|
|
|
+
|
|
|
+ draft_synthesis_emotion_scale: profile.draft_synthesis_emotion_scale ?? nextProfiles[existingIndex].draft_synthesis_emotion_scale,
|
|
|
+
|
|
|
+ draft_synthesis_enable_subtitle: profile.draft_synthesis_enable_subtitle ?? nextProfiles[existingIndex].draft_synthesis_enable_subtitle,
|
|
|
+
|
|
|
+ draft_synthesis_silence_duration: profile.draft_synthesis_silence_duration ?? nextProfiles[existingIndex].draft_synthesis_silence_duration,
|
|
|
+
|
|
|
+ draft_synthesis_enable_language_detector: profile.draft_synthesis_enable_language_detector ?? nextProfiles[existingIndex].draft_synthesis_enable_language_detector,
|
|
|
+
|
|
|
+ draft_synthesis_disable_markdown_filter: profile.draft_synthesis_disable_markdown_filter ?? nextProfiles[existingIndex].draft_synthesis_disable_markdown_filter,
|
|
|
+
|
|
|
+ draft_synthesis_disable_emoji_filter: profile.draft_synthesis_disable_emoji_filter ?? nextProfiles[existingIndex].draft_synthesis_disable_emoji_filter,
|
|
|
+
|
|
|
+ draft_synthesis_explicit_language: profile.draft_synthesis_explicit_language ?? nextProfiles[existingIndex].draft_synthesis_explicit_language,
|
|
|
+
|
|
|
+ is_synthesizing: false
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.voiceProfiles = nextProfiles;
|
|
|
+
|
|
|
+ if (this.voiceSynthesisForm.profileId === nextProfiles[existingIndex].id) {
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildVoiceSynthesisFormFromProfile(nextProfiles[existingIndex]);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.voiceProfiles = [{ ...profile, is_synthesizing: false }, ...nextProfiles];
|
|
|
+
|
|
|
+ if (!this.voiceSynthesisForm.profileId && !String(this.voiceSynthesisForm.speakerId || '').trim() && this.canSynthesizeVoiceProfile(profile)) {
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildVoiceSynthesisFormFromProfile(profile);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private getVoiceCloneModelMeta(modelSource: any): { demoAudio: string; iclSpeakerId: string; xApiResourceId: string; modelVersion: string } {
|
|
|
+
|
|
|
+ const model = Array.isArray(modelSource) ? (modelSource.find((item) => !!item) || {}) : (modelSource || {});
|
|
|
+
|
|
|
+ const rawResourceId = Array.isArray(model?.x_api_resource_id)
|
|
|
+
|
|
|
+ ? model.x_api_resource_id.find((item: any) => String(item || '').trim()) || ''
|
|
|
+
|
|
|
+ : model?.x_api_resource_id;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ demoAudio: String(model?.demo_audio || '').trim(),
|
|
|
+
|
|
|
+ iclSpeakerId: String(model?.icl_speaker_id || '').trim(),
|
|
|
+
|
|
|
+ xApiResourceId: String(rawResourceId || '').trim(),
|
|
|
+
|
|
|
+ modelVersion: String(model?.version || model?.model_version || '').trim()
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private buildVoiceProfileFromCloneApiResponse(response: any, fallback: {
|
|
|
+
|
|
|
+ displayName: string;
|
|
|
+
|
|
|
+ speakerId: string;
|
|
|
+
|
|
|
+ sampleText: string;
|
|
|
+
|
|
|
+ audioText: string;
|
|
|
+
|
|
|
+ sourceAudioName: string;
|
|
|
+
|
|
|
+ sourceAudioFormat: string;
|
|
|
+
|
|
|
+ language: number;
|
|
|
+
|
|
|
+ }): VoiceProfile | null {
|
|
|
+
|
|
|
+ const timbre = response?.data?.timbre;
|
|
|
+
|
|
|
+ if (!timbre || typeof timbre !== 'object') {
|
|
|
+
|
|
|
+ return null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const modelMeta = this.getVoiceCloneModelMeta(timbre?.models);
|
|
|
+
|
|
|
+ return this.hydrateVoiceProfile({
|
|
|
+
|
|
|
+ id: String(timbre?.objectId || `voice-${Date.now()}`),
|
|
|
+
|
|
|
+ name: String(timbre?.name || fallback.displayName || '未命名音色'),
|
|
|
+
|
|
|
+ creation_mode: 'clone',
|
|
|
+
|
|
|
+ speaker_id: String(timbre?.speaker_id || fallback.speakerId || ''),
|
|
|
+
|
|
|
+ timbre_id: String(timbre?.objectId || ''),
|
|
|
+
|
|
|
+ sample_text: fallback.sampleText,
|
|
|
+
|
|
|
+ source_audio_text: fallback.audioText,
|
|
|
+
|
|
|
+ source_audio_name: fallback.sourceAudioName,
|
|
|
+
|
|
|
+ source_audio_format: fallback.sourceAudioFormat,
|
|
|
+
|
|
|
+ language: fallback.language,
|
|
|
+
|
|
|
+ status: timbre?.status === null || timbre?.status === undefined || timbre?.status === '' ? null : Number(timbre.status),
|
|
|
+
|
|
|
+ status_label: String(response?.data?.tip || (modelMeta.demoAudio ? '可用' : '未知')),
|
|
|
+
|
|
|
+ demo_audio: modelMeta.demoAudio,
|
|
|
+
|
|
|
+ occupied: !!timbre?.occupied,
|
|
|
+
|
|
|
+ x_api_resource_id: modelMeta.xApiResourceId,
|
|
|
+
|
|
|
+ model_version: modelMeta.modelVersion,
|
|
|
+
|
|
|
+ icl_speaker_id: modelMeta.iclSpeakerId,
|
|
|
+
|
|
|
+ latest_audio_url: modelMeta.demoAudio,
|
|
|
+
|
|
|
+ message: String(response?.data?.tip || ''),
|
|
|
+
|
|
|
+ created_at: timbre?.createdAt || new Date().toISOString(),
|
|
|
+
|
|
|
+ updated_at: timbre?.updatedAt || new Date().toISOString()
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private applySynthesisResponseToProfile(profile: VoiceProfile, response: any, payload: {
|
|
|
+
|
|
|
+ text: string;
|
|
|
+
|
|
|
+ ssml: string;
|
|
|
+
|
|
|
+ xApiResourceId: string;
|
|
|
+
|
|
|
+ model: string;
|
|
|
+
|
|
|
+ format: string;
|
|
|
+
|
|
|
+ sampleRate: number;
|
|
|
+
|
|
|
+ speechRate: number;
|
|
|
+
|
|
|
+ loudnessRate: number;
|
|
|
+
|
|
|
+ emotion: string;
|
|
|
+
|
|
|
+ emotionScale: number;
|
|
|
+
|
|
|
+ enableSubtitle: boolean;
|
|
|
+
|
|
|
+ silenceDuration: number;
|
|
|
+
|
|
|
+ enableLanguageDetector: boolean;
|
|
|
+
|
|
|
+ disableMarkdownFilter: boolean;
|
|
|
+
|
|
|
+ disableEmojiFilter: boolean;
|
|
|
+
|
|
|
+ explicitLanguage: string;
|
|
|
+
|
|
|
+ }): VoiceProfile {
|
|
|
+
|
|
|
+ const audioUrl = String(response?.data?.audioUrl || '').trim();
|
|
|
+
|
|
|
+ const workId = String(response?.data?.workId || '').trim();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return this.hydrateVoiceProfile({
|
|
|
+
|
|
|
+ ...profile,
|
|
|
+
|
|
|
+ synthesized_audio_url: audioUrl || profile.synthesized_audio_url || '',
|
|
|
+
|
|
|
+ synthesized_work_id: workId || profile.synthesized_work_id || '',
|
|
|
+
|
|
|
+ latest_audio_url: audioUrl || profile.latest_audio_url || profile.demo_audio || '',
|
|
|
+
|
|
|
+ last_synthesis_text: payload.text,
|
|
|
+
|
|
|
+ last_synthesis_ssml: payload.ssml,
|
|
|
+
|
|
|
+ last_synthesis_x_api_resource_id: payload.xApiResourceId || profile.x_api_resource_id || '',
|
|
|
+
|
|
|
+ last_synthesis_model: payload.model,
|
|
|
+
|
|
|
+ last_synthesis_format: payload.format,
|
|
|
+
|
|
|
+ last_synthesis_sample_rate: payload.sampleRate,
|
|
|
+
|
|
|
+ last_synthesis_speech_rate: payload.speechRate,
|
|
|
+
|
|
|
+ last_synthesis_loudness_rate: payload.loudnessRate,
|
|
|
+
|
|
|
+ last_synthesis_emotion: payload.emotion,
|
|
|
+
|
|
|
+ last_synthesis_emotion_scale: payload.emotionScale,
|
|
|
+
|
|
|
+ last_synthesis_enable_subtitle: payload.enableSubtitle,
|
|
|
+
|
|
|
+ last_synthesis_silence_duration: payload.silenceDuration,
|
|
|
+
|
|
|
+ last_synthesis_enable_language_detector: payload.enableLanguageDetector,
|
|
|
+
|
|
|
+ last_synthesis_disable_markdown_filter: payload.disableMarkdownFilter,
|
|
|
+
|
|
|
+ last_synthesis_disable_emoji_filter: payload.disableEmojiFilter,
|
|
|
+
|
|
|
+ last_synthesis_explicit_language: payload.explicitLanguage,
|
|
|
+
|
|
|
+ updated_at: new Date().toISOString()
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private syncVoiceProfileToBackend(profile: VoiceProfile | null | undefined): void {
|
|
|
+
|
|
|
+ if (!profile?.id) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/voice-profiles/sync', {
|
|
|
+
|
|
|
+ ...profile,
|
|
|
+
|
|
|
+ created_at: profile.created_at instanceof Date ? profile.created_at.toISOString() : profile.created_at,
|
|
|
+
|
|
|
+ updated_at: profile.updated_at instanceof Date ? profile.updated_at.toISOString() : profile.updated_at
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.profile) {
|
|
|
+
|
|
|
+ this.upsertLocalVoiceProfile(this.hydrateVoiceProfile(res.profile));
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 音色记录本地同步失败:', err);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private isVoiceSpeakerIdentifier(value: string): boolean {
|
|
|
+
|
|
|
+ const normalizedValue = String(value || '').trim();
|
|
|
+
|
|
|
+ return /^(S_|ICL_|icl_|saturn_|dit_)/.test(normalizedValue);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onVoiceSourceAudioSelected(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ if (!input.files || input.files.length === 0) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const file = input.files[0];
|
|
|
+
|
|
|
+ const ext = (file.name.match(/\.[^.]+$/)?.[0] || '').toLowerCase();
|
|
|
+
|
|
|
+ const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/flac', 'audio/ogg', 'audio/opus', 'application/octet-stream'];
|
|
|
+
|
|
|
+ const allowedExts = ['.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg', '.opus', '.pcm'];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if ((!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) || file.size <= 0) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '音频样本仅支持 mp3、wav、m4a、aac、flac、ogg、opus、pcm 格式';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (file.size > 20 * 1024 * 1024) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '音频样本大小不能超过 20MB';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.voiceSourceAudioFile = file;
|
|
|
+
|
|
|
+ this.voiceSourceAudioName = file.name;
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ clearVoiceSourceAudio(): void {
|
|
|
+
|
|
|
+ this.voiceSourceAudioFile = null;
|
|
|
+
|
|
|
+ this.voiceSourceAudioName = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onVoiceDesignImageSelected(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ if (!input.files || input.files.length === 0) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const file = input.files[0];
|
|
|
+
|
|
|
+ if (!String(file.type || '').startsWith('image/') || file.size <= 0) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '图片提示仅支持常见图片格式';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (file.size > 10 * 1024 * 1024) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '图片提示大小不能超过 10MB';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.voiceDesignImageFile = file;
|
|
|
+
|
|
|
+ this.voiceDesignImageName = file.name;
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ clearVoiceDesignImage(): void {
|
|
|
+
|
|
|
+ this.voiceDesignImageFile = null;
|
|
|
+
|
|
|
+ this.voiceDesignImageName = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ setVoiceCreateMode(mode: 'clone' | 'design' | 'synthesize'): void {
|
|
|
+
|
|
|
+ if (this.voiceCreateMode === mode) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.voiceCreateMode = mode;
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ if (mode === 'synthesize') {
|
|
|
+
|
|
|
+ this.syncVoiceSynthesisFormSelection();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private resetVoiceCreateForm(): void {
|
|
|
+
|
|
|
+ this.voiceForm = this.buildDefaultVoiceForm();
|
|
|
+
|
|
|
+ this.voiceSourceAudioFile = null;
|
|
|
+
|
|
|
+ this.voiceSourceAudioName = '';
|
|
|
+
|
|
|
+ this.voiceDesignImageFile = null;
|
|
|
+
|
|
|
+ this.voiceDesignImageName = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getVoiceProfileAudioUrl(profile: VoiceProfile | null | undefined): string {
|
|
|
+
|
|
|
+ if (!profile) {
|
|
|
+
|
|
|
+ return '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return String(profile.latest_audio_url || profile.synthesized_audio_url || profile.demo_audio || '').trim();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ canSynthesizeVoiceProfile(profile: VoiceProfile | null | undefined): boolean {
|
|
|
+
|
|
|
+ if (!profile) {
|
|
|
+
|
|
|
+ return false;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return !!String(profile.icl_speaker_id || profile.speaker_id || '').trim();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getSynthesisVoiceProfiles(): VoiceProfile[] {
|
|
|
+
|
|
|
+ return this.voiceProfiles.filter((profile) => this.canSynthesizeVoiceProfile(profile));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getSelectedVoiceSynthesisProfile(): VoiceProfile | null {
|
|
|
+
|
|
|
+ return this.getSynthesisVoiceProfiles().find((profile) => profile.id === this.voiceSynthesisForm.profileId) || null;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ selectVoiceSynthesisProfile(profileId: string): void {
|
|
|
+
|
|
|
+ if (!profileId) {
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = {
|
|
|
+
|
|
|
+ ...this.voiceSynthesisForm,
|
|
|
+
|
|
|
+ profileId: ''
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const nextProfile = this.voiceProfiles.find((profile) => profile.id === profileId) || null;
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildVoiceSynthesisFormFromProfile(nextProfile);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ openVoiceSynthesisWorkbench(profile: VoiceProfile): void {
|
|
|
+
|
|
|
+ if (!this.canSynthesizeVoiceProfile(profile)) {
|
|
|
+
|
|
|
+ this.showToast('❌ 当前音色缺少可用于语音合成的 speaker 标识', 'error');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.voiceCreateMode = 'synthesize';
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildVoiceSynthesisFormFromProfile(profile);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ getVoiceProfileAudioLabel(profile: VoiceProfile | null | undefined): string {
|
|
|
+
|
|
|
+ if (!profile) {
|
|
|
+
|
|
|
+ return '暂无可用音频';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const audioUrl = this.getVoiceProfileAudioUrl(profile);
|
|
|
+
|
|
|
+ if (!audioUrl) {
|
|
|
+
|
|
|
+ return '暂无可用音频';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (audioUrl === String(profile.synthesized_audio_url || '')) {
|
|
|
+
|
|
|
+ return '文本合成音频';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (audioUrl === String(profile.demo_audio || '')) {
|
|
|
+
|
|
|
+ return '试听音频';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ return '音色音频';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ createVoiceProfile(): void {
|
|
|
+
|
|
|
+ if (this.isCreatingVoiceProfile) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.voiceCreateMode === 'synthesize') {
|
|
|
+
|
|
|
+ this.submitVoiceSynthesisForm();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.voiceCreateMode === 'design') {
|
|
|
+
|
|
|
+ this.createVoiceDesignProfile();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.createVoiceCloneProfile();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private createVoiceCloneProfile(): void {
|
|
|
+
|
|
|
+ const speakerId = this.voiceForm.speakerId.trim();
|
|
|
+
|
|
|
+ const displayName = this.voiceForm.displayName.trim();
|
|
|
+
|
|
|
+ const audioText = this.voiceForm.audioText.trim();
|
|
|
+
|
|
|
+ const sampleText = this.voiceForm.sampleText.trim();
|
|
|
+
|
|
|
+ const synthesisText = this.voiceForm.synthesisText.trim();
|
|
|
+
|
|
|
+ const sourceAudioFile = this.voiceSourceAudioFile;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!displayName) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请输入音色名称';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!speakerId) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请输入音色代号 speaker_id';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!sourceAudioFile) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请上传用于复刻的音频样本';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!sampleText) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请输入试听文本';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (sampleText.length < 4 || sampleText.length > 80) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '试听文本长度需在 4-80 字之间';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.isCreatingVoiceProfile = true;
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const finalizeCloneFlow = (profile: VoiceProfile | null, toastType: 'success' | 'warn', toastMessage: string) => {
|
|
|
+
|
|
|
+ if (profile) {
|
|
|
+
|
|
|
+ profile.draft_synthesis_text = profile.last_synthesis_text || synthesisText;
|
|
|
+
|
|
|
+ this.upsertLocalVoiceProfile(profile);
|
|
|
+
|
|
|
+ this.syncVoiceProfileToBackend(profile);
|
|
|
+
|
|
|
+ if (this.getVoiceProfileAudioUrl(profile)) {
|
|
|
+
|
|
|
+ this.preferredVoiceProfileId = profile.id;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.resetVoiceCreateForm();
|
|
|
+
|
|
|
+ this.isCreatingVoiceProfile = false;
|
|
|
+
|
|
|
+ this.showToast(toastMessage, toastType, 5000);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.readFileAsBase64(sourceAudioFile).then((audioBase64) => {
|
|
|
+
|
|
|
+ const sourceAudioFormat = this.inferVoiceSourceAudioFormat(sourceAudioFile);
|
|
|
+
|
|
|
+ this.http.post<any>(`${this.voiceTtsBaseUrl}/voice_clone`, {
|
|
|
+
|
|
|
+ token: this.voiceApiToken,
|
|
|
+
|
|
|
+ name: displayName,
|
|
|
+
|
|
|
+ speaker_id: speakerId,
|
|
|
+
|
|
|
+ audioData: {
|
|
|
+
|
|
|
+ base64: audioBase64,
|
|
|
+
|
|
|
+ format: sourceAudioFormat,
|
|
|
+
|
|
|
+ ...(audioText ? { text: audioText } : {})
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ language: this.voiceForm.language || 0,
|
|
|
+
|
|
|
+ extra_params: {
|
|
|
+
|
|
|
+ demo_text: sampleText
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ const baseProfile: VoiceProfile | null = res?.profile
|
|
|
+
|
|
|
+ ? this.hydrateVoiceProfile(res.profile)
|
|
|
+
|
|
|
+ : this.buildVoiceProfileFromCloneApiResponse(res, {
|
|
|
+
|
|
|
+ displayName,
|
|
|
+
|
|
|
+ speakerId,
|
|
|
+
|
|
|
+ sampleText,
|
|
|
+
|
|
|
+ audioText,
|
|
|
+
|
|
|
+ sourceAudioName: sourceAudioFile.name,
|
|
|
+
|
|
|
+ sourceAudioFormat,
|
|
|
+
|
|
|
+ language: this.voiceForm.language || 0
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!synthesisText || !baseProfile) {
|
|
|
+
|
|
|
+ finalizeCloneFlow(
|
|
|
+
|
|
|
+ baseProfile,
|
|
|
+
|
|
|
+ 'success',
|
|
|
+
|
|
|
+ '✅ 音色复刻完成,可在列表中继续合成文本并用于数字人生成'
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>(`${this.voiceTtsBaseUrl}/unidirectional`, {
|
|
|
+
|
|
|
+ token: this.voiceApiToken,
|
|
|
+
|
|
|
+ text: synthesisText,
|
|
|
+
|
|
|
+ timbreId: baseProfile.timbre_id || '',
|
|
|
+
|
|
|
+ speaker_id: baseProfile.icl_speaker_id || baseProfile.speaker_id || '',
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ x_api_resource_id: baseProfile.x_api_resource_id || ''
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (synthesisRes) => {
|
|
|
+
|
|
|
+ const nextProfile: VoiceProfile | null = synthesisRes?.profile
|
|
|
+
|
|
|
+ ? this.hydrateVoiceProfile(synthesisRes.profile)
|
|
|
+
|
|
|
+ : this.applySynthesisResponseToProfile(baseProfile, synthesisRes, {
|
|
|
+
|
|
|
+ text: synthesisText,
|
|
|
+
|
|
|
+ ssml: '',
|
|
|
+
|
|
|
+ xApiResourceId: baseProfile.x_api_resource_id || '',
|
|
|
+
|
|
|
+ model: '',
|
|
|
+
|
|
|
+ format: 'mp3',
|
|
|
+
|
|
|
+ sampleRate: 24000,
|
|
|
+
|
|
|
+ speechRate: 0,
|
|
|
+
|
|
|
+ loudnessRate: 0,
|
|
|
+
|
|
|
+ emotion: '',
|
|
|
+
|
|
|
+ emotionScale: 4,
|
|
|
+
|
|
|
+ enableSubtitle: false,
|
|
|
+
|
|
|
+ silenceDuration: 0,
|
|
|
+
|
|
|
+ enableLanguageDetector: false,
|
|
|
+
|
|
|
+ disableMarkdownFilter: false,
|
|
|
+
|
|
|
+ disableEmojiFilter: false,
|
|
|
+
|
|
|
+ explicitLanguage: ''
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ finalizeCloneFlow(nextProfile, 'success', '✅ 音色复刻完成,并已生成首条文本音频');
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ finalizeCloneFlow(baseProfile, 'warn', '⚠️ 音色复刻完成,但首条文本合成失败,可稍后重试');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.isCreatingVoiceProfile = false;
|
|
|
+
|
|
|
+ this.voiceDesignError = this.getErrorText(err, '音色复刻失败,请检查 token、speaker_id 与音频参数');
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.voiceDesignError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }).catch(() => {
|
|
|
+
|
|
|
+ this.isCreatingVoiceProfile = false;
|
|
|
+
|
|
|
+ this.voiceDesignError = '音频文件读取失败,请重新选择音频样本';
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.voiceDesignError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private createVoiceDesignProfile(): void {
|
|
|
+
|
|
|
+ const speakerId = this.voiceForm.speakerId.trim();
|
|
|
+
|
|
|
+ const displayName = this.voiceForm.displayName.trim();
|
|
|
+
|
|
|
+ const sampleText = this.voiceForm.sampleText.trim();
|
|
|
+
|
|
|
+ const textPrompt = this.voiceForm.textPrompt.trim();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!displayName) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请输入音色名称';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!speakerId) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请输入音色代号 speaker_id';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!sampleText) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请输入试听文本';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (sampleText.length > 300) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '试听文本不能超过 300 字';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (textPrompt.length > 200) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '文本提示词不能超过 200 字';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!textPrompt && !this.voiceDesignImageFile) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '文本提示词与图片提示不能同时为空';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.isCreatingVoiceProfile = true;
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const formData = new FormData();
|
|
|
+
|
|
|
+ formData.append('speakerId', speakerId);
|
|
|
+
|
|
|
+ formData.append('displayName', displayName);
|
|
|
+
|
|
|
+ formData.append('sampleText', sampleText);
|
|
|
+
|
|
|
+ formData.append('textPrompt', textPrompt);
|
|
|
+
|
|
|
+ formData.append('language', String(this.voiceForm.language || 0));
|
|
|
+
|
|
|
+ if (this.voiceDesignImageFile) {
|
|
|
+
|
|
|
+ formData.append('image', this.voiceDesignImageFile, this.voiceDesignImageFile.name);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/voice/design', formData).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ const profile: VoiceProfile | null = res?.profile
|
|
|
+
|
|
|
+ ? this.hydrateVoiceProfile(res.profile)
|
|
|
+
|
|
|
+ : null;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (profile) {
|
|
|
+
|
|
|
+ this.upsertLocalVoiceProfile(profile);
|
|
|
+
|
|
|
+ if (this.getVoiceProfileAudioUrl(profile)) {
|
|
|
+
|
|
|
+ this.preferredVoiceProfileId = profile.id;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.resetVoiceCreateForm();
|
|
|
+
|
|
|
+ this.isCreatingVoiceProfile = false;
|
|
|
+
|
|
|
+ this.showToast('✅ 音色设计完成,可直接试听并用于数字人生成', 'success', 5000);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.isCreatingVoiceProfile = false;
|
|
|
+
|
|
|
+ this.voiceDesignError = this.getErrorText(err, '音色设计失败,请检查 speaker_id、提示词与图片参数');
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.voiceDesignError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ submitVoiceSynthesisForm(): void {
|
|
|
+
|
|
|
+ const profile = this.voiceProfiles.find((item) => item.id === this.voiceSynthesisForm.profileId) || null;
|
|
|
+
|
|
|
+ const manualVoiceId = String(this.voiceSynthesisForm.speakerId || '').trim();
|
|
|
+
|
|
|
+ if (!profile && !manualVoiceId) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请选择已有音色,或直接输入 timbreId / speaker_id';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!profile) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ if (this.isVoiceSpeakerIdentifier(manualVoiceId)) {
|
|
|
+
|
|
|
+ this.synthesizeVoiceBySpeakerId(manualVoiceId);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const manualProfile = this.hydrateVoiceProfile({
|
|
|
+
|
|
|
+ id: `manual-${manualVoiceId}`,
|
|
|
+
|
|
|
+ name: `手动音色 ${manualVoiceId}`,
|
|
|
+
|
|
|
+ creation_mode: 'manual',
|
|
|
+
|
|
|
+ speaker_id: '',
|
|
|
+
|
|
|
+ timbre_id: manualVoiceId,
|
|
|
+
|
|
|
+ sample_text: '',
|
|
|
+
|
|
|
+ source_audio_text: '',
|
|
|
+
|
|
|
+ source_audio_name: '',
|
|
|
+
|
|
|
+ source_audio_format: '',
|
|
|
+
|
|
|
+ language: 0,
|
|
|
+
|
|
|
+ status: null,
|
|
|
+
|
|
|
+ status_label: '手动输入',
|
|
|
+
|
|
|
+ demo_audio: '',
|
|
|
+
|
|
|
+ x_api_resource_id: String(this.voiceSynthesisForm.xApiResourceId || '').trim(),
|
|
|
+
|
|
|
+ model_version: String(this.voiceSynthesisForm.model || '').trim(),
|
|
|
+
|
|
|
+ icl_speaker_id: '',
|
|
|
+
|
|
|
+ occupied: false,
|
|
|
+
|
|
|
+ created_at: new Date().toISOString(),
|
|
|
+
|
|
|
+ updated_at: new Date().toISOString()
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_text = this.voiceSynthesisForm.text;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_ssml = this.voiceSynthesisForm.ssml;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_x_api_resource_id = this.voiceSynthesisForm.xApiResourceId;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_model = this.voiceSynthesisForm.model;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_format = this.voiceSynthesisForm.format;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_sample_rate = this.voiceSynthesisForm.sampleRate;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_speech_rate = this.voiceSynthesisForm.speechRate;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_loudness_rate = this.voiceSynthesisForm.loudnessRate;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_emotion = this.voiceSynthesisForm.emotion;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_emotion_scale = this.voiceSynthesisForm.emotionScale;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_enable_subtitle = this.voiceSynthesisForm.enableSubtitle;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_silence_duration = this.voiceSynthesisForm.silenceDuration;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_enable_language_detector = this.voiceSynthesisForm.enableLanguageDetector;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_disable_markdown_filter = this.voiceSynthesisForm.disableMarkdownFilter;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_disable_emoji_filter = this.voiceSynthesisForm.disableEmojiFilter;
|
|
|
+
|
|
|
+ manualProfile.draft_synthesis_explicit_language = this.voiceSynthesisForm.explicitLanguage;
|
|
|
+
|
|
|
+ this.synthesizeVoiceProfile(manualProfile);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.voiceSynthesisForm.speakerId = String(profile.icl_speaker_id || profile.speaker_id || '').trim();
|
|
|
+
|
|
|
+ profile.draft_synthesis_text = this.voiceSynthesisForm.text;
|
|
|
+
|
|
|
+ profile.draft_synthesis_ssml = this.voiceSynthesisForm.ssml;
|
|
|
+
|
|
|
+ profile.draft_synthesis_x_api_resource_id = this.voiceSynthesisForm.xApiResourceId;
|
|
|
+
|
|
|
+ profile.draft_synthesis_model = this.voiceSynthesisForm.model;
|
|
|
+
|
|
|
+ profile.draft_synthesis_format = this.voiceSynthesisForm.format;
|
|
|
+
|
|
|
+ profile.draft_synthesis_sample_rate = this.voiceSynthesisForm.sampleRate;
|
|
|
+
|
|
|
+ profile.draft_synthesis_speech_rate = this.voiceSynthesisForm.speechRate;
|
|
|
+
|
|
|
+ profile.draft_synthesis_loudness_rate = this.voiceSynthesisForm.loudnessRate;
|
|
|
+
|
|
|
+ profile.draft_synthesis_emotion = this.voiceSynthesisForm.emotion;
|
|
|
+
|
|
|
+ profile.draft_synthesis_emotion_scale = this.voiceSynthesisForm.emotionScale;
|
|
|
+
|
|
|
+ profile.draft_synthesis_enable_subtitle = this.voiceSynthesisForm.enableSubtitle;
|
|
|
+
|
|
|
+ profile.draft_synthesis_silence_duration = this.voiceSynthesisForm.silenceDuration;
|
|
|
+
|
|
|
+ profile.draft_synthesis_enable_language_detector = this.voiceSynthesisForm.enableLanguageDetector;
|
|
|
+
|
|
|
+ profile.draft_synthesis_disable_markdown_filter = this.voiceSynthesisForm.disableMarkdownFilter;
|
|
|
+
|
|
|
+ profile.draft_synthesis_disable_emoji_filter = this.voiceSynthesisForm.disableEmojiFilter;
|
|
|
+
|
|
|
+ profile.draft_synthesis_explicit_language = this.voiceSynthesisForm.explicitLanguage;
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ this.synthesizeVoiceProfile(profile);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private synthesizeVoiceBySpeakerId(speakerId: string): void {
|
|
|
+
|
|
|
+ if (this.isDirectVoiceSynthesizing) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const text = String(this.voiceSynthesisForm.text || '').trim();
|
|
|
+
|
|
|
+ const ssml = String(this.voiceSynthesisForm.ssml || '').trim();
|
|
|
+
|
|
|
+ if (!text && !ssml) {
|
|
|
+
|
|
|
+ this.voiceDesignError = '请输入要合成的文本内容或 SSML';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const xApiResourceId = String(this.voiceSynthesisForm.xApiResourceId || '').trim();
|
|
|
+
|
|
|
+ const model = String(this.voiceSynthesisForm.model || '').trim();
|
|
|
+
|
|
|
+ const format = this.normalizeVoiceSynthesisFormat(this.voiceSynthesisForm.format);
|
|
|
+
|
|
|
+ const sampleRate = this.normalizeVoiceSynthesisSampleRate(this.voiceSynthesisForm.sampleRate);
|
|
|
+
|
|
|
+ const speechRate = this.clampVoiceSynthesisRate(this.voiceSynthesisForm.speechRate, 0);
|
|
|
+
|
|
|
+ const loudnessRate = this.clampVoiceSynthesisRate(this.voiceSynthesisForm.loudnessRate, 0);
|
|
|
+
|
|
|
+ const emotion = String(this.voiceSynthesisForm.emotion || '').trim();
|
|
|
+
|
|
|
+ const emotionScale = this.clampVoiceSynthesisEmotionScale(this.voiceSynthesisForm.emotionScale, 4);
|
|
|
+
|
|
|
+ const enableSubtitle = !!this.voiceSynthesisForm.enableSubtitle;
|
|
|
+
|
|
|
+ const silenceDuration = this.clampVoiceSynthesisSilenceDuration(this.voiceSynthesisForm.silenceDuration, 0);
|
|
|
+
|
|
|
+ const explicitLanguage = String(this.voiceSynthesisForm.explicitLanguage || '').trim();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = {
|
|
|
+
|
|
|
+ ...this.voiceSynthesisForm,
|
|
|
+
|
|
|
+ speakerId,
|
|
|
+
|
|
|
+ text,
|
|
|
+
|
|
|
+ ssml,
|
|
|
+
|
|
|
+ xApiResourceId,
|
|
|
+
|
|
|
+ model,
|
|
|
+
|
|
|
+ format,
|
|
|
+
|
|
|
+ sampleRate,
|
|
|
+
|
|
|
+ speechRate,
|
|
|
+
|
|
|
+ loudnessRate,
|
|
|
+
|
|
|
+ emotion,
|
|
|
+
|
|
|
+ emotionScale,
|
|
|
+
|
|
|
+ enableSubtitle,
|
|
|
+
|
|
|
+ silenceDuration,
|
|
|
+
|
|
|
+ explicitLanguage
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.voiceDesignError = '';
|
|
|
+
|
|
|
+ this.isDirectVoiceSynthesizing = true;
|
|
|
+
|
|
|
+ this.directVoiceSynthesisAudioUrl = '';
|
|
|
+
|
|
|
+ this.directVoiceSynthesisWorkId = '';
|
|
|
+
|
|
|
+ this.directVoiceSynthesisSpeakerId = speakerId;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>(`${this.localVoiceTtsBaseUrl}/unidirectional`, {
|
|
|
+
|
|
|
+ token: this.voiceApiToken,
|
|
|
+
|
|
|
+ speaker_id: speakerId,
|
|
|
+
|
|
|
+ text,
|
|
|
+
|
|
|
+ ssml,
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ x_api_resource_id: xApiResourceId,
|
|
|
+
|
|
|
+ model,
|
|
|
+
|
|
|
+ audio_params: {
|
|
|
+
|
|
|
+ format,
|
|
|
+
|
|
|
+ sample_rate: sampleRate,
|
|
|
+
|
|
|
+ speech_rate: speechRate,
|
|
|
+
|
|
|
+ loudness_rate: loudnessRate,
|
|
|
+
|
|
|
+ emotion,
|
|
|
+
|
|
|
+ emotion_scale: emotionScale,
|
|
|
+
|
|
|
+ enable_subtitle: enableSubtitle
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ additions: {
|
|
|
+
|
|
|
+ silence_duration: silenceDuration,
|
|
|
+
|
|
|
+ enable_language_detector: !!this.voiceSynthesisForm.enableLanguageDetector,
|
|
|
+
|
|
|
+ disable_markdown_filter: !!this.voiceSynthesisForm.disableMarkdownFilter,
|
|
|
+
|
|
|
+ disable_emoji_filter: !!this.voiceSynthesisForm.disableEmojiFilter,
|
|
|
+
|
|
|
+ ...(explicitLanguage ? { explicit_language: explicitLanguage } : {})
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ this.isDirectVoiceSynthesizing = false;
|
|
|
+
|
|
|
+ this.directVoiceSynthesisAudioUrl = String(res?.data?.audioUrl || '').trim();
|
|
|
+
|
|
|
+ this.directVoiceSynthesisWorkId = String(res?.data?.workId || '').trim();
|
|
|
+
|
|
|
+ this.directVoiceSynthesisSpeakerId = String(res?.speakerId || speakerId).trim();
|
|
|
+
|
|
|
+ if (res?.profile) {
|
|
|
+
|
|
|
+ const nextProfile = this.hydrateVoiceProfile(res.profile);
|
|
|
+
|
|
|
+ this.upsertLocalVoiceProfile(nextProfile);
|
|
|
+
|
|
|
+ this.syncVoiceProfileToBackend(nextProfile);
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ const matchedProfile = this.voiceProfiles.find((item) => (
|
|
|
+
|
|
|
+ String(item.icl_speaker_id || '').trim() === speakerId
|
|
|
+
|
|
|
+ || String(item.speaker_id || '').trim() === speakerId
|
|
|
+
|
|
|
+ ));
|
|
|
+
|
|
|
+ if (matchedProfile) {
|
|
|
+
|
|
|
+ const nextProfile = this.applySynthesisResponseToProfile(matchedProfile, res, {
|
|
|
+
|
|
|
+ text,
|
|
|
+
|
|
|
+ ssml,
|
|
|
+
|
|
|
+ xApiResourceId,
|
|
|
+
|
|
|
+ model,
|
|
|
+
|
|
|
+ format,
|
|
|
+
|
|
|
+ sampleRate,
|
|
|
+
|
|
|
+ speechRate,
|
|
|
+
|
|
|
+ loudnessRate,
|
|
|
+
|
|
|
+ emotion,
|
|
|
+
|
|
|
+ emotionScale,
|
|
|
+
|
|
|
+ enableSubtitle,
|
|
|
+
|
|
|
+ silenceDuration,
|
|
|
+
|
|
|
+ enableLanguageDetector: !!this.voiceSynthesisForm.enableLanguageDetector,
|
|
|
+
|
|
|
+ disableMarkdownFilter: !!this.voiceSynthesisForm.disableMarkdownFilter,
|
|
|
+
|
|
|
+ disableEmojiFilter: !!this.voiceSynthesisForm.disableEmojiFilter,
|
|
|
+
|
|
|
+ explicitLanguage
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ this.upsertLocalVoiceProfile(nextProfile);
|
|
|
+
|
|
|
+ this.syncVoiceProfileToBackend(nextProfile);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.showToast(`✅ 已使用 speaker_id「${this.directVoiceSynthesisSpeakerId}」生成语音`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.isDirectVoiceSynthesizing = false;
|
|
|
+
|
|
|
+ this.voiceDesignError = this.getErrorText(err, '文本合成失败');
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.voiceDesignError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ synthesizeVoiceProfile(profile: VoiceProfile): void {
|
|
|
+
|
|
|
+ if (profile.is_synthesizing) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!this.canSynthesizeVoiceProfile(profile)) {
|
|
|
+
|
|
|
+ this.showToast('❌ 该音色记录缺少可用于语音合成的 speaker 标识,暂时无法进行文本合成', 'error');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const text = String(profile.draft_synthesis_text || '').trim();
|
|
|
+
|
|
|
+ const ssml = String(profile.draft_synthesis_ssml || '').trim();
|
|
|
+
|
|
|
+ if (!text && !ssml) {
|
|
|
+
|
|
|
+ this.showToast('❌ 请输入要合成的文本内容或 SSML', 'error');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const xApiResourceId = String(profile.draft_synthesis_x_api_resource_id || profile.x_api_resource_id || '').trim();
|
|
|
+
|
|
|
+ const model = String(profile.draft_synthesis_model || '').trim();
|
|
|
+
|
|
|
+ const format = this.normalizeVoiceSynthesisFormat(profile.draft_synthesis_format);
|
|
|
+
|
|
|
+ const sampleRate = this.normalizeVoiceSynthesisSampleRate(profile.draft_synthesis_sample_rate);
|
|
|
+
|
|
|
+ const speechRate = this.clampVoiceSynthesisRate(profile.draft_synthesis_speech_rate, 0);
|
|
|
+
|
|
|
+ const loudnessRate = this.clampVoiceSynthesisRate(profile.draft_synthesis_loudness_rate, 0);
|
|
|
+
|
|
|
+ const emotion = String(profile.draft_synthesis_emotion || '').trim();
|
|
|
+
|
|
|
+ const emotionScale = this.clampVoiceSynthesisEmotionScale(profile.draft_synthesis_emotion_scale, 4);
|
|
|
+
|
|
|
+ const enableSubtitle = !!profile.draft_synthesis_enable_subtitle;
|
|
|
+
|
|
|
+ const silenceDuration = this.clampVoiceSynthesisSilenceDuration(profile.draft_synthesis_silence_duration, 0);
|
|
|
+
|
|
|
+ const explicitLanguage = String(profile.draft_synthesis_explicit_language || '').trim();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ profile.is_synthesizing = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>(`${this.voiceTtsBaseUrl}/unidirectional`, {
|
|
|
+
|
|
|
+ token: this.voiceApiToken,
|
|
|
+
|
|
|
+ text,
|
|
|
+
|
|
|
+ ssml,
|
|
|
+
|
|
|
+ timbreId: profile.timbre_id || '',
|
|
|
+
|
|
|
+ speaker_id: profile.icl_speaker_id || profile.speaker_id || '',
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ x_api_resource_id: xApiResourceId,
|
|
|
+
|
|
|
+ model,
|
|
|
+
|
|
|
+ audio_params: {
|
|
|
+
|
|
|
+ format,
|
|
|
+
|
|
|
+ sample_rate: sampleRate,
|
|
|
+
|
|
|
+ speech_rate: speechRate,
|
|
|
+
|
|
|
+ loudness_rate: loudnessRate,
|
|
|
+
|
|
|
+ emotion,
|
|
|
+
|
|
|
+ emotion_scale: emotionScale,
|
|
|
+
|
|
|
+ enable_subtitle: enableSubtitle
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ additions: {
|
|
|
+
|
|
|
+ silence_duration: silenceDuration,
|
|
|
+
|
|
|
+ enable_language_detector: !!profile.draft_synthesis_enable_language_detector,
|
|
|
+
|
|
|
+ disable_markdown_filter: !!profile.draft_synthesis_disable_markdown_filter,
|
|
|
+
|
|
|
+ disable_emoji_filter: !!profile.draft_synthesis_disable_emoji_filter,
|
|
|
+
|
|
|
+ ...(explicitLanguage ? { explicit_language: explicitLanguage } : {})
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ const nextProfile = res?.profile
|
|
|
+
|
|
|
+ ? this.hydrateVoiceProfile(res.profile)
|
|
|
+
|
|
|
+ : this.applySynthesisResponseToProfile(profile, res, {
|
|
|
+
|
|
|
+ text,
|
|
|
+
|
|
|
+ ssml,
|
|
|
+
|
|
|
+ xApiResourceId,
|
|
|
+
|
|
|
+ model,
|
|
|
+
|
|
|
+ format,
|
|
|
+
|
|
|
+ sampleRate,
|
|
|
+
|
|
|
+ speechRate,
|
|
|
+
|
|
|
+ loudnessRate,
|
|
|
+
|
|
|
+ emotion,
|
|
|
+
|
|
|
+ emotionScale,
|
|
|
+
|
|
|
+ enableSubtitle,
|
|
|
+
|
|
|
+ silenceDuration,
|
|
|
+
|
|
|
+ enableLanguageDetector: !!profile.draft_synthesis_enable_language_detector,
|
|
|
+
|
|
|
+ disableMarkdownFilter: !!profile.draft_synthesis_disable_markdown_filter,
|
|
|
+
|
|
|
+ disableEmojiFilter: !!profile.draft_synthesis_disable_emoji_filter,
|
|
|
+
|
|
|
+ explicitLanguage
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_text = text;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_ssml = ssml;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_x_api_resource_id = xApiResourceId;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_model = model;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_format = format;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_sample_rate = sampleRate;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_speech_rate = speechRate;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_loudness_rate = loudnessRate;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_emotion = emotion;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_emotion_scale = emotionScale;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_enable_subtitle = enableSubtitle;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_silence_duration = silenceDuration;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_enable_language_detector = !!profile.draft_synthesis_enable_language_detector;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_disable_markdown_filter = !!profile.draft_synthesis_disable_markdown_filter;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_disable_emoji_filter = !!profile.draft_synthesis_disable_emoji_filter;
|
|
|
+
|
|
|
+ nextProfile.draft_synthesis_explicit_language = explicitLanguage;
|
|
|
+
|
|
|
+ this.upsertLocalVoiceProfile(nextProfile);
|
|
|
+
|
|
|
+ this.syncVoiceProfileToBackend(nextProfile);
|
|
|
+
|
|
|
+ if (this.voiceSynthesisForm.profileId === nextProfile.id) {
|
|
|
+
|
|
|
+ this.voiceSynthesisForm = this.buildVoiceSynthesisFormFromProfile(nextProfile);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.getVoiceProfileAudioUrl(nextProfile)) {
|
|
|
+
|
|
|
+ this.preferredVoiceProfileId = nextProfile.id;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.showToast(`✅ 已为「${nextProfile.name}」生成文本音频`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ profile.is_synthesizing = false;
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.getErrorText(err, '文本合成失败')}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ removeVoiceProfile(profile: VoiceProfile): void {
|
|
|
+
|
|
|
+ if (!confirm(`确定要删除音色「${profile.name}」吗?`)) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.delete<any>(`/backend/voice-profiles/${profile.id}`).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ this.voiceProfiles = this.voiceProfiles.filter(item => item.id !== profile.id);
|
|
|
+
|
|
|
+ this.syncVoiceSynthesisFormSelection();
|
|
|
+
|
|
|
+ if (this.preferredVoiceProfileId === profile.id) {
|
|
|
+
|
|
|
+ this.preferredVoiceProfileId = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.showToast(`🗑️ 已删除音色「${profile.name}」`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.getErrorText(err, '删除音色失败')}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onRemixDigitalHumanImageSelected(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'];
|
|
|
+
|
|
|
+ const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
|
|
+
|
|
|
+ const allowedExts = ['.jpg', '.jpeg', '.png', '.gif'];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if ((!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) || file.size <= 0) {
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '仅支持 jpg、jpeg、png、gif 格式的形象图';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (file.size > 20 * 1024 * 1024) {
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '形象图大小不能超过 20MB';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.revokeDigitalHumanPreviewUrl();
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageFile = file;
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageName = file.name;
|
|
|
+
|
|
|
+ this.remixDigitalHumanImagePreviewUrl = URL.createObjectURL(file);
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageAssetUrl = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ clearRemixDigitalHumanImage(): void {
|
|
|
+
|
|
|
+ this.revokeDigitalHumanPreviewUrl();
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageFile = null;
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageName = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageAssetUrl = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ goToRemixGenerationConfig(): void {
|
|
|
+
|
|
|
+ if (this.remixMode === 'digital-human') {
|
|
|
+
|
|
|
+ if (!this.remixDigitalHumanImageFile) {
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '请先上传数字人形象图';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.remixStep = 4;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.confirmTranscript();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private revokeDigitalHumanPreviewUrl(): void {
|
|
|
+
|
|
|
+ if (this.remixDigitalHumanImagePreviewUrl) {
|
|
|
+
|
|
|
+ URL.revokeObjectURL(this.remixDigitalHumanImagePreviewUrl);
|
|
|
+
|
|
|
+ this.remixDigitalHumanImagePreviewUrl = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private resetDigitalHumanState(): void {
|
|
|
+
|
|
|
+ this.revokeDigitalHumanPreviewUrl();
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageFile = null;
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageName = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageAssetUrl = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanPrompt = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanFastMode = false;
|
|
|
+
|
|
|
+ this.remixDigitalHumanResolution = '1080p';
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanAudioSource = 'tts';
|
|
|
+
|
|
|
+ this.remixDigitalHumanTtsText = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanTtsTimbreId = '';
|
|
|
+
|
|
|
+ this.remixDigitalHumanTtsSynthesizing = false;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 加载该视频关联的 Whisper 处理文件(通过后端读取)
|
|
|
+
|
|
|
+ loadWhisperFiles(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ if (!video.whisper) {
|
|
|
+
|
|
|
+ console.log('ℹ️ 该视频无 Whisper 转录数据,跳过自动加载');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const w = video.whisper;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 优先加载美化文字稿,其次原始转录
|
|
|
+
|
|
|
+ const transcriptPath = w.beautified || w.transcript;
|
|
|
+
|
|
|
+ if (transcriptPath) {
|
|
|
+
|
|
|
+ this.http.get(`/backend/files/read`, { params: { path: transcriptPath }, responseType: 'text' }).subscribe({
|
|
|
+
|
|
|
+ next: (text) => {
|
|
|
+
|
|
|
+ this.remixTranscript = text;
|
|
|
+
|
|
|
+ if (w.beautified) this.remixBeautifiedTranscript = text;
|
|
|
+
|
|
|
+ console.log(`📄 已加载 Whisper 文字稿: ${transcriptPath}`);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => console.log(`⚠️ 未能加载文字稿: ${transcriptPath}`)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 加载分镜脚本
|
|
|
+
|
|
|
+ if (w.storyboard) {
|
|
|
+
|
|
|
+ this.http.get<any>(`/backend/files/read`, { params: { path: w.storyboard } }).subscribe({
|
|
|
+
|
|
|
+ next: (data: any) => {
|
|
|
+
|
|
|
+ if (Array.isArray(data)) {
|
|
|
+
|
|
|
+ this.remixStoryboard = data.map((seg: any) => ({
|
|
|
+
|
|
|
+ id: seg.id,
|
|
|
+
|
|
|
+ act: seg.act,
|
|
|
+
|
|
|
+ narration: seg.narration,
|
|
|
+
|
|
|
+ prompt: seg.prompt,
|
|
|
+
|
|
|
+ duration: seg.duration,
|
|
|
+
|
|
|
+ status: 'pending' as const
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ console.log(`📝 已加载分镜脚本: ${this.remixStoryboard.length} 个片段`);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => console.log(`⚠️ 未能加载分镜脚本: ${w.storyboard}`)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 下一步
|
|
|
+
|
|
|
+ nextRemixStep(): void {
|
|
|
+
|
|
|
+ if (this.remixStep < 6) this.remixStep++;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 上一步
|
|
|
+
|
|
|
+ prevRemixStep(): void {
|
|
|
+
|
|
|
+ if (this.remixStep > 1 && !this.isRemixSubmitting) this.remixStep--;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 使用 Whisper 生成文字稿(通过本地后端)
|
|
|
+
|
|
|
+ generateTranscript(): void {
|
|
|
+
|
|
|
+ if (!this.remixTargetVideo || this.isWhisperRunning) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const video = this.remixTargetVideo;
|
|
|
+
|
|
|
+ this.isWhisperRunning = true;
|
|
|
+
|
|
|
+ this.whisperStatusText = '正在检查 Whisper 服务...';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 先检查后端是否可用
|
|
|
+
|
|
|
+ this.http.get<any>('/backend/api/health').subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ // 后端可用,调用 Whisper 转录
|
|
|
+
|
|
|
+ this.whisperStatusText = '正在调用 Whisper 语音识别,视频较长时可能需要数分钟...';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/whisper/transcribe', {
|
|
|
+
|
|
|
+ videoId: video.id,
|
|
|
+
|
|
|
+ language: 'Chinese',
|
|
|
+
|
|
|
+ model: 'base'
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ if (res?.success && res?.transcript) {
|
|
|
+
|
|
|
+ this.remixTranscript = res.transcript;
|
|
|
+
|
|
|
+ this.whisperStatusText = '✅ 识别完成!';
|
|
|
+
|
|
|
+ this.isWhisperRunning = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 后端已自动更新 manifest,同步前端的 whisper 字段
|
|
|
+
|
|
|
+ if (res.whisper) {
|
|
|
+
|
|
|
+ video.whisper = res.whisper;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('✅ Whisper 转录完成,manifest 已更新');
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.whisperStatusText = '识别完成但未返回文字稿';
|
|
|
+
|
|
|
+ this.isWhisperRunning = false;
|
|
|
+
|
|
|
+ this.showManualTranscriptInput = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.error('Whisper 转录失败:', err);
|
|
|
+
|
|
|
+ const msg = err?.error?.error || err?.error?.hint || '转录过程出错';
|
|
|
+
|
|
|
+ this.whisperStatusText = `❌ ${msg}`;
|
|
|
+
|
|
|
+ this.isWhisperRunning = false;
|
|
|
+
|
|
|
+ this.showManualTranscriptInput = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ // 后端不可用,降级为手动方案
|
|
|
+
|
|
|
+ this.onWhisperFallback(video);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 后端不可用时的降级方案
|
|
|
+
|
|
|
+ private onWhisperFallback(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ this.isWhisperRunning = false;
|
|
|
+
|
|
|
+ this.whisperStatusText = '';
|
|
|
+
|
|
|
+ const cmd = `whisper "data/videos/${video.filename}" --model base --language Chinese --output_dir "Whisper"`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ navigator.clipboard.writeText(cmd).then(() => {
|
|
|
+
|
|
|
+ alert(
|
|
|
+
|
|
|
+ `后端服务未启动。请先在终端运行:\n\nnpm run server\n\n` +
|
|
|
+
|
|
|
+ `或手动执行 Whisper 命令(已复制到剪贴板):\n\n${cmd}`
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ }).catch(() => {
|
|
|
+
|
|
|
+ alert(`后端服务未启动。请先运行 npm run server,或手动执行:\n\n${cmd}`);
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ this.showManualTranscriptInput = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 1 → 2: 确认文字稿,进入AI美化
|
|
|
+
|
|
|
+ confirmTranscript(): void {
|
|
|
+
|
|
|
+ if (!this.remixTranscript.trim()) return;
|
|
|
+
|
|
|
+ this.remixStep = 2;
|
|
|
+
|
|
|
+ // 自动执行AI美化
|
|
|
+
|
|
|
+ this.beautifyTranscript();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 2: AI美化文字稿(当前为本地处理,后续可接入LLM API)
|
|
|
+
|
|
|
+ beautifyTranscript(): void {
|
|
|
+
|
|
|
+ let text = this.remixTranscript;
|
|
|
+
|
|
|
+ // 去除口语化词汇
|
|
|
+
|
|
|
+ const fillerWords = ['嗯', '啊', '呢', '吧', '哦', '额', '那个', '就是说', '然后呢', '对吧'];
|
|
|
+
|
|
|
+ fillerWords.forEach(w => { text = text.replace(new RegExp(w, 'g'), ''); });
|
|
|
+
|
|
|
+ // 去除多余空格和空行
|
|
|
+
|
|
|
+ text = text.replace(/\s+/g, ' ').trim();
|
|
|
+
|
|
|
+ // 按句号/问号/感叹号分句,重新组织段落
|
|
|
+
|
|
|
+ const sentences = text.split(/(?<=[。!?.!?])/g).filter(s => s.trim());
|
|
|
+
|
|
|
+ const paragraphs: string[] = [];
|
|
|
+
|
|
|
+ let current = '';
|
|
|
+
|
|
|
+ sentences.forEach(s => {
|
|
|
+
|
|
|
+ current += s.trim();
|
|
|
+
|
|
|
+ if (current.length >= 60) {
|
|
|
+
|
|
|
+ paragraphs.push(current);
|
|
|
+
|
|
|
+ current = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ if (current) paragraphs.push(current);
|
|
|
+
|
|
|
+ this.remixBeautifiedTranscript = paragraphs.join('\n\n');
|
|
|
+
|
|
|
+ if (!this.remixBeautifiedTranscript.trim()) {
|
|
|
+
|
|
|
+ this.remixBeautifiedTranscript = this.remixTranscript;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 2 → 3: 确认美化文字稿,生成分镜脚本
|
|
|
+
|
|
|
+ confirmBeautified(): void {
|
|
|
+
|
|
|
+ if (!this.remixBeautifiedTranscript.trim()) return;
|
|
|
+
|
|
|
+ this.generateStoryboard();
|
|
|
+
|
|
|
+ this.remixStep = 3;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 3: 根据美化文字稿生成分镜脚本(参考SKILL.md Stage 1)
|
|
|
+
|
|
|
+ generateStoryboard(): void {
|
|
|
+
|
|
|
+ const text = this.remixBeautifiedTranscript;
|
|
|
+
|
|
|
+ // 按约40-45个中文字(≈15秒语速)拆分为分镜段
|
|
|
+
|
|
|
+ const segmentCharLen = 42;
|
|
|
+
|
|
|
+ const segments: StoryboardSegment[] = [];
|
|
|
+
|
|
|
+ // 优先按段落分割
|
|
|
+
|
|
|
+ const paragraphs = text.split(/\n+/).filter(p => p.trim());
|
|
|
+
|
|
|
+ let charBuffer = '';
|
|
|
+
|
|
|
+ let segIndex = 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const pushSegment = (narration: string) => {
|
|
|
+
|
|
|
+ segIndex++;
|
|
|
+
|
|
|
+ const id = `S-${String(segIndex).padStart(2, '0')}`;
|
|
|
+
|
|
|
+ const act = segIndex === 1 ? '开场' : (segIndex <= 3 ? '引入' : '正文');
|
|
|
+
|
|
|
+ const stylePrompt = this.remixStyleOptions.find(s => s.id === this.remixStyle)?.prompt || '';
|
|
|
+
|
|
|
+ // 生成英文提示词:风格 + 场景描述
|
|
|
+
|
|
|
+ const prompt = `${stylePrompt}, cinematic scene depicting: ${narration.substring(0, 80)}`;
|
|
|
+
|
|
|
+ segments.push({
|
|
|
+
|
|
|
+ id,
|
|
|
+
|
|
|
+ act,
|
|
|
+
|
|
|
+ narration: narration.trim(),
|
|
|
+
|
|
|
+ prompt,
|
|
|
+
|
|
|
+ duration: this.remixFrames === 121 ? '5s' : '10s',
|
|
|
+
|
|
|
+ status: 'pending'
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ paragraphs.forEach(para => {
|
|
|
+
|
|
|
+ charBuffer += para;
|
|
|
+
|
|
|
+ while (charBuffer.length >= segmentCharLen) {
|
|
|
+
|
|
|
+ // 尝试在句子边界切割
|
|
|
+
|
|
|
+ let cutPos = segmentCharLen;
|
|
|
+
|
|
|
+ const searchRange = charBuffer.substring(0, segmentCharLen + 15);
|
|
|
+
|
|
|
+ const punctPos = Math.max(
|
|
|
+
|
|
|
+ searchRange.lastIndexOf('。'),
|
|
|
+
|
|
|
+ searchRange.lastIndexOf('!'),
|
|
|
+
|
|
|
+ searchRange.lastIndexOf('?'),
|
|
|
+
|
|
|
+ searchRange.lastIndexOf(','),
|
|
|
+
|
|
|
+ searchRange.lastIndexOf(';')
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ if (punctPos > segmentCharLen * 0.5) cutPos = punctPos + 1;
|
|
|
+
|
|
|
+ pushSegment(charBuffer.substring(0, cutPos));
|
|
|
+
|
|
|
+ charBuffer = charBuffer.substring(cutPos);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ // 剩余部分
|
|
|
+
|
|
|
+ if (charBuffer.trim().length > 0) {
|
|
|
+
|
|
|
+ pushSegment(charBuffer);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.remixStoryboard = segments;
|
|
|
+
|
|
|
+ console.log(`📝 分镜脚本生成完毕:${segments.length} 个片段`);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 3 → 4: 确认分镜,进入参数配置
|
|
|
+
|
|
|
+ confirmStoryboard(): void {
|
|
|
+
|
|
|
+ if (this.remixStoryboard.length === 0) return;
|
|
|
+
|
|
|
+ this.remixStep = 4;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 4 → 5: 开始批量生成
|
|
|
+
|
|
|
+ startBatchGeneration(): void {
|
|
|
+
|
|
|
+ if (this.isRemixSubmitting) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.remixMode === 'digital-human') {
|
|
|
+
|
|
|
+ this.startDigitalHumanGeneration();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.remixStoryboard.length === 0) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const video = this.remixTargetVideo!;
|
|
|
+
|
|
|
+ const styleName = this.remixStyleOptions.find(s => s.id === this.remixStyle)?.name || this.remixStyle;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.isRemixSubmitting = true;
|
|
|
+
|
|
|
+ this.remixStep = 5;
|
|
|
+
|
|
|
+ video.isRemixing = true;
|
|
|
+
|
|
|
+ video.remixProgress = 0;
|
|
|
+
|
|
|
+ video.remixStyle = styleName;
|
|
|
+
|
|
|
+ this.remixResults = { total: this.remixStoryboard.length, success: 0, failed: 0 };
|
|
|
+
|
|
|
+ this.remixStatusText = '正在提交生成任务...';
|
|
|
+
|
|
|
+ this.currentRemixId = `REMIX-${Date.now()}`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 创建持久化任务
|
|
|
+
|
|
|
+ const newTask: Task = {
|
|
|
+
|
|
|
+ id: `TASK-${Date.now()}`,
|
|
|
+
|
|
|
+ keyword: `${video.title} - ${styleName}`,
|
|
|
+
|
|
|
+ status: 'processing',
|
|
|
+
|
|
|
+ step: 'generate',
|
|
|
+
|
|
|
+ progress: 0,
|
|
|
+
|
|
|
+ cost: 0,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ updated_at: new Date(),
|
|
|
+
|
|
|
+ original_video_title: video.title,
|
|
|
+
|
|
|
+ duration: `${this.remixStoryboard.length} 个片段`
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.activeTasks.unshift(newTask);
|
|
|
+
|
|
|
+ this.currentRemixTaskId = newTask.id;
|
|
|
+
|
|
|
+ this.saveTask(newTask);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 显示 toast 并关闭模态框
|
|
|
+
|
|
|
+ this.showToast(`任务已提交,可在「任务管理」中查看进度`, 'success');
|
|
|
+
|
|
|
+ this.showRemixModal = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ console.log(`🎨 开始批量生成 ${this.remixStoryboard.length} 个分镜视频, 任务ID: ${newTask.id}`);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 逐个提交(串行,避免频率限制)
|
|
|
+
|
|
|
+ this.generateSegmentSequentially(0, video, styleName);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private startDigitalHumanGeneration(): void {
|
|
|
+
|
|
|
+ if (!this.remixTargetVideo || !this.remixDigitalHumanImageFile || this.isRemixSubmitting) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const video = this.remixTargetVideo;
|
|
|
+
|
|
|
+ const isTtsMode = this.remixDigitalHumanAudioSource === 'tts';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!isTtsMode) {
|
|
|
+
|
|
|
+ const safeDuration = Number(video.duration || 0);
|
|
|
+
|
|
|
+ if (safeDuration > 60) {
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '原视频音频时长超过 60 秒,当前数字人接口不支持';
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.remixDigitalHumanError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (isTtsMode) {
|
|
|
+
|
|
|
+ if (!this.remixDigitalHumanTtsTimbreId.trim()) {
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '请输入或选择音色 ID(可在「语音合成」标签页中复刻获取)';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.remixDigitalHumanTtsText.trim()) {
|
|
|
+
|
|
|
+ this.remixDigitalHumanError = '请输入要合成的语音文本';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const styleName = '数字人生成';
|
|
|
+
|
|
|
+ this.isRemixSubmitting = true;
|
|
|
+
|
|
|
+ this.remixStep = 5;
|
|
|
+
|
|
|
+ video.isRemixing = true;
|
|
|
+
|
|
|
+ video.remixProgress = 0;
|
|
|
+
|
|
|
+ video.remixStyle = styleName;
|
|
|
+
|
|
|
+ this.remixResults = { total: 1, success: 0, failed: 0 };
|
|
|
+
|
|
|
+ this.remixStatusText = '正在准备数字人素材...';
|
|
|
+
|
|
|
+ this.remixStitchStatus = 'idle';
|
|
|
+
|
|
|
+ this.remixStitchError = '';
|
|
|
+
|
|
|
+ this.remixFinalVideoUrl = '';
|
|
|
+
|
|
|
+ this.currentRemixId = `REMIX-${Date.now()}`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const newTask: Task = {
|
|
|
+
|
|
|
+ id: `TASK-${Date.now()}`,
|
|
|
+
|
|
|
+ keyword: `${video.title} - ${styleName}`,
|
|
|
+
|
|
|
+ status: 'processing',
|
|
|
+
|
|
|
+ step: 'generate',
|
|
|
+
|
|
|
+ progress: 0,
|
|
|
+
|
|
|
+ cost: 0,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ updated_at: new Date(),
|
|
|
+
|
|
|
+ original_video_title: video.title,
|
|
|
+
|
|
|
+ duration: '数字人视频'
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.activeTasks.unshift(newTask);
|
|
|
+
|
|
|
+ this.currentRemixTaskId = newTask.id;
|
|
|
+
|
|
|
+ this.saveTask(newTask);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.showToast('数字人任务已提交,可在「任务管理」中查看进度', 'success');
|
|
|
+
|
|
|
+ this.showRemixModal = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.jimengService.uploadFileToParse(
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageFile,
|
|
|
+
|
|
|
+ `digital-human-${Date.now()}-${this.remixDigitalHumanImageFile.name}`,
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageFile.type || 'image/jpeg'
|
|
|
+
|
|
|
+ ).subscribe({
|
|
|
+
|
|
|
+ next: (imageUrl: string) => {
|
|
|
+
|
|
|
+ this.remixDigitalHumanImageAssetUrl = imageUrl;
|
|
|
+
|
|
|
+ video.remixProgress = 10;
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', 10);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (isTtsMode) {
|
|
|
+
|
|
|
+ this.remixStatusText = '形象图上传完成,正在合成语音...';
|
|
|
+
|
|
|
+ this.remixDigitalHumanTtsSynthesizing = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('https://server.fmode.cn/api/volcengine/tts/unidirectional', {
|
|
|
+
|
|
|
+ token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600',
|
|
|
+
|
|
|
+ text: this.remixDigitalHumanTtsText.trim(),
|
|
|
+
|
|
|
+ timbreId: this.remixDigitalHumanTtsTimbreId.trim(),
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ audio_params: {
|
|
|
+
|
|
|
+ format: 'mp3',
|
|
|
+
|
|
|
+ sample_rate: 24000,
|
|
|
+
|
|
|
+ speech_rate: 0,
|
|
|
+
|
|
|
+ loudness_rate: 0
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ this.remixDigitalHumanTtsSynthesizing = false;
|
|
|
+
|
|
|
+ if (res?.code === 200 && res?.data?.audioUrl) {
|
|
|
+
|
|
|
+ video.remixProgress = 30;
|
|
|
+
|
|
|
+ this.remixStatusText = '语音合成完成,正在识别数字人主体...';
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', 30);
|
|
|
+
|
|
|
+ this.startDigitalHumanGenerationWithAudio(video, imageUrl, res.data.audioUrl);
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, res?.error?.message || '语音合成失败,请检查音色ID和文本');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.remixDigitalHumanTtsSynthesizing = false;
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, this.getErrorText(err, '语音合成请求失败'));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.remixStatusText = '形象图上传完成,正在提取原视频音频...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post('/backend/api/remix/extract-audio', {
|
|
|
+
|
|
|
+ videoId: video.id
|
|
|
+
|
|
|
+ }, {
|
|
|
+
|
|
|
+ responseType: 'blob'
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (audioBlob: Blob) => {
|
|
|
+
|
|
|
+ const audioFile = new File(
|
|
|
+
|
|
|
+ [audioBlob],
|
|
|
+
|
|
|
+ `${video.filename.replace(/\.[^.]+$/, '') || 'source-audio'}.wav`,
|
|
|
+
|
|
|
+ { type: audioBlob.type || 'audio/wav' }
|
|
|
+
|
|
|
+ );
|
|
|
+
|
|
|
+ video.remixProgress = 20;
|
|
|
+
|
|
|
+ this.remixStatusText = '音频提取完成,正在上传音频素材...';
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', 20);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.jimengService.uploadFileToParse(audioFile, audioFile.name, audioFile.type).subscribe({
|
|
|
+
|
|
|
+ next: (audioUrl: string) => {
|
|
|
+
|
|
|
+ video.remixProgress = 30;
|
|
|
+
|
|
|
+ this.remixStatusText = '素材准备完成,正在识别数字人主体...';
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', 30);
|
|
|
+
|
|
|
+ this.startDigitalHumanGenerationWithAudio(video, imageUrl, audioUrl);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, err?.message || '上传音频素材失败');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: async (err) => {
|
|
|
+
|
|
|
+ const message = await this.extractHttpBlobErrorMessage(err, '提取原视频音频失败,请确认已安装 ffmpeg');
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, message);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, err?.message || '上传数字人形象图失败');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private startDigitalHumanGenerationWithAudio(video: ManagedVideo, imageUrl: string, audioUrl: string): void {
|
|
|
+
|
|
|
+ this.jimengService.identifyDigitalHuman(imageUrl).subscribe({
|
|
|
+
|
|
|
+ next: ({ workId }) => {
|
|
|
+
|
|
|
+ this.jimengService.pollDigitalHumanIdentifyUntilReady(workId, (status, progress) => {
|
|
|
+
|
|
|
+ const mappedProgress = Math.min(30 + progress * 0.2, 50);
|
|
|
+
|
|
|
+ video.remixProgress = mappedProgress;
|
|
|
+
|
|
|
+ this.remixStatusText = status;
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', mappedProgress);
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ this.remixStatusText = '主体识别完成,开始生成数字人视频...';
|
|
|
+
|
|
|
+ video.remixProgress = 50;
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', 50);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.jimengService.generateDigitalHuman(workId, audioUrl, {
|
|
|
+
|
|
|
+ prompt: this.remixDigitalHumanPrompt,
|
|
|
+
|
|
|
+ peFastMode: this.remixDigitalHumanResolution === '720p' ? true : this.remixDigitalHumanFastMode,
|
|
|
+
|
|
|
+ outputResolution: this.remixDigitalHumanResolution,
|
|
|
+
|
|
|
+ maskUrl: []
|
|
|
+
|
|
|
+ }, (status, progress) => {
|
|
|
+
|
|
|
+ const mappedProgress = Math.min(50 + progress * 0.45, 95);
|
|
|
+
|
|
|
+ video.remixProgress = mappedProgress;
|
|
|
+
|
|
|
+ this.remixStatusText = status;
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', mappedProgress);
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ video.remixProgress = 100;
|
|
|
+
|
|
|
+ this.remixResults.success = 1;
|
|
|
+
|
|
|
+ this.remixFinalVideoUrl = result.videoUrl;
|
|
|
+
|
|
|
+ this.remixStatusText = '数字人视频生成完成';
|
|
|
+
|
|
|
+ this.createDigitalHumanVersion(video, result.videoUrl);
|
|
|
+
|
|
|
+ this.completeRemixTask('completed', result.videoUrl);
|
|
|
+
|
|
|
+ this.saveRemixSession('completed');
|
|
|
+
|
|
|
+ video.isRemixing = false;
|
|
|
+
|
|
|
+ this.isRemixSubmitting = false;
|
|
|
+
|
|
|
+ this.remixStep = 6;
|
|
|
+
|
|
|
+ this.showToast(`✅ 数字人视频已生成:${video.title}`, 'success', 6000);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, err?.message || '数字人生成失败');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, err?.message || '数字人主体识别失败');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.handleDigitalHumanGenerationError(video, err?.message || '提交数字人主体识别失败');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private handleDigitalHumanGenerationError(video: ManagedVideo, message: string): void {
|
|
|
+
|
|
|
+ video.isRemixing = false;
|
|
|
+
|
|
|
+ video.remixProgress = 0;
|
|
|
+
|
|
|
+ this.isRemixSubmitting = false;
|
|
|
+
|
|
|
+ this.remixResults.failed = 1;
|
|
|
+
|
|
|
+ this.remixStep = 6;
|
|
|
+
|
|
|
+ this.remixStatusText = message;
|
|
|
+
|
|
|
+ this.remixStitchStatus = 'failed';
|
|
|
+
|
|
|
+ this.remixStitchError = message;
|
|
|
+
|
|
|
+ this.completeRemixTask('failed', undefined, message);
|
|
|
+
|
|
|
+ this.saveRemixSession('failed');
|
|
|
+
|
|
|
+ this.showToast(`❌ ${message}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 串行生成每个分镜段的视频
|
|
|
+
|
|
|
+ private generateSegmentSequentially(index: number, video: ManagedVideo, styleName: string): void {
|
|
|
+
|
|
|
+ if (index >= this.remixStoryboard.length) {
|
|
|
+
|
|
|
+ // 全部完成
|
|
|
+
|
|
|
+ this.onBatchGenerationComplete(video, styleName);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const segment = this.remixStoryboard[index];
|
|
|
+
|
|
|
+ segment.status = 'generating';
|
|
|
+
|
|
|
+ const totalSegments = this.remixStoryboard.length;
|
|
|
+
|
|
|
+ this.remixStatusText = `正在生成第 ${index + 1}/${totalSegments} 个片段...`;
|
|
|
+
|
|
|
+ video.remixProgress = (index / totalSegments) * 100;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 更新任务进度
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', (index / totalSegments) * 80);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.jimengService.remixVideo(
|
|
|
+
|
|
|
+ segment.prompt,
|
|
|
+
|
|
|
+ {
|
|
|
+
|
|
|
+ method: '1',
|
|
|
+
|
|
|
+ frames: this.remixFrames,
|
|
|
+
|
|
|
+ aspectRatio: this.remixAspectRatio,
|
|
|
+
|
|
|
+ quality: this.remixQuality
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ (status: string, progress: number) => {
|
|
|
+
|
|
|
+ // 单片段内进度映射到总进度
|
|
|
+
|
|
|
+ const segProgress = (index + progress / 100) / totalSegments * 100;
|
|
|
+
|
|
|
+ video.remixProgress = segProgress;
|
|
|
+
|
|
|
+ this.remixStatusText = `片段 ${index + 1}/${totalSegments}: ${status}`;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ ).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ segment.status = 'completed';
|
|
|
+
|
|
|
+ segment.videoUrl = result.videoUrl;
|
|
|
+
|
|
|
+ segment.workId = result.workId;
|
|
|
+
|
|
|
+ this.remixResults.success++;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 添加到生成结果
|
|
|
+
|
|
|
+ this.generatedResults.unshift({
|
|
|
+
|
|
|
+ id: `REMIX-${Date.now()}-${segment.id}`,
|
|
|
+
|
|
|
+ type: 'video',
|
|
|
+
|
|
|
+ url: result.videoUrl,
|
|
|
+
|
|
|
+ title: `${video.title} - ${styleName} [${segment.id}]`,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ quality: this.remixQuality === 'pro' ? '1080P Pro' : '720P',
|
|
|
+
|
|
|
+ duration: this.remixFrames === 121 ? '5秒' : '10秒',
|
|
|
+
|
|
|
+ cost: this.remixQuality === 'pro' ? 5.0 : 1.4
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ console.log(`✅ 片段 ${segment.id} 完成: ${result.videoUrl}`);
|
|
|
+
|
|
|
+ // 自动保存当前会话
|
|
|
+
|
|
|
+ this.saveRemixSession('generating');
|
|
|
+
|
|
|
+ // 延迟1秒再提交下一个(避免频率限制)
|
|
|
+
|
|
|
+ setTimeout(() => this.generateSegmentSequentially(index + 1, video, styleName), 1000);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ segment.status = 'failed';
|
|
|
+
|
|
|
+ segment.error = err.message || '生成失败';
|
|
|
+
|
|
|
+ this.remixResults.failed++;
|
|
|
+
|
|
|
+ console.error(`❌ 片段 ${segment.id} 失败:`, err);
|
|
|
+
|
|
|
+ this.saveRemixSession('generating');
|
|
|
+
|
|
|
+ // 失败也继续下一个
|
|
|
+
|
|
|
+ setTimeout(() => this.generateSegmentSequentially(index + 1, video, styleName), 1000);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 批量生成完成 → 自动触发一键成片拼接
|
|
|
+
|
|
|
+ private onBatchGenerationComplete(video: ManagedVideo, styleName: string): void {
|
|
|
+
|
|
|
+ this.remixStep = 6;
|
|
|
+
|
|
|
+ this.remixStatusText = `片段生成完成:成功 ${this.remixResults.success}/${this.remixResults.total}`;
|
|
|
+
|
|
|
+ console.log(`🎉 批量生成完成: 成功${this.remixResults.success}, 失败${this.remixResults.failed}`);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 更新任务进度到80%
|
|
|
+
|
|
|
+ this.updateRemixTaskProgress('generate', 80);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 收集所有成功片段的视频URL
|
|
|
+
|
|
|
+ const successUrls = this.remixStoryboard
|
|
|
+
|
|
|
+ .filter(s => s.status === 'completed' && s.videoUrl)
|
|
|
+
|
|
|
+ .map(s => s.videoUrl!);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (successUrls.length < 1) {
|
|
|
+
|
|
|
+ video.isRemixing = false;
|
|
|
+
|
|
|
+ video.remixProgress = 100;
|
|
|
+
|
|
|
+ this.isRemixSubmitting = false;
|
|
|
+
|
|
|
+ this.remixStitchStatus = 'failed';
|
|
|
+
|
|
|
+ this.remixStitchError = '没有成功的视频片段可以拼接';
|
|
|
+
|
|
|
+ this.remixStatusText = `生成完成,但没有可拼接的片段`;
|
|
|
+
|
|
|
+ this.completeRemixTask('failed', undefined, '所有片段生成失败');
|
|
|
+
|
|
|
+ this.saveRemixSession('failed');
|
|
|
+
|
|
|
+ this.showToast('任务失败:所有片段生成失败', 'error');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 一键成片功能暂时关闭(Quickly API 凭证过期),直接标记完成
|
|
|
+
|
|
|
+ // TODO: 待 Quickly 凭证更新后恢复自动拼接功能
|
|
|
+
|
|
|
+ this.remixStitchStatus = 'failed';
|
|
|
+
|
|
|
+ this.remixStitchError = '一键成片功能暂不可用(Quickly API 凭证过期),各片段可单独查看/下载';
|
|
|
+
|
|
|
+ this.remixStatusText = `片段生成完成:成功 ${successUrls.length} 个,一键成片暂不可用`;
|
|
|
+
|
|
|
+ video.isRemixing = false;
|
|
|
+
|
|
|
+ video.remixProgress = 100;
|
|
|
+
|
|
|
+ this.isRemixSubmitting = false;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const firstUrl = successUrls[0];
|
|
|
+
|
|
|
+ this.createRemixVersion(video, this.remixStyle, styleName, firstUrl);
|
|
|
+
|
|
|
+ this.completeRemixTask('completed', firstUrl);
|
|
|
+
|
|
|
+ this.saveRemixSession('completed');
|
|
|
+
|
|
|
+ this.showToast(`✅ AI重塑完成!「${video.title}」共 ${successUrls.length} 个片段`, 'success', 6000);
|
|
|
+
|
|
|
+ console.log(`✅ AI重塑完成(一键成片已跳过): ${successUrls.length} 个片段`);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 结果页点击完成
|
|
|
+
|
|
|
+ finishRemix(): void {
|
|
|
+
|
|
|
+ this.showRemixModal = false;
|
|
|
+
|
|
|
+ this.remixTargetVideo = null;
|
|
|
+
|
|
|
+ this.resetDigitalHumanState();
|
|
|
+
|
|
|
+ this.setCurrentTab('results');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private createDigitalHumanVersion(video: ManagedVideo, videoUrl: string): void {
|
|
|
+
|
|
|
+ const remixVersion: RemixVersion = {
|
|
|
+
|
|
|
+ id: `REMIX-${Date.now()}`,
|
|
|
+
|
|
|
+ style: '数字人生成',
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ file_path: videoUrl,
|
|
|
+
|
|
|
+ thumbnail: this.remixDigitalHumanImageAssetUrl || video.thumbnail || 'assets/default-remix-thumb.jpg',
|
|
|
+
|
|
|
+ description: '使用上传形象图与原视频提取音频生成的数字人版本',
|
|
|
+
|
|
|
+ parameters: {
|
|
|
+
|
|
|
+ style: 'digital-human',
|
|
|
+
|
|
|
+ intensity: 1,
|
|
|
+
|
|
|
+ preserve_audio: true,
|
|
|
+
|
|
|
+ quality: this.remixDigitalHumanResolution === '1080p' ? 'high' : 'standard',
|
|
|
+
|
|
|
+ effects: ['digital-human']
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!video.remixVersions) {
|
|
|
+
|
|
|
+ video.remixVersions = [];
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ video.remixVersions.unshift(remixVersion);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const durationSeconds = Math.min(Number(video.duration || 0) || 0, 60);
|
|
|
+
|
|
|
+ const estimatedCost = Math.round(durationSeconds * 100) / 100;
|
|
|
+
|
|
|
+ const qualityLabel = this.remixDigitalHumanResolution === '1080p' ? '1080P' : '720P';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.saveResult({
|
|
|
+
|
|
|
+ type: 'video',
|
|
|
+
|
|
|
+ url: videoUrl,
|
|
|
+
|
|
|
+ title: `${video.title} - 数字人生成`,
|
|
|
+
|
|
|
+ quality: qualityLabel,
|
|
|
+
|
|
|
+ duration: durationSeconds > 0 ? `${durationSeconds} 秒` : '数字人视频',
|
|
|
+
|
|
|
+ cost: estimatedCost,
|
|
|
+
|
|
|
+ remixId: this.currentRemixId,
|
|
|
+
|
|
|
+ videoId: video.id,
|
|
|
+
|
|
|
+ segments: [{ id: 'DH-01', videoUrl, prompt: this.remixDigitalHumanPrompt, status: 'completed' }]
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.saveHistoryRecord({
|
|
|
+
|
|
|
+ keyword: `${video.title} - 数字人生成`,
|
|
|
+
|
|
|
+ original_video_title: video.title,
|
|
|
+
|
|
|
+ status: 'completed',
|
|
|
+
|
|
|
+ cost: estimatedCost,
|
|
|
+
|
|
|
+ duration: durationSeconds > 0 ? `${durationSeconds} 秒` : '数字人视频',
|
|
|
+
|
|
|
+ remixId: this.currentRemixId,
|
|
|
+
|
|
|
+ videoId: video.id,
|
|
|
+
|
|
|
+ styleName: '数字人生成',
|
|
|
+
|
|
|
+ resultUrl: videoUrl,
|
|
|
+
|
|
|
+ quality: qualityLabel,
|
|
|
+
|
|
|
+ segmentCount: 1,
|
|
|
+
|
|
|
+ successCount: 1
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 创建重塑版本 + 持久化结果和历史
|
|
|
+
|
|
|
+ private createRemixVersion(video: ManagedVideo, styleId: string, styleName: string, videoUrl?: string): void {
|
|
|
+
|
|
|
+ const remixVersion: RemixVersion = {
|
|
|
+
|
|
|
+ id: `REMIX-${Date.now()}`,
|
|
|
+
|
|
|
+ style: styleName,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ file_path: videoUrl || `remix/${video.filename.replace('.mp4', `-${styleId}.mp4`)}`,
|
|
|
+
|
|
|
+ thumbnail: video.thumbnail || 'assets/default-remix-thumb.jpg',
|
|
|
+
|
|
|
+ description: `使用${styleName}风格重塑的视频版本`,
|
|
|
+
|
|
|
+ parameters: {
|
|
|
+
|
|
|
+ style: styleId,
|
|
|
+
|
|
|
+ intensity: 0.8,
|
|
|
+
|
|
|
+ preserve_audio: true,
|
|
|
+
|
|
|
+ quality: this.remixQuality === 'pro' ? 'ultra' : 'high',
|
|
|
+
|
|
|
+ effects: [styleId, 'enhancement']
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (!video.remixVersions) {
|
|
|
+
|
|
|
+ video.remixVersions = [];
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ video.remixVersions.unshift(remixVersion);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 收集所有成功片段数据
|
|
|
+
|
|
|
+ const successSegments = this.remixStoryboard
|
|
|
+
|
|
|
+ .filter(s => s.status === 'completed' && s.videoUrl)
|
|
|
+
|
|
|
+ .map(s => ({ id: s.id, videoUrl: s.videoUrl!, prompt: s.prompt, status: s.status }));
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 计算费用估算
|
|
|
+
|
|
|
+ const costPerSecond = this.remixQuality === 'pro' ? 1.0 : this.remixQuality === '1080p' ? 0.63 : 0.28;
|
|
|
+
|
|
|
+ const estimatedCost = Math.round(this.remixResults.success * 5 * costPerSecond * 100) / 100;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 质量显示
|
|
|
+
|
|
|
+ const qualityLabel = this.remixQuality === 'pro' ? 'Pro 1080P' : this.remixQuality === '1080p' ? '1080P' : '720P';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 持久化:保存生成结果(包含所有片段)
|
|
|
+
|
|
|
+ this.saveResult({
|
|
|
+
|
|
|
+ type: 'video',
|
|
|
+
|
|
|
+ url: videoUrl || '',
|
|
|
+
|
|
|
+ title: `${video.title} - ${styleName}风格重塑`,
|
|
|
+
|
|
|
+ quality: qualityLabel,
|
|
|
+
|
|
|
+ duration: `${this.remixResults.success} 个片段`,
|
|
|
+
|
|
|
+ cost: estimatedCost,
|
|
|
+
|
|
|
+ remixId: this.currentRemixId,
|
|
|
+
|
|
|
+ videoId: video.id,
|
|
|
+
|
|
|
+ segments: successSegments
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 持久化:保存历史记录
|
|
|
+
|
|
|
+ this.saveHistoryRecord({
|
|
|
+
|
|
|
+ keyword: `${video.title} - ${styleName}`,
|
|
|
+
|
|
|
+ original_video_title: video.title,
|
|
|
+
|
|
|
+ status: this.remixResults.success > 0 ? 'completed' : 'failed',
|
|
|
+
|
|
|
+ cost: estimatedCost,
|
|
|
+
|
|
|
+ duration: `${this.remixResults.success}/${this.remixResults.total} 片段`,
|
|
|
+
|
|
|
+ remixId: this.currentRemixId,
|
|
|
+
|
|
|
+ videoId: video.id,
|
|
|
+
|
|
|
+ styleName: styleName,
|
|
|
+
|
|
|
+ resultUrl: videoUrl || successSegments[0]?.videoUrl || '',
|
|
|
+
|
|
|
+ quality: qualityLabel,
|
|
|
+
|
|
|
+ segmentCount: this.remixResults.total,
|
|
|
+
|
|
|
+ successCount: this.remixResults.success
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ console.log('重塑版本已创建:', remixVersion);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 查看重塑版本
|
|
|
+
|
|
|
+ viewRemixVersions(video: ManagedVideo): void {
|
|
|
+
|
|
|
+ if (!video.remixVersions || video.remixVersions.length === 0) {
|
|
|
+
|
|
|
+ alert('该视频暂无重塑版本');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ console.log('查看重塑版本:', video.remixVersions);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ let versionsList = `「${video.title}」的重塑版本:\n\n`;
|
|
|
+
|
|
|
+ video.remixVersions.forEach((version, index) => {
|
|
|
+
|
|
|
+ versionsList += `${index + 1}. ${version.style} - ${version.description}\n 创建时间: ${version.created_at.toLocaleString()}\n\n`;
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ alert(versionsList);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取重塑状态文本
|
|
|
+
|
|
|
+ getRemixStatusText(video: ManagedVideo): string {
|
|
|
+
|
|
|
+ if (video.isRemixing && video.remixProgress !== undefined) {
|
|
|
+
|
|
|
+ return `重塑中... ${Math.round(video.remixProgress)}%`;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return 'AI重塑';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 获取重塑状态颜色
|
|
|
+
|
|
|
+ getRemixStatusColor(video: ManagedVideo): string {
|
|
|
+
|
|
|
+ if (video.isRemixing) {
|
|
|
+
|
|
|
+ return 'btn-accent';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ return 'btn-primary';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== 语音合成标签页方法 ====================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ setVsTtsMode(mode: 'clone' | 'synthesize'): void {
|
|
|
+
|
|
|
+ this.vsTtsMode = mode;
|
|
|
+
|
|
|
+ this.vsCloneError = '';
|
|
|
+
|
|
|
+ this.vsSynthError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onVsCloneAudioSelected(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ if (!input.files || input.files.length === 0) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const file = input.files[0];
|
|
|
+
|
|
|
+ const allowedExts = ['.mp3', '.wav', '.m4a', '.ogg', '.aac', '.flac', '.wma'];
|
|
|
+
|
|
|
+ const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
|
|
+
|
|
|
+ if (!allowedExts.includes(ext) || file.size <= 0) {
|
|
|
+
|
|
|
+ this.vsCloneError = '仅支持 mp3、wav、m4a、ogg、aac、flac、wma 格式的音频文件';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (file.size > 50 * 1024 * 1024) {
|
|
|
+
|
|
|
+ this.vsCloneError = '音频文件不能超过 50MB';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.vsCloneAudioFile = file;
|
|
|
+
|
|
|
+ this.vsCloneAudioName = file.name;
|
|
|
+
|
|
|
+ this.vsCloneAudioUrl = '';
|
|
|
+
|
|
|
+ this.vsCloneError = '';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ clearVsCloneAudio(): void {
|
|
|
+
|
|
|
+ this.vsCloneAudioFile = null;
|
|
|
+
|
|
|
+ this.vsCloneAudioName = '';
|
|
|
+
|
|
|
+ this.vsCloneAudioUrl = '';
|
|
|
+
|
|
|
+ this.vsCloneError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private vsUploadCloneAudio(): Promise<string> {
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+
|
|
|
+ if (!this.vsCloneAudioFile) {
|
|
|
+
|
|
|
+ reject(new Error('未选择音频文件'));
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.vsCloneAudioUrl) {
|
|
|
+
|
|
|
+ resolve(this.vsCloneAudioUrl);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.vsCloneUploading = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const formData = new FormData();
|
|
|
+
|
|
|
+ const filename = `voice-clone-${Date.now()}-${this.vsCloneAudioFile.name}`;
|
|
|
+
|
|
|
+ formData.append('file', this.vsCloneAudioFile, filename);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/remix/upload-asset', formData).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ this.vsCloneUploading = false;
|
|
|
+
|
|
|
+ const url = res?.url || '';
|
|
|
+
|
|
|
+ if (!url) {
|
|
|
+
|
|
|
+ reject(new Error('上传音频失败,未返回可用 URL'));
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.vsCloneAudioUrl = url;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ resolve(url);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.vsCloneUploading = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ reject(new Error(this.getErrorText(err, '音频上传失败')));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vsStartClone(): void {
|
|
|
+
|
|
|
+ if (this.vsCloneLoading || this.vsCloneUploading) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const { name, speakerId, audioText, language, demoText } = this.vsCloneForm;
|
|
|
+
|
|
|
+ if (!name.trim()) {
|
|
|
+
|
|
|
+ this.vsCloneError = '请输入音色名称';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!speakerId.trim()) {
|
|
|
+
|
|
|
+ this.vsCloneError = '请选择 speaker_id';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.vsCloneAudioFile) {
|
|
|
+
|
|
|
+ this.vsCloneError = '请先上传音频文件';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!demoText.trim() || demoText.trim().length < 4 || demoText.trim().length > 80) {
|
|
|
+
|
|
|
+ this.vsCloneError = '试听文本长度需在 4-80 字之间';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.vsCloneLoading = true;
|
|
|
+
|
|
|
+ this.vsCloneError = '';
|
|
|
+
|
|
|
+ this.vsCloneResult = null;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.vsUploadCloneAudio().then((audioUrl) => {
|
|
|
+
|
|
|
+ this.http.post<any>('https://server.fmode.cn/api/volcengine/tts/voice_clone', {
|
|
|
+
|
|
|
+ token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600',
|
|
|
+
|
|
|
+ name: name.trim(),
|
|
|
+
|
|
|
+ speaker_id: speakerId.trim(),
|
|
|
+
|
|
|
+ audioData: {
|
|
|
+
|
|
|
+ url: audioUrl,
|
|
|
+
|
|
|
+ ...(audioText.trim() ? { text: audioText.trim() } : {})
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ language: language,
|
|
|
+
|
|
|
+ extra_params: {
|
|
|
+
|
|
|
+ demo_text: demoText.trim()
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ this.vsCloneLoading = false;
|
|
|
+
|
|
|
+ this.vsCloneResult = res;
|
|
|
+
|
|
|
+ if (res?.code === 200 && res?.data?.timbre) {
|
|
|
+
|
|
|
+ const timbre = res.data.timbre;
|
|
|
+
|
|
|
+ const existing = this.vsClonedTimbreList.find(t => t.objectId === timbre.objectId);
|
|
|
+
|
|
|
+ if (!existing) {
|
|
|
+
|
|
|
+ this.vsClonedTimbreList.unshift({
|
|
|
+
|
|
|
+ objectId: timbre.objectId,
|
|
|
+
|
|
|
+ name: timbre.name || name.trim(),
|
|
|
+
|
|
|
+ speakerId: timbre.speaker_id || speakerId.trim()
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ this.saveClonedTimbreList();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.vsSynthForm.timbreId) {
|
|
|
+
|
|
|
+ this.vsSynthForm.timbreId = timbre.objectId;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.showToast(`✅ 音色「${timbre.name}」复刻成功`, 'success');
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.vsCloneError = res?.error?.message || '复刻失败,请检查参数';
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.vsCloneError}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.vsCloneLoading = false;
|
|
|
+
|
|
|
+ this.vsCloneError = this.getErrorText(err, '音色复刻请求失败');
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.vsCloneError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }).catch((uploadErr) => {
|
|
|
+
|
|
|
+ this.vsCloneLoading = false;
|
|
|
+
|
|
|
+ this.vsCloneError = uploadErr?.message || '音频上传失败';
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.vsCloneError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vsStartSynthesize(): void {
|
|
|
+
|
|
|
+ if (this.vsSynthLoading) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const { timbreId, text, isStream, format, sampleRate, speechRate, loudnessRate } = this.vsSynthForm;
|
|
|
+
|
|
|
+ if (!timbreId.trim()) {
|
|
|
+
|
|
|
+ this.vsSynthError = '请输入音色 ID(timbreId),可先通过音色复刻获取';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!text.trim()) {
|
|
|
+
|
|
|
+ this.vsSynthError = '请输入要合成的文本内容';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.vsSynthLoading = true;
|
|
|
+
|
|
|
+ this.vsSynthError = '';
|
|
|
+
|
|
|
+ this.vsSynthAudioUrl = '';
|
|
|
+
|
|
|
+ this.vsSynthWorkId = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('https://server.fmode.cn/api/volcengine/tts/unidirectional', {
|
|
|
+
|
|
|
+ token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600',
|
|
|
+
|
|
|
+ text: text.trim(),
|
|
|
+
|
|
|
+ timbreId: timbreId.trim(),
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ audio_params: {
|
|
|
+
|
|
|
+ format: format || 'mp3',
|
|
|
+
|
|
|
+ sample_rate: sampleRate || 24000,
|
|
|
+
|
|
|
+ speech_rate: speechRate || 0,
|
|
|
+
|
|
|
+ loudness_rate: loudnessRate || 0
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ this.vsSynthLoading = false;
|
|
|
+
|
|
|
+ if (res?.code === 200 && res?.data?.audioUrl) {
|
|
|
+
|
|
|
+ this.vsSynthAudioUrl = res.data.audioUrl;
|
|
|
+
|
|
|
+ this.vsSynthWorkId = res.data.workId || '';
|
|
|
+
|
|
|
+ this.showToast('✅ 语音合成成功', 'success');
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.vsSynthError = res?.error?.message || '合成失败,请检查参数';
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.vsSynthError}`, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.vsSynthLoading = false;
|
|
|
+
|
|
|
+ this.vsSynthError = this.getErrorText(err, '语音合成请求失败');
|
|
|
+
|
|
|
+ this.showToast(`❌ ${this.vsSynthError}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vsSelectClonedTimbre(objectId: string): void {
|
|
|
+
|
|
|
+ this.vsSynthForm.timbreId = objectId;
|
|
|
+
|
|
|
+ this.vsTtsMode = 'synthesize';
|
|
|
+
|
|
|
+ this.vsSynthError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== 数字人合成独立页面 ====================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onDhImageSelected(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ if (!input.files || input.files.length === 0) return;
|
|
|
+
|
|
|
+ const file = input.files[0];
|
|
|
+
|
|
|
+ const allowedExts = ['.jpg', '.jpeg', '.png', '.gif'];
|
|
|
+
|
|
|
+ const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
|
|
+
|
|
|
+ if (!allowedExts.includes(ext)) {
|
|
|
+
|
|
|
+ this.dhError = '仅支持 jpg、jpeg、png、gif 格式的形象图';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (file.size > 20 * 1024 * 1024) {
|
|
|
+
|
|
|
+ this.dhError = '形象图大小不能超过 20MB';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.dhRevokeImagePreview();
|
|
|
+
|
|
|
+ this.dhImageFile = file;
|
|
|
+
|
|
|
+ this.dhImageName = file.name;
|
|
|
+
|
|
|
+ this.dhImagePreviewUrl = URL.createObjectURL(file);
|
|
|
+
|
|
|
+ this.dhImageAssetUrl = '';
|
|
|
+
|
|
|
+ this.dhError = '';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ clearDhImage(): void {
|
|
|
+
|
|
|
+ this.dhRevokeImagePreview();
|
|
|
+
|
|
|
+ this.dhImageFile = null;
|
|
|
+
|
|
|
+ this.dhImageName = '';
|
|
|
+
|
|
|
+ this.dhImageAssetUrl = '';
|
|
|
+
|
|
|
+ this.dhError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhRevokeImagePreview(): void {
|
|
|
+
|
|
|
+ if (this.dhImagePreviewUrl) {
|
|
|
+
|
|
|
+ URL.revokeObjectURL(this.dhImagePreviewUrl);
|
|
|
+
|
|
|
+ this.dhImagePreviewUrl = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onDhAudioSelected(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ if (!input.files || input.files.length === 0) return;
|
|
|
+
|
|
|
+ const file = input.files[0];
|
|
|
+
|
|
|
+ const allowedExts = ['.mp3', '.wav', '.m4a', '.ogg', '.aac', '.flac', '.wma'];
|
|
|
+
|
|
|
+ const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
|
|
+
|
|
|
+ if (!allowedExts.includes(ext)) {
|
|
|
+
|
|
|
+ this.dhError = '仅支持 MP3、WAV、M4A、OGG、AAC、FLAC、WMA 格式的音频';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (file.size > 50 * 1024 * 1024) {
|
|
|
+
|
|
|
+ this.dhError = '音频文件大小不能超过 50MB';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.dhUploadAudioFile = file;
|
|
|
+
|
|
|
+ this.dhUploadAudioName = file.name;
|
|
|
+
|
|
|
+ this.dhUploadAudioUrl = '';
|
|
|
+
|
|
|
+ this.dhError = '';
|
|
|
+
|
|
|
+ input.value = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ clearDhAudio(): void {
|
|
|
+
|
|
|
+ this.dhUploadAudioFile = null;
|
|
|
+
|
|
|
+ this.dhUploadAudioName = '';
|
|
|
+
|
|
|
+ this.dhUploadAudioUrl = '';
|
|
|
+
|
|
|
+ this.dhError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ dhReset(): void {
|
|
|
+
|
|
|
+ this.dhRevokeImagePreview();
|
|
|
+
|
|
|
+ this.dhStep = 'form';
|
|
|
+
|
|
|
+ this.dhImageFile = null;
|
|
|
+
|
|
|
+ this.dhImageName = '';
|
|
|
+
|
|
|
+ this.dhImageAssetUrl = '';
|
|
|
+
|
|
|
+ this.dhAudioSource = 'tts';
|
|
|
+
|
|
|
+ this.dhTtsTimbreId = '';
|
|
|
+
|
|
|
+ this.dhTtsText = '';
|
|
|
+
|
|
|
+ this.dhUploadAudioFile = null;
|
|
|
+
|
|
|
+ this.dhUploadAudioName = '';
|
|
|
+
|
|
|
+ this.dhUploadAudioUrl = '';
|
|
|
+
|
|
|
+ this.dhUploadingAudio = false;
|
|
|
+
|
|
|
+ this.dhPrompt = '';
|
|
|
+
|
|
|
+ this.dhFastMode = false;
|
|
|
+
|
|
|
+ this.dhResolution = '1080p';
|
|
|
+
|
|
|
+ this.dhError = '';
|
|
|
+
|
|
|
+ this.dhLoading = false;
|
|
|
+
|
|
|
+ this.dhStatusText = '';
|
|
|
+
|
|
|
+ this.dhProgress = 0;
|
|
|
+
|
|
|
+ this.dhResultVideoUrl = '';
|
|
|
+
|
|
|
+ this.dhSynthesizing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 数字人形象库:从生成结果中筛选数字人作品
|
|
|
+
|
|
|
+ getDigitalHumanResults(): GeneratedResult[] {
|
|
|
+
|
|
|
+ return (this.generatedResults || []).filter(r => {
|
|
|
+
|
|
|
+ const title = String(r?.title || '');
|
|
|
+
|
|
|
+ return title.includes('数字人合成') || title.includes('数字人');
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ========================================================================
|
|
|
+
|
|
|
+ // 数字人合成页:AI 对话助手 —— 让用户用自然语言要求 AI 改写口播稿 / 优化提示词
|
|
|
+
|
|
|
+ // ========================================================================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 发送一条对话给 AI,AI 基于当前 dhTtsText / dhPrompt 给出修改建议 */
|
|
|
+
|
|
|
+ dhChatSend(): void {
|
|
|
+
|
|
|
+ const userMsg = (this.dhChatInput || '').trim();
|
|
|
+
|
|
|
+ if (!userMsg || this.dhChatLoading) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.dhChatMessages.push({ role: 'user', content: userMsg });
|
|
|
+
|
|
|
+ this.dhChatInput = '';
|
|
|
+
|
|
|
+ this.dhChatLoading = true;
|
|
|
+
|
|
|
+ this.dhChatError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const systemPrompt = `你是一位短视频数字人口播脚本编辑助手。你需要根据用户的自然语言指令,在不偏离原意的前提下改写「口播文本」或「补充提示词」。
|
|
|
+
|
|
|
+【当前口播文本】
|
|
|
+
|
|
|
+${this.dhTtsText || '(空)'}
|
|
|
+
|
|
|
+【当前补充提示词】
|
|
|
+
|
|
|
+${this.dhPrompt || '(空)'}
|
|
|
+
|
|
|
+【输出格式】请只输出严格 JSON:
|
|
|
+
|
|
|
+{
|
|
|
+
|
|
|
+ "explanation": "用一两句话向用户解释你做了什么修改(≤40字)",
|
|
|
+
|
|
|
+ "ttsScript": "新的口播文本;若用户本轮指令不需要改写口播,请返回空字符串",
|
|
|
+
|
|
|
+ "dhPrompt": "新的补充提示词;若用户本轮指令不需要改写提示词,请返回空字符串"
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+要求:
|
|
|
+
|
|
|
+- 字符串内不得出现真实换行,必须用 \\n 转义。
|
|
|
+
|
|
|
+- 口播文本控制在 200 字以内,第一人称、自然口语。
|
|
|
+
|
|
|
+- 补充提示词控制在 80 字以内,描述动作 / 镜头语言 / 风格。
|
|
|
+
|
|
|
+- 只返回纯 JSON,禁止 markdown 代码块、禁止前后缀。`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 把最近若干轮历史拼成一段上下文,避免 token 爆炸
|
|
|
+
|
|
|
+ const history = this.dhChatMessages
|
|
|
+
|
|
|
+ .slice(-8, -1) // 不含本次刚 push 的用户消息
|
|
|
+
|
|
|
+ .map(m => `${m.role === 'user' ? '用户' : 'AI'}: ${m.content}`)
|
|
|
+
|
|
|
+ .join('\n');
|
|
|
+
|
|
|
+ const userInput = (history ? `【历史对话】\n${history}\n\n` : '') + `【本轮用户指令】\n${userMsg}`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ console.log('🗣️ [dhChatSend] 发送对话:', { userMsg, ttsLen: this.dhTtsText.length, promptLen: this.dhPrompt.length });
|
|
|
+
|
|
|
+ this.llmService.askWithSystem(systemPrompt, userInput, { model: 'gemini-2.5-flash', max_tokens: 2048 })
|
|
|
+
|
|
|
+ .pipe(
|
|
|
+
|
|
|
+ timeout(60000),
|
|
|
+
|
|
|
+ // ★ finalize 保证不论 next/error/throw/cancel,都强制解锁按钮并刷新视图
|
|
|
+
|
|
|
+ finalize(() => {
|
|
|
+
|
|
|
+ this.dhChatLoading = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ })
|
|
|
+
|
|
|
+ )
|
|
|
+
|
|
|
+ .subscribe({
|
|
|
+
|
|
|
+ next: (text) => {
|
|
|
+
|
|
|
+ console.log('🗣️ [dhChatSend] 收到回复,长度:', (text || '').length);
|
|
|
+
|
|
|
+ const cleaned = (text || '').replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
|
|
|
+
|
|
|
+ const parsed: any = this.parseRewriteJson(cleaned);
|
|
|
+
|
|
|
+ if (parsed) {
|
|
|
+
|
|
|
+ const explanation = typeof parsed.explanation === 'string' ? parsed.explanation.trim() : '';
|
|
|
+
|
|
|
+ const tts = typeof parsed.ttsScript === 'string' ? parsed.ttsScript.trim() : '';
|
|
|
+
|
|
|
+ const prompt = typeof parsed.dhPrompt === 'string' ? parsed.dhPrompt.trim() : '';
|
|
|
+
|
|
|
+ this.dhChatMessages.push({
|
|
|
+
|
|
|
+ role: 'assistant',
|
|
|
+
|
|
|
+ content: explanation || (tts || prompt ? '已为你生成修改建议,可点击下方按钮应用' : '我没有给出修改'),
|
|
|
+
|
|
|
+ suggestedTts: tts || undefined,
|
|
|
+
|
|
|
+ suggestedPrompt: prompt || undefined,
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ // 兜底:把原文当作 assistant 文本呈现,不自动应用
|
|
|
+
|
|
|
+ this.dhChatMessages.push({ role: 'assistant', content: cleaned || '(AI 无返回)' });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.error('🗣️ [dhChatSend] 请求失败:', err);
|
|
|
+
|
|
|
+ this.dhChatLoading = false;
|
|
|
+
|
|
|
+ // 优先抽取上游具体错误(fmode 网关返回的 "无效的令牌..." 等)
|
|
|
+
|
|
|
+ const upstream = err?.error?.error?.message || err?.error?.message || err?.error?.error || err?.error || '';
|
|
|
+
|
|
|
+ const msg = (typeof upstream === 'string' && upstream) || err?.message || 'AI 对话请求失败';
|
|
|
+
|
|
|
+ this.dhChatError = msg;
|
|
|
+
|
|
|
+ this.dhChatMessages.push({ role: 'assistant', content: `⚠️ ${msg}` });
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 应用 AI 建议到对应字段;field 控制只应用一项还是两项一起 */
|
|
|
+
|
|
|
+ dhChatApply(msgIndex: number, field: 'tts' | 'prompt' | 'both'): void {
|
|
|
+
|
|
|
+ const msg = this.dhChatMessages[msgIndex];
|
|
|
+
|
|
|
+ if (!msg || msg.role !== 'assistant') return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if ((field === 'tts' || field === 'both') && msg.suggestedTts) {
|
|
|
+
|
|
|
+ this.dhTtsText = msg.suggestedTts.slice(0, 600);
|
|
|
+
|
|
|
+ msg.appliedTts = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if ((field === 'prompt' || field === 'both') && msg.suggestedPrompt) {
|
|
|
+
|
|
|
+ this.dhPrompt = msg.suggestedPrompt.slice(0, 200);
|
|
|
+
|
|
|
+ msg.appliedPrompt = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.showToast('✅ 已应用 AI 建议', 'success', 2000);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 清空对话历史 */
|
|
|
+
|
|
|
+ dhChatClear(): void {
|
|
|
+
|
|
|
+ this.dhChatMessages = [];
|
|
|
+
|
|
|
+ this.dhChatError = '';
|
|
|
+
|
|
|
+ this.dhChatInput = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 快捷指令:一键发送预设的改写诉求 */
|
|
|
+
|
|
|
+ dhChatQuickAsk(preset: string): void {
|
|
|
+
|
|
|
+ if (this.dhChatLoading) return;
|
|
|
+
|
|
|
+ this.dhChatInput = preset;
|
|
|
+
|
|
|
+ this.dhChatSend();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /** 刷新数字人形象库(重新拉取生成结果) */
|
|
|
+
|
|
|
+ refreshDigitalHumanGallery(): void {
|
|
|
+
|
|
|
+ this.http.get<any[]>('/backend/api/results').subscribe({
|
|
|
+
|
|
|
+ next: (results) => {
|
|
|
+
|
|
|
+ this.generatedResults = (results || []).map(r => ({ ...r, created_at: new Date(r.created_at) }));
|
|
|
+
|
|
|
+ this.showToast('🔄 形象库已刷新', 'success', 2500);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ this.showToast('❌ 刷新失败,请稍后重试', 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ dhStartGeneration(): void {
|
|
|
+
|
|
|
+ if (this.dhLoading) return;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 验证形象图
|
|
|
+
|
|
|
+ if (!this.dhImageFile) {
|
|
|
+
|
|
|
+ this.dhError = '请先上传数字人形象图';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 验证音频来源
|
|
|
+
|
|
|
+ if (this.dhAudioSource === 'tts') {
|
|
|
+
|
|
|
+ if (!this.dhTtsTimbreId.trim()) {
|
|
|
+
|
|
|
+ this.dhError = '请输入或选择音色 ID';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.dhTtsText.trim()) {
|
|
|
+
|
|
|
+ this.dhError = '请输入口播文本';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ if (!this.dhUploadAudioFile) {
|
|
|
+
|
|
|
+ this.dhError = '请上传音频文件';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.dhError = '';
|
|
|
+
|
|
|
+ this.dhLoading = true;
|
|
|
+
|
|
|
+ this.dhStep = 'generating';
|
|
|
+
|
|
|
+ this.dhProgress = 0;
|
|
|
+
|
|
|
+ this.dhStatusText = '正在上传形象图...';
|
|
|
+
|
|
|
+ this.dhResultVideoUrl = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 上传形象图
|
|
|
+
|
|
|
+ this.jimengService.uploadFileToParse(
|
|
|
+
|
|
|
+ this.dhImageFile,
|
|
|
+
|
|
|
+ `digital-human-${Date.now()}-${this.dhImageFile.name}`,
|
|
|
+
|
|
|
+ this.dhImageFile.type || 'image/jpeg'
|
|
|
+
|
|
|
+ ).subscribe({
|
|
|
+
|
|
|
+ next: (imageUrl: string) => {
|
|
|
+
|
|
|
+ this.dhImageAssetUrl = imageUrl;
|
|
|
+
|
|
|
+ this.dhProgress = 10;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.dhAudioSource === 'tts') {
|
|
|
+
|
|
|
+ this.dhStatusText = '形象图上传完成,正在合成语音...';
|
|
|
+
|
|
|
+ this.dhSynthesizing = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('https://server.fmode.cn/api/volcengine/tts/unidirectional', {
|
|
|
+
|
|
|
+ token: this.voiceApiToken,
|
|
|
+
|
|
|
+ text: this.dhTtsText.trim(),
|
|
|
+
|
|
|
+ timbreId: this.dhTtsTimbreId.trim(),
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ audio_params: {
|
|
|
+
|
|
|
+ format: 'mp3',
|
|
|
+
|
|
|
+ sample_rate: 24000,
|
|
|
+
|
|
|
+ speech_rate: 0,
|
|
|
+
|
|
|
+ loudness_rate: 0
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ this.dhSynthesizing = false;
|
|
|
+
|
|
|
+ if (res?.code === 200 && res?.data?.audioUrl) {
|
|
|
+
|
|
|
+ this.dhProgress = 25;
|
|
|
+
|
|
|
+ this.dhStatusText = '语音合成完成,正在识别数字人主体...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhGenerateWithAudio(imageUrl, res.data.audioUrl);
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.dhHandleError(res?.error?.message || '语音合成失败,请检查音色ID和文本');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.dhSynthesizing = false;
|
|
|
+
|
|
|
+ this.dhHandleError(this.getErrorText(err, '语音合成请求失败'));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ // 上传音频文件
|
|
|
+
|
|
|
+ this.dhStatusText = '形象图上传完成,正在上传音频文件...';
|
|
|
+
|
|
|
+ this.dhUploadingAudio = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const audioFile = this.dhUploadAudioFile!;
|
|
|
+
|
|
|
+ this.jimengService.uploadFileToParse(
|
|
|
+
|
|
|
+ audioFile,
|
|
|
+
|
|
|
+ `dh-audio-${Date.now()}-${audioFile.name}`,
|
|
|
+
|
|
|
+ audioFile.type || 'audio/mpeg'
|
|
|
+
|
|
|
+ ).subscribe({
|
|
|
+
|
|
|
+ next: (audioUrl: string) => {
|
|
|
+
|
|
|
+ this.dhUploadingAudio = false;
|
|
|
+
|
|
|
+ this.dhUploadAudioUrl = audioUrl;
|
|
|
+
|
|
|
+ this.dhProgress = 25;
|
|
|
+
|
|
|
+ this.dhStatusText = '音频上传完成,正在识别数字人主体...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.dhGenerateWithAudio(imageUrl, audioUrl);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.dhUploadingAudio = false;
|
|
|
+
|
|
|
+ this.dhHandleError(err?.message || '上传音频文件失败');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.dhHandleError(err?.message || '上传形象图失败');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhGenerateWithAudio(imageUrl: string, audioUrl: string): void {
|
|
|
+
|
|
|
+ console.log('🤖 数字人生成 - imageUrl:', imageUrl, 'audioUrl:', audioUrl);
|
|
|
+
|
|
|
+ this.jimengService.identifyDigitalHuman(imageUrl).subscribe({
|
|
|
+
|
|
|
+ next: ({ workId }) => {
|
|
|
+
|
|
|
+ this.jimengService.pollDigitalHumanIdentifyUntilReady(workId, (status, progress) => {
|
|
|
+
|
|
|
+ this.dhProgress = Math.min(25 + progress * 0.25, 50);
|
|
|
+
|
|
|
+ this.dhStatusText = status;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: () => {
|
|
|
+
|
|
|
+ this.dhStatusText = '主体识别完成,开始生成数字人视频...';
|
|
|
+
|
|
|
+ this.dhProgress = 50;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.jimengService.generateDigitalHuman(workId, audioUrl, {
|
|
|
+
|
|
|
+ prompt: this.dhPrompt,
|
|
|
+
|
|
|
+ peFastMode: this.dhResolution === '720p' ? true : this.dhFastMode,
|
|
|
+
|
|
|
+ outputResolution: this.dhResolution,
|
|
|
+
|
|
|
+ maskUrl: []
|
|
|
+
|
|
|
+ }, (status, progress) => {
|
|
|
+
|
|
|
+ this.dhProgress = Math.min(50 + progress * 0.45, 95);
|
|
|
+
|
|
|
+ this.dhStatusText = status;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ this.dhProgress = 100;
|
|
|
+
|
|
|
+ this.dhResultVideoUrl = result.videoUrl;
|
|
|
+
|
|
|
+ this.dhStatusText = '数字人视频生成完成!';
|
|
|
+
|
|
|
+ this.dhLoading = false;
|
|
|
+
|
|
|
+ this.dhStep = 'done';
|
|
|
+
|
|
|
+ this.showToast('✅ 数字人视频已生成', 'success', 6000);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 保存到结果
|
|
|
+
|
|
|
+ this.saveResult({
|
|
|
+
|
|
|
+ type: 'video',
|
|
|
+
|
|
|
+ url: result.videoUrl,
|
|
|
+
|
|
|
+ title: `数字人合成 - ${new Date().toLocaleString('zh-CN')}`,
|
|
|
+
|
|
|
+ quality: this.dhResolution === '1080p' ? '1080P' : '720P',
|
|
|
+
|
|
|
+ duration: '数字人视频',
|
|
|
+
|
|
|
+ cost: 0,
|
|
|
+
|
|
|
+ segments: [{ id: 'DH-01', videoUrl: result.videoUrl, prompt: this.dhPrompt, status: 'completed' }]
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.saveHistoryRecord({
|
|
|
+
|
|
|
+ keyword: '数字人合成',
|
|
|
+
|
|
|
+ status: 'completed',
|
|
|
+
|
|
|
+ cost: 0,
|
|
|
+
|
|
|
+ duration: '数字人视频',
|
|
|
+
|
|
|
+ styleName: '数字人合成',
|
|
|
+
|
|
|
+ resultUrl: result.videoUrl,
|
|
|
+
|
|
|
+ quality: this.dhResolution === '1080p' ? '1080P' : '720P',
|
|
|
+
|
|
|
+ segmentCount: 1,
|
|
|
+
|
|
|
+ successCount: 1
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 同步到视频管理
|
|
|
+
|
|
|
+ this.addDhVideoToManifest(result.videoUrl);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => this.dhHandleError(typeof err === 'string' ? err : err?.message || err?.error?.message || '数字人视频生成失败')
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => this.dhHandleError(typeof err === 'string' ? err : err?.message || err?.error?.message || '数字人主体识别失败')
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => this.dhHandleError(typeof err === 'string' ? err : err?.message || err?.error?.message || '提交数字人主体识别失败')
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private dhHandleError(message: string): void {
|
|
|
+
|
|
|
+ this.dhLoading = false;
|
|
|
+
|
|
|
+ this.dhSynthesizing = false;
|
|
|
+
|
|
|
+ this.dhUploadingAudio = false;
|
|
|
+
|
|
|
+ this.dhProgress = 0;
|
|
|
+
|
|
|
+ this.dhStep = 'done';
|
|
|
+
|
|
|
+ this.dhStatusText = message;
|
|
|
+
|
|
|
+ this.dhError = message;
|
|
|
+
|
|
|
+ this.showToast(`❌ ${message}`, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== 视频生成工作流 ====================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vgReset(): void {
|
|
|
+
|
|
|
+ this.vgStep = 1;
|
|
|
+
|
|
|
+ this.vgSourceFile = null;
|
|
|
+
|
|
|
+ this.vgSourceFileName = '';
|
|
|
+
|
|
|
+ this.vgSourceVideoUrl = '';
|
|
|
+
|
|
|
+ this.vgUploading = false;
|
|
|
+
|
|
|
+ this.vgUploadProgress = 0;
|
|
|
+
|
|
|
+ this.vgVideoId = '';
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.vgAnalysisText = '';
|
|
|
+
|
|
|
+ this.vgTranscript = '';
|
|
|
+
|
|
|
+ this.vgGeneratingScript = false;
|
|
|
+
|
|
|
+ this.vgScript = '';
|
|
|
+
|
|
|
+ this.vgScriptSegments = [];
|
|
|
+
|
|
|
+ this.vgGeneratingImages = false;
|
|
|
+
|
|
|
+ this.vgImageResults = [];
|
|
|
+
|
|
|
+ this.vgImageProgress = 0;
|
|
|
+
|
|
|
+ this.vgVoiceMode = 'system';
|
|
|
+
|
|
|
+ this.vgVoiceTimbreId = '';
|
|
|
+
|
|
|
+ this.vgSynthesizing = false;
|
|
|
+
|
|
|
+ this.vgAudioUrls = [];
|
|
|
+
|
|
|
+ this.vgCompositing = false;
|
|
|
+
|
|
|
+ this.vgCompositingProgress = 0;
|
|
|
+
|
|
|
+ this.vgFinalVideoUrl = '';
|
|
|
+
|
|
|
+ this.vgError = '';
|
|
|
+
|
|
|
+ this.vgTaskId = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频生成:创建任务(上传成功后调用)
|
|
|
+
|
|
|
+ private vgCreateTask(): void {
|
|
|
+
|
|
|
+ const tempId = `VG-${Date.now()}`;
|
|
|
+
|
|
|
+ const task: Task = {
|
|
|
+
|
|
|
+ id: tempId,
|
|
|
+
|
|
|
+ keyword: this.vgSourceFileName || '视频生成',
|
|
|
+
|
|
|
+ status: 'processing',
|
|
|
+
|
|
|
+ step: 'vg-upload',
|
|
|
+
|
|
|
+ progress: 10,
|
|
|
+
|
|
|
+ cost: 0,
|
|
|
+
|
|
|
+ created_at: new Date(),
|
|
|
+
|
|
|
+ updated_at: new Date(),
|
|
|
+
|
|
|
+ original_video_title: this.vgSourceFileName,
|
|
|
+
|
|
|
+ type: 'video-generation',
|
|
|
+
|
|
|
+ vgData: this.vgBuildSnapshot()
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.vgTaskId = tempId;
|
|
|
+
|
|
|
+ this.activeTasks.unshift(task);
|
|
|
+
|
|
|
+ // saveTask POST 后端会返回真实 id,回填后同步 vgTaskId
|
|
|
+
|
|
|
+ this.taskSaveReady = new Promise<void>((resolve) => {
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/tasks', task).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.task?.id) {
|
|
|
+
|
|
|
+ task.id = res.task.id;
|
|
|
+
|
|
|
+ this.vgTaskId = res.task.id;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ console.log('💾 视频生成任务已保存:', task.id);
|
|
|
+
|
|
|
+ resolve();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ 任务保存失败:', err);
|
|
|
+
|
|
|
+ resolve();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ this.showToast('任务已创建,可在「任务管理」中查看和恢复进度', 'success');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频生成:更新任务进度(每个步骤转换时调用)
|
|
|
+
|
|
|
+ private vgUpdateTaskProgress(step: Task['step'], progress: number, status: Task['status'] = 'processing'): void {
|
|
|
+
|
|
|
+ const task = this.activeTasks.find(t => t.id === this.vgTaskId || (t.type === 'video-generation' && t.vgData?.videoId === this.vgVideoId));
|
|
|
+
|
|
|
+ if (!task) return;
|
|
|
+
|
|
|
+ // 若任务已被用户取消,则不再覆盖回 processing 状态
|
|
|
+
|
|
|
+ if (task.id && this.canceledTaskIds.has(task.id)) {
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.vgTaskId = task.id;
|
|
|
+
|
|
|
+ task.step = step;
|
|
|
+
|
|
|
+ task.progress = progress;
|
|
|
+
|
|
|
+ task.status = status;
|
|
|
+
|
|
|
+ task.updated_at = new Date();
|
|
|
+
|
|
|
+ task.error_message = status === 'failed' ? this.vgError : undefined;
|
|
|
+
|
|
|
+ task.vgData = this.vgBuildSnapshot();
|
|
|
+
|
|
|
+ if (status === 'completed' && this.vgFinalVideoUrl) {
|
|
|
+
|
|
|
+ task.result_url = this.vgFinalVideoUrl;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.updateTask(task);
|
|
|
+
|
|
|
+ this.updateTaskStats();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频生成:构建当前状态快照
|
|
|
+
|
|
|
+ private vgBuildSnapshot(): Task['vgData'] {
|
|
|
+
|
|
|
+ return {
|
|
|
+
|
|
|
+ videoId: this.vgVideoId,
|
|
|
+
|
|
|
+ sourceFileName: this.vgSourceFileName,
|
|
|
+
|
|
|
+ sourceVideoUrl: this.vgSourceVideoUrl,
|
|
|
+
|
|
|
+ step: this.vgStep,
|
|
|
+
|
|
|
+ transcript: this.vgTranscript,
|
|
|
+
|
|
|
+ analysisText: this.vgAnalysisText,
|
|
|
+
|
|
|
+ script: this.vgScript,
|
|
|
+
|
|
|
+ scriptSegments: this.vgScriptSegments,
|
|
|
+
|
|
|
+ imageResults: this.vgImageResults,
|
|
|
+
|
|
|
+ audioUrls: this.vgAudioUrls,
|
|
|
+
|
|
|
+ voiceMode: this.vgVoiceMode,
|
|
|
+
|
|
|
+ voiceSpeakerId: this.vgVoiceSpeakerId,
|
|
|
+
|
|
|
+ voiceTimbreId: this.vgVoiceTimbreId,
|
|
|
+
|
|
|
+ finalVideoUrl: this.vgFinalVideoUrl
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 视频生成:从任务恢复状态
|
|
|
+
|
|
|
+ vgResumeFromTask(task: Task): void {
|
|
|
+
|
|
|
+ if (!task.vgData) {
|
|
|
+
|
|
|
+ this.showToast('该任务没有可恢复的数据', 'warn');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ const d = task.vgData;
|
|
|
+
|
|
|
+ this.vgTaskId = task.id;
|
|
|
+
|
|
|
+ this.vgVideoId = d.videoId;
|
|
|
+
|
|
|
+ this.vgSourceFileName = d.sourceFileName;
|
|
|
+
|
|
|
+ this.vgSourceVideoUrl = d.sourceVideoUrl;
|
|
|
+
|
|
|
+ this.vgSourceFile = null; // File 对象无法持久化
|
|
|
+
|
|
|
+ this.vgStep = d.step;
|
|
|
+
|
|
|
+ this.vgTranscript = d.transcript;
|
|
|
+
|
|
|
+ this.vgAnalysisText = d.analysisText;
|
|
|
+
|
|
|
+ this.vgScript = d.script;
|
|
|
+
|
|
|
+ this.vgScriptSegments = d.scriptSegments || [];
|
|
|
+
|
|
|
+ this.vgImageResults = d.imageResults || [];
|
|
|
+
|
|
|
+ this.vgAudioUrls = d.audioUrls || [];
|
|
|
+
|
|
|
+ this.vgVoiceMode = d.voiceMode || 'system';
|
|
|
+
|
|
|
+ this.vgVoiceSpeakerId = d.voiceSpeakerId || 'zh_female_shuangkuai-am_16k';
|
|
|
+
|
|
|
+ this.vgVoiceTimbreId = d.voiceTimbreId || '';
|
|
|
+
|
|
|
+ this.vgFinalVideoUrl = d.finalVideoUrl || '';
|
|
|
+
|
|
|
+ this.vgError = '';
|
|
|
+
|
|
|
+ this.vgUploading = false;
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.vgGeneratingScript = false;
|
|
|
+
|
|
|
+ this.vgGeneratingImages = false;
|
|
|
+
|
|
|
+ this.vgSynthesizing = false;
|
|
|
+
|
|
|
+ this.vgCompositing = false;
|
|
|
+
|
|
|
+ // 切换到视频生成页
|
|
|
+
|
|
|
+ this.setCurrentTab('video-generation');
|
|
|
+
|
|
|
+ this.showToast(`已恢复任务:${d.sourceFileName},当前在第${d.step}步`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 1: 选择源视频文件
|
|
|
+
|
|
|
+ vgSelectFile(event: Event): void {
|
|
|
+
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+
|
|
|
+ const file = input?.files?.[0];
|
|
|
+
|
|
|
+ if (!file) return;
|
|
|
+
|
|
|
+ if (!file.type.startsWith('video/')) {
|
|
|
+
|
|
|
+ this.showToast('请选择视频文件', 'warn');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.vgSourceFile = file;
|
|
|
+
|
|
|
+ this.vgSourceFileName = file.name;
|
|
|
+
|
|
|
+ this.vgSourceVideoUrl = URL.createObjectURL(file);
|
|
|
+
|
|
|
+ this.vgError = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 1 → 2: 上传视频并进入解析
|
|
|
+
|
|
|
+ vgUploadAndAnalyze(): void {
|
|
|
+
|
|
|
+ if (!this.vgSourceFile || this.vgUploading) return;
|
|
|
+
|
|
|
+ this.vgUploading = true;
|
|
|
+
|
|
|
+ this.vgUploadProgress = 0;
|
|
|
+
|
|
|
+ this.vgError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const formData = new FormData();
|
|
|
+
|
|
|
+ formData.append('video', this.vgSourceFile);
|
|
|
+
|
|
|
+ formData.append('title', `视频生成源 - ${new Date().toLocaleString('zh-CN')}`);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const xhr = new XMLHttpRequest();
|
|
|
+
|
|
|
+ xhr.open('POST', '/backend/api/upload/video');
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.upload.onprogress = (e) => {
|
|
|
+
|
|
|
+ if (e.lengthComputable) {
|
|
|
+
|
|
|
+ this.vgUploadProgress = Math.round((e.loaded / e.total) * 100);
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.onload = () => {
|
|
|
+
|
|
|
+ this.ngZone.run(() => {
|
|
|
+
|
|
|
+ this.vgUploading = false;
|
|
|
+
|
|
|
+ if (xhr.status === 200) {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const res = JSON.parse(xhr.responseText);
|
|
|
+
|
|
|
+ if (res?.success && res?.video?.id) {
|
|
|
+
|
|
|
+ this.vgVideoId = res.video.id;
|
|
|
+
|
|
|
+ this.vgStep = 2;
|
|
|
+
|
|
|
+ this.vgCreateTask();
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-analyze', 15);
|
|
|
+
|
|
|
+ this.vgStartAnalysis();
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.vgError = '上传返回异常';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch { this.vgError = '解析上传结果失败'; }
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.vgError = `上传失败 (${xhr.status})`;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.onerror = () => {
|
|
|
+
|
|
|
+ this.ngZone.run(() => {
|
|
|
+
|
|
|
+ this.vgUploading = false;
|
|
|
+
|
|
|
+ this.vgError = '网络错误,上传失败';
|
|
|
+
|
|
|
+ this.cdr.detectChanges();
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ xhr.send(formData);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 2: Gemini 视频识别(分析 + 转录),Whisper 作为降级方案
|
|
|
+
|
|
|
+ private vgStartAnalysis(): void {
|
|
|
+
|
|
|
+ this.vgAnalyzing = true;
|
|
|
+
|
|
|
+ this.vgTranscript = '';
|
|
|
+
|
|
|
+ this.vgAnalysisText = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const fileSizeMB = this.vgSourceFile ? this.vgSourceFile.size / 1024 / 1024 : 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.vgSourceFile && fileSizeMB <= 20) {
|
|
|
+
|
|
|
+ // ≤20MB:Gemini 直接分析视频(画面+音频+转录)
|
|
|
+
|
|
|
+ this.vgAnalysisText = `正在将视频 (${fileSizeMB.toFixed(1)}MB) 发送给 Gemini 进行视频理解...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunGeminiAnalysisAndTranscript();
|
|
|
+
|
|
|
+ } else if (this.vgVideoId) {
|
|
|
+
|
|
|
+ // >20MB:后端 ffmpeg 提取音轨 → Gemini 音频识别 + 转录
|
|
|
+
|
|
|
+ this.vgAnalysisText = `视频 ${fileSizeMB.toFixed(1)}MB 超过 Gemini 视频限制,正在提取音轨用 Gemini 音频识别...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunAudioExtractAndGemini();
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ // 无文件 → Whisper 降级
|
|
|
+
|
|
|
+ this.vgAnalysisText = '使用 Whisper 语音识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Gemini 一次请求:视频分析 + 精确转录
|
|
|
+
|
|
|
+ private async vgRunGeminiAnalysisAndTranscript(): Promise<void> {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const base64 = await this.llmService.fileToBase64(this.vgSourceFile!);
|
|
|
+
|
|
|
+ const mimeType = this.vgSourceFile!.type || 'video/mp4';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const prompt = `你是一个专业的视频内容分析师。请仔细观看这个视频,完成以下两个任务:
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+===== 任务一:视频内容分析 =====
|
|
|
+
|
|
|
+从画面和音频两个维度进行全面分析,提取:
|
|
|
+
|
|
|
+1. **视频主题和核心观点**:视频讲了什么
|
|
|
+
|
|
|
+2. **画面内容描述**:出现了哪些场景、人物、物体、文字
|
|
|
+
|
|
|
+3. **目标受众**:适合什么人群观看
|
|
|
+
|
|
|
+4. **内容风格**:教程/评测/新闻/故事/口播/Vlog等
|
|
|
+
|
|
|
+5. **关键知识点或亮点**(3-5条)
|
|
|
+
|
|
|
+6. **视觉风格评价**:画面质量、构图、色调、字幕等
|
|
|
+
|
|
|
+7. **建议的改进方向**
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+===== 任务二:完整语音转录 =====
|
|
|
+
|
|
|
+请将视频中所有语音内容逐字转录为中文文本。要求:
|
|
|
+
|
|
|
+- 忠实还原每一句话,不遗漏、不改写、不总结
|
|
|
+
|
|
|
+- 保留说话人的原始用词和语气
|
|
|
+
|
|
|
+- 按自然段落分段
|
|
|
+
|
|
|
+- 如有专业术语或人名,请尽量准确
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+请严格按以下格式输出(用分隔线区分两部分):
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+【内容分析】
|
|
|
+
|
|
|
+(这里输出任务一的分析结果)
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+---TRANSCRIPT_START---
|
|
|
+
|
|
|
+(这里输出任务二的完整语音转录文本,只包含转录内容,不要加任何说明)
|
|
|
+
|
|
|
+---TRANSCRIPT_END---`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.llmService.analyzeVideo(base64, mimeType, prompt, {
|
|
|
+
|
|
|
+ model: 'gemini-2.5-flash',
|
|
|
+
|
|
|
+ generationConfig: { maxOutputTokens: 8192 }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ this.vgParseGeminiResult(result);
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-analyze', 25);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ Gemini 视频识别失败,降级为 Whisper:', err);
|
|
|
+
|
|
|
+ this.vgAnalysisText = 'Gemini 识别失败,正在使用 Whisper 语音识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (err) {
|
|
|
+
|
|
|
+ console.warn('⚠️ 视频文件读取失败:', err);
|
|
|
+
|
|
|
+ this.vgAnalysisText = '视频文件读取失败,正在使用 Whisper 语音识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 解析 Gemini 返回结果:拆分「内容分析」和「转录文本」
|
|
|
+
|
|
|
+ private vgParseGeminiResult(raw: string): void {
|
|
|
+
|
|
|
+ const transcriptMatch = raw.match(/---TRANSCRIPT_START---([\s\S]*?)---TRANSCRIPT_END---/);
|
|
|
+
|
|
|
+ if (transcriptMatch) {
|
|
|
+
|
|
|
+ this.vgTranscript = transcriptMatch[1].trim();
|
|
|
+
|
|
|
+ // 分析部分 = 转录标记之前的内容
|
|
|
+
|
|
|
+ const analysisPart = raw.substring(0, raw.indexOf('---TRANSCRIPT_START---')).trim();
|
|
|
+
|
|
|
+ this.vgAnalysisText = analysisPart.replace(/^【内容分析】\s*/, '') || '分析完成';
|
|
|
+
|
|
|
+ console.log(`✅ Gemini 分析+转录完成 (转录 ${this.vgTranscript.length} 字)`);
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ // 没有找到转录标记,整体作为分析结果
|
|
|
+
|
|
|
+ this.vgAnalysisText = raw || '分析完成';
|
|
|
+
|
|
|
+ console.warn('⚠️ Gemini 未返回转录标记,需手动输入文字稿');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // >20MB 视频:后端 ffmpeg 提取音轨 → Gemini 音频识别(分析+转录)
|
|
|
+
|
|
|
+ private vgRunAudioExtractAndGemini(): void {
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/extract-audio-mp3', {
|
|
|
+
|
|
|
+ videoId: this.vgVideoId
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.success && res?.audio?.base64) {
|
|
|
+
|
|
|
+ const audioSizeMB = res.audio.sizeMB || 0;
|
|
|
+
|
|
|
+ console.log(`🎵 音轨提取完成 (${audioSizeMB}MB),发送给 Gemini 音频识别...`);
|
|
|
+
|
|
|
+ this.vgAnalysisText = `音轨提取完成 (${audioSizeMB.toFixed(1)}MB),正在 Gemini 音频识别...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (audioSizeMB > 20) {
|
|
|
+
|
|
|
+ // 音频仍然过大,降级为 Whisper
|
|
|
+
|
|
|
+ console.warn('⚠️ 提取的音频仍超过 20MB,降级为 Whisper');
|
|
|
+
|
|
|
+ this.vgAnalysisText = '音频文件仍较大,改用 Whisper 识别...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunWhisperFallback();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const prompt = `你是一个专业的音频内容分析师。请仔细听这段音频,完成以下两个任务:
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+===== 任务一:内容分析 =====
|
|
|
+
|
|
|
+从音频内容进行分析,提取:
|
|
|
+
|
|
|
+1. **主题和核心观点**:音频讲了什么
|
|
|
+
|
|
|
+2. **目标受众**:适合什么人群
|
|
|
+
|
|
|
+3. **内容风格**:教程/评测/新闻/故事/口播等
|
|
|
+
|
|
|
+4. **关键知识点或亮点**(3-5条)
|
|
|
+
|
|
|
+5. **建议的改进方向**
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+===== 任务二:完整语音转录 =====
|
|
|
+
|
|
|
+请将音频中所有语音内容逐字转录为中文文本。要求:
|
|
|
+
|
|
|
+- 忠实还原每一句话,不遗漏、不改写、不总结
|
|
|
+
|
|
|
+- 保留说话人的原始用词和语气
|
|
|
+
|
|
|
+- 按自然段落分段
|
|
|
+
|
|
|
+- 如有专业术语或人名,请尽量准确
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+请严格按以下格式输出(用分隔线区分两部分):
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+【内容分析】
|
|
|
+
|
|
|
+(这里输出任务一的分析结果)
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+---TRANSCRIPT_START---
|
|
|
+
|
|
|
+(这里输出任务二的完整语音转录文本,只包含转录内容,不要加任何说明)
|
|
|
+
|
|
|
+---TRANSCRIPT_END---`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.llmService.analyzeAudio(res.audio.base64, res.audio.mimeType || 'audio/mp3', prompt, {
|
|
|
+
|
|
|
+ model: 'gemini-2.5-flash',
|
|
|
+
|
|
|
+ generationConfig: { maxOutputTokens: 8192 }
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ this.vgParseGeminiResult(result);
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn('⚠️ Gemini 音频识别失败,降级为 Whisper:', err);
|
|
|
+
|
|
|
+ this.vgAnalysisText = 'Gemini 音频识别失败,改用 Whisper...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ console.warn('⚠️ 音轨提取未返回数据,降级为 Whisper');
|
|
|
+
|
|
|
+ this.vgRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ const detail = err?.error?.error || err?.message || `HTTP ${err?.status}`;
|
|
|
+
|
|
|
+ console.warn('⚠️ 音轨提取失败:', detail, err);
|
|
|
+
|
|
|
+ this.vgAnalysisText = `音轨提取失败 (${detail}),改用 Whisper...`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ this.vgRunWhisperFallback();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 降级方案:Whisper 转录 + GPT 文本分析
|
|
|
+
|
|
|
+ private vgRunWhisperFallback(): void {
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/whisper/transcribe', {
|
|
|
+
|
|
|
+ videoId: this.vgVideoId,
|
|
|
+
|
|
|
+ language: 'Chinese',
|
|
|
+
|
|
|
+ model: 'large'
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.success && res?.transcript) {
|
|
|
+
|
|
|
+ this.vgTranscript = res.transcript;
|
|
|
+
|
|
|
+ this.vgAnalysisText = 'Whisper 转录完成,正在 AI 分析内容...';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ // 用 GPT 做文本分析
|
|
|
+
|
|
|
+ this.vgDoTextAnalysis();
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.vgAnalysisText = '语音识别完成但未返回文字稿,请手动输入';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.vgAnalysisText = `Whisper 识别失败: ${err?.error?.error || err?.message || '未知错误'}。请手动输入文字稿。`;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private vgDoTextAnalysis(): void {
|
|
|
+
|
|
|
+ const systemPrompt = `你是一个专业的视频内容分析师。请分析以下视频的转录文字稿,提取:
|
|
|
+
|
|
|
+1. 视频主题和核心观点
|
|
|
+
|
|
|
+2. 目标受众
|
|
|
+
|
|
|
+3. 内容风格(教程/评测/新闻/故事等)
|
|
|
+
|
|
|
+4. 关键知识点或亮点(3-5条)
|
|
|
+
|
|
|
+5. 建议的改进方向
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+请用简洁的中文回复。`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.llmService.askWithSystem(systemPrompt, this.vgTranscript, { model: 'gemini-2.5-flash', max_tokens: 1024 }).subscribe({
|
|
|
+
|
|
|
+ next: (analysis) => {
|
|
|
+
|
|
|
+ this.vgAnalysisText = analysis || '分析完成';
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ this.vgAnalysisText = '(AI 文本分析暂不可用,已完成 Whisper 转录)';
|
|
|
+
|
|
|
+ this.vgAnalyzing = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vgConfirmAnalysis(): void {
|
|
|
+
|
|
|
+ if (!this.vgTranscript.trim()) {
|
|
|
+
|
|
|
+ this.showToast('请先完成视频转录或手动输入文字稿', 'warn');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.vgStep = 3;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-script', 30);
|
|
|
+
|
|
|
+ this.vgGenerateScript();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 3: LLM 生成分镜脚本
|
|
|
+
|
|
|
+ private vgGenerateScript(): void {
|
|
|
+
|
|
|
+ this.vgGeneratingScript = true;
|
|
|
+
|
|
|
+ this.vgScript = '';
|
|
|
+
|
|
|
+ this.vgScriptSegments = [];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const systemPrompt = `你是一个专业的短视频脚本编剧。根据以下视频转录文字稿,生成一个完整的分镜脚本。
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+⚠️ 最重要的要求:你必须完整覆盖原文的全部内容,不得遗漏任何段落或要点!将原文从头到尾全部改写为分镜旁白,确保信息量100%覆盖。
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+要求:
|
|
|
+
|
|
|
+1. 根据内容长度自动决定片段数量(短文5-8段,长文8-15段),确保原文每一段内容都被覆盖到
|
|
|
+
|
|
|
+2. 每个片段包含:旁白文本(narration)、配图英文提示词(imagePrompt)、配图中文提示词(imagePromptCn)
|
|
|
+
|
|
|
+3. 旁白文本要求:必须包含原文中的所有关键信息、数据、观点,不能省略或概括,只做口语化改写
|
|
|
+
|
|
|
+4. 配图中文提示词(imagePromptCn)是最重要的配图生成依据,要求:
|
|
|
+
|
|
|
+ - 描述一幅完整的16:9横版信息图/示意图画面
|
|
|
+
|
|
|
+ - 必须全部用中文描述,图片中的所有文字标注都是中文
|
|
|
+
|
|
|
+ - 内容紧密关联该段旁白,提取关键数据、概念、流程等
|
|
|
+
|
|
|
+ - 风格:白色或浅色简洁背景,扁平化设计,信息图表/流程图/概念图
|
|
|
+
|
|
|
+ - 包含中文大标题、关键词、数据可视化、图标、箭头等元素
|
|
|
+
|
|
|
+ - 注意:提示词中不要出现"PPT"、"幻灯片"、"演示文稿"等字样,只描述画面内容本身
|
|
|
+
|
|
|
+5. 配图英文提示词(imagePrompt)是备选提示词,描述同样的画面内容
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+请严格按以下 JSON 格式返回(不要包含其他文字,不要用 markdown 代码块包裹):
|
|
|
+
|
|
|
+[
|
|
|
+
|
|
|
+ {"id": "S-01", "narration": "旁白文本...", "imagePrompt": "English: 16:9 infographic...", "imagePromptCn": "16:9横版信息图,白色背景,大标题"xxx",下方用流程图展示xxx的步骤..."},
|
|
|
+
|
|
|
+ {"id": "S-02", "narration": "旁白文本...", "imagePrompt": "English: 16:9 infographic...", "imagePromptCn": "16:9横版信息图,浅色背景,标题"xxx",用图标和箭头展示xxx的关系..."}
|
|
|
+
|
|
|
+]`;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.llmService.askWithSystem(systemPrompt, this.vgTranscript, { model: 'gemini-2.5-flash', max_tokens: 16384 }).subscribe({
|
|
|
+
|
|
|
+ next: (result) => {
|
|
|
+
|
|
|
+ this.vgScript = result;
|
|
|
+
|
|
|
+ this.vgGeneratingScript = false;
|
|
|
+
|
|
|
+ this.vgParseScriptSegments(result);
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-script', 40);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ this.vgGeneratingScript = false;
|
|
|
+
|
|
|
+ this.vgError = `脚本生成失败: ${err?.message || '请重试'}`;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-script', 30, 'failed');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private vgParseScriptSegments(raw: string): void {
|
|
|
+
|
|
|
+ console.log('📝 原始脚本响应:', raw.substring(0, 500));
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 1. 去除 markdown code block 包裹
|
|
|
+
|
|
|
+ let cleaned = raw;
|
|
|
+
|
|
|
+ const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
|
+
|
|
|
+ if (codeBlockMatch) {
|
|
|
+
|
|
|
+ cleaned = codeBlockMatch[1].trim();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 2. 尝试提取 JSON 数组
|
|
|
+
|
|
|
+ const jsonMatch = cleaned.match(/\[[\s\S]*\]/);
|
|
|
+
|
|
|
+ if (jsonMatch) {
|
|
|
+
|
|
|
+ cleaned = jsonMatch[0];
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 3. 修复常见 JSON 问题(尾部逗号)
|
|
|
+
|
|
|
+ cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const parsed = JSON.parse(cleaned);
|
|
|
+
|
|
|
+ if (Array.isArray(parsed) && parsed.length > 0) {
|
|
|
+
|
|
|
+ this.vgScriptSegments = parsed.map((item: any, i: number) => ({
|
|
|
+
|
|
|
+ id: item.id || `S-${String(i + 1).padStart(2, '0')}`,
|
|
|
+
|
|
|
+ narration: item.narration || '',
|
|
|
+
|
|
|
+ imagePrompt: item.imagePrompt || item.image_prompt || '',
|
|
|
+
|
|
|
+ imagePromptCn: item.imagePromptCn || item.image_prompt_cn || ''
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ console.log(`✅ 脚本解析成功: ${this.vgScriptSegments.length} 个片段`);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+
|
|
|
+ console.warn('⚠️ JSON.parse 失败:', (e as Error).message);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 4. 逐对象提取 fallback:正则匹配每个 {...} 块
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const objMatches = cleaned.match(/\{[^{}]*\}/g);
|
|
|
+
|
|
|
+ if (objMatches && objMatches.length > 0) {
|
|
|
+
|
|
|
+ const segments: any[] = [];
|
|
|
+
|
|
|
+ for (const objStr of objMatches) {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const obj = JSON.parse(objStr.replace(/,\s*}/g, '}'));
|
|
|
+
|
|
|
+ if (obj.narration) segments.push(obj);
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (segments.length > 0) {
|
|
|
+
|
|
|
+ this.vgScriptSegments = segments.map((item: any, i: number) => ({
|
|
|
+
|
|
|
+ id: item.id || `S-${String(i + 1).padStart(2, '0')}`,
|
|
|
+
|
|
|
+ narration: item.narration || '',
|
|
|
+
|
|
|
+ imagePrompt: item.imagePrompt || item.image_prompt || '',
|
|
|
+
|
|
|
+ imagePromptCn: item.imagePromptCn || item.image_prompt_cn || ''
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ console.log(`✅ 逐对象解析成功: ${this.vgScriptSegments.length} 个片段`);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 5. 最终 fallback:保留原始文本供用户手动编辑
|
|
|
+
|
|
|
+ console.warn('⚠️ 脚本解析全部失败,显示原文供手动编辑');
|
|
|
+
|
|
|
+ this.vgScriptSegments = [{
|
|
|
+
|
|
|
+ id: 'S-01',
|
|
|
+
|
|
|
+ narration: raw.replace(/```[\s\S]*?```/g, '').trim(),
|
|
|
+
|
|
|
+ imagePrompt: 'Professional PPT slide illustration, clean white background, infographic style',
|
|
|
+
|
|
|
+ imagePromptCn: '(脚本自动解析失败,请手动拆分片段)'
|
|
|
+
|
|
|
+ }];
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vgConfirmScript(): void {
|
|
|
+
|
|
|
+ if (this.vgScriptSegments.length === 0) {
|
|
|
+
|
|
|
+ this.showToast('请先生成或编辑脚本', 'warn');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.vgStep = 4;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-image', 45);
|
|
|
+
|
|
|
+ // 初始化图片结果列表
|
|
|
+
|
|
|
+ this.vgImageResults = this.vgScriptSegments.map(seg => ({
|
|
|
+
|
|
|
+ id: seg.id,
|
|
|
+
|
|
|
+ prompt: seg.imagePromptCn || seg.imagePrompt,
|
|
|
+
|
|
|
+ imageUrl: '',
|
|
|
+
|
|
|
+ status: 'pending' as const
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 4: 批量生成配图(使用即梦 API)
|
|
|
+
|
|
|
+ vgGenerateImages(): void {
|
|
|
+
|
|
|
+ if (this.vgGeneratingImages) return;
|
|
|
+
|
|
|
+ this.vgGeneratingImages = true;
|
|
|
+
|
|
|
+ this.vgImageProgress = 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const pending = this.vgImageResults.filter(r => r.status === 'pending');
|
|
|
+
|
|
|
+ if (pending.length === 0) {
|
|
|
+
|
|
|
+ this.vgGeneratingImages = false;
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ let completed = 0;
|
|
|
+
|
|
|
+ const total = pending.length;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const RETRY_MAX = 5;
|
|
|
+
|
|
|
+ const RETRY_DELAY = 15000; // 并发超限时等 15 秒重试
|
|
|
+
|
|
|
+ const GAP_DELAY = 6000; // 每张图之间间隔 6 秒,避免并发
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const generateNext = (index: number, retryCount: number = 0) => {
|
|
|
+
|
|
|
+ if (index >= pending.length) {
|
|
|
+
|
|
|
+ this.vgGeneratingImages = false;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-image', 60);
|
|
|
+
|
|
|
+ this.showToast(`配图生成完成 (${completed}/${total})`, completed > 0 ? 'success' : 'warn');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const item = pending[index];
|
|
|
+
|
|
|
+ item.status = 'generating';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.jimengService.generateImage(item.prompt, 1920, 1080).subscribe({
|
|
|
+
|
|
|
+ next: (res: any) => {
|
|
|
+
|
|
|
+ const workId = res?.data?.workId || res?.workId;
|
|
|
+
|
|
|
+ if (!workId) {
|
|
|
+
|
|
|
+ item.status = 'failed';
|
|
|
+
|
|
|
+ completed++;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ setTimeout(() => generateNext(index + 1), GAP_DELAY);
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.jimengService.pollUntilComplete(workId, 'getText2ImgV31', (status: any, attempt: number) => {
|
|
|
+
|
|
|
+ this.vgImageProgress = Math.round(((completed + Math.min(attempt / 20, 0.9)) / total) * 100);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (result: any) => {
|
|
|
+
|
|
|
+ const imageUrl = result?.images?.[0] || result?.url || '';
|
|
|
+
|
|
|
+ item.imageUrl = imageUrl;
|
|
|
+
|
|
|
+ item.status = imageUrl ? 'done' : 'failed';
|
|
|
+
|
|
|
+ completed++;
|
|
|
+
|
|
|
+ this.vgImageProgress = Math.round((completed / total) * 100);
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ setTimeout(() => generateNext(index + 1), GAP_DELAY);
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: () => {
|
|
|
+
|
|
|
+ item.status = 'failed';
|
|
|
+
|
|
|
+ completed++;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ setTimeout(() => generateNext(index + 1), GAP_DELAY);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ const errMsg = err?.error?.error?.message || err?.error?.message || err?.message || '';
|
|
|
+
|
|
|
+ const isRetryable = errMsg.includes('并发超限') || err?.status === 500 || err?.status === 429;
|
|
|
+
|
|
|
+ if (isRetryable && retryCount < RETRY_MAX) {
|
|
|
+
|
|
|
+ const backoff = RETRY_DELAY * (retryCount + 1); // 递增退避: 15s, 30s, 45s...
|
|
|
+
|
|
|
+ console.warn(`⚠️ 配图 ${item.id} 请求失败(${errMsg || err?.status}),${backoff / 1000}s 后第${retryCount + 1}次重试...`);
|
|
|
+
|
|
|
+ item.status = 'pending';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ setTimeout(() => generateNext(index, retryCount + 1), backoff);
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ console.error(`❌ 配图 ${item.id} 最终失败:`, errMsg || err?.status);
|
|
|
+
|
|
|
+ item.status = 'failed';
|
|
|
+
|
|
|
+ completed++;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ setTimeout(() => generateNext(index + 1), GAP_DELAY);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ generateNext(0);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vgConfirmImages(): void {
|
|
|
+
|
|
|
+ this.vgStep = 5;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-voice', 65);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 5: 语音配音
|
|
|
+
|
|
|
+ vgSynthesizeAll(): void {
|
|
|
+
|
|
|
+ if (this.vgSynthesizing) return;
|
|
|
+
|
|
|
+ this.vgSynthesizing = true;
|
|
|
+
|
|
|
+ this.vgAudioUrls = [];
|
|
|
+
|
|
|
+ this.vgError = '';
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const segments = this.vgScriptSegments;
|
|
|
+
|
|
|
+ let index = 0;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const synthNext = () => {
|
|
|
+
|
|
|
+ if (index >= segments.length) {
|
|
|
+
|
|
|
+ this.vgSynthesizing = false;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-voice', 80);
|
|
|
+
|
|
|
+ this.showToast(`配音完成 (${this.vgAudioUrls.length}/${segments.length})`, 'success');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ const seg = segments[index];
|
|
|
+
|
|
|
+ const text = seg.narration;
|
|
|
+
|
|
|
+ if (!text.trim()) {
|
|
|
+
|
|
|
+ index++;
|
|
|
+
|
|
|
+ synthNext();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 使用远程 TTS 合成(server.fmode.cn)
|
|
|
+
|
|
|
+ const payload: any = {
|
|
|
+
|
|
|
+ token: this.voiceApiToken,
|
|
|
+
|
|
|
+ text,
|
|
|
+
|
|
|
+ isStream: false,
|
|
|
+
|
|
|
+ audio_params: {
|
|
|
+
|
|
|
+ format: 'mp3',
|
|
|
+
|
|
|
+ sample_rate: 24000,
|
|
|
+
|
|
|
+ speech_rate: 0,
|
|
|
+
|
|
|
+ loudness_rate: 0
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (this.vgVoiceMode === 'clone' && this.vgVoiceTimbreId) {
|
|
|
+
|
|
|
+ payload.timbreId = this.vgVoiceTimbreId;
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ payload.speaker_id = this.vgVoiceSpeakerId || 'zh_female_shuangkuai-am_16k';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>(`${this.voiceTtsBaseUrl}/unidirectional`, payload).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ const audioUrl = res?.data?.audioUrl || '';
|
|
|
+
|
|
|
+ if (audioUrl) {
|
|
|
+
|
|
|
+ this.vgAudioUrls.push({ segId: seg.id, audioUrl });
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ console.warn(`配音片段 ${seg.id}: 未返回 audioUrl`, res);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ index++;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ synthNext();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ console.warn(`配音片段 ${seg.id} 失败:`, err);
|
|
|
+
|
|
|
+ index++;
|
|
|
+
|
|
|
+ synthNext();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ synthNext();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ vgConfirmVoice(): void {
|
|
|
+
|
|
|
+ this.vgStep = 6;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-composite', 85);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // Step 6: 合成最终视频(图片+音频 → 视频)
|
|
|
+
|
|
|
+ vgComposite(): void {
|
|
|
+
|
|
|
+ this.vgCompositing = true;
|
|
|
+
|
|
|
+ this.vgCompositingProgress = 0;
|
|
|
+
|
|
|
+ this.vgFinalVideoUrl = '';
|
|
|
+
|
|
|
+ this.vgError = '';
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 构建片段数据:匹配图片和音频
|
|
|
+
|
|
|
+ const segments: { id: string; imageUrl: string; audioUrl: string; }[] = [];
|
|
|
+
|
|
|
+ for (const seg of this.vgScriptSegments) {
|
|
|
+
|
|
|
+ const img = this.vgImageResults.find(r => r.id === seg.id);
|
|
|
+
|
|
|
+ const audio = this.vgAudioUrls.find(a => a.segId === seg.id);
|
|
|
+
|
|
|
+ if (img?.imageUrl && audio?.audioUrl) {
|
|
|
+
|
|
|
+ segments.push({ id: seg.id, imageUrl: img.imageUrl, audioUrl: audio.audioUrl });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if (segments.length === 0) {
|
|
|
+
|
|
|
+ this.vgCompositing = false;
|
|
|
+
|
|
|
+ this.vgError = '没有可用的图片+音频配对,无法合成视频';
|
|
|
+
|
|
|
+ this.showToast(this.vgError, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 模拟进度(因为后端是同步处理,用定时器模拟进度条)
|
|
|
+
|
|
|
+ let fakeProgress = 0;
|
|
|
+
|
|
|
+ const progressTimer = setInterval(() => {
|
|
|
+
|
|
|
+ fakeProgress = Math.min(fakeProgress + 2, 90);
|
|
|
+
|
|
|
+ this.vgCompositingProgress = fakeProgress;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }, 1000);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/video/composite', {
|
|
|
+
|
|
|
+ segments,
|
|
|
+
|
|
|
+ title: this.vgSourceFileName?.replace(/\.[^.]+$/, '') || '视频生成'
|
|
|
+
|
|
|
+ }).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ clearInterval(progressTimer);
|
|
|
+
|
|
|
+ this.vgCompositingProgress = 100;
|
|
|
+
|
|
|
+ this.vgCompositing = false;
|
|
|
+
|
|
|
+ if (res?.success && res?.videoUrl) {
|
|
|
+
|
|
|
+ this.vgFinalVideoUrl = '/backend' + res.videoUrl;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-composite', 100, 'completed');
|
|
|
+
|
|
|
+ this.showToast(`视频合成完成!共 ${res.segments} 个片段`, 'success');
|
|
|
+
|
|
|
+ } else {
|
|
|
+
|
|
|
+ this.vgError = '合成返回结果异常';
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-composite', 90, 'failed');
|
|
|
+
|
|
|
+ this.showToast(this.vgError, 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => {
|
|
|
+
|
|
|
+ clearInterval(progressTimer);
|
|
|
+
|
|
|
+ this.vgCompositing = false;
|
|
|
+
|
|
|
+ this.vgCompositingProgress = 0;
|
|
|
+
|
|
|
+ this.vgError = `视频合成失败: ${err?.error?.error || err?.message || '请重试'}`;
|
|
|
+
|
|
|
+ this.vgUpdateTaskProgress('vg-composite', 85, 'failed');
|
|
|
+
|
|
|
+ this.showToast(this.vgError, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 回到上一步
|
|
|
+
|
|
|
+ vgPrevStep(): void {
|
|
|
+
|
|
|
+ if (this.vgStep > 1) this.vgStep--;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // ===== 卡片瀑布展开/折叠控制 =====
|
|
|
+
|
|
|
+ vgGetStepState(n: number): 'done' | 'active' | 'locked' {
|
|
|
+
|
|
|
+ if (n < this.vgStep) return 'done';
|
|
|
+
|
|
|
+ if (n === this.vgStep) return 'active';
|
|
|
+
|
|
|
+ return 'locked';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ vgIsStepExpanded(n: number): boolean {
|
|
|
+
|
|
|
+ if (n > this.vgStep) return false; // 未解锁:永远折叠
|
|
|
+
|
|
|
+ if (n === this.vgStep) return true; // 当前步骤:始终展开
|
|
|
+
|
|
|
+ return this.vgUserExpandedSteps.has(n); // 已完成:仅在用户手动展开时显示
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ vgToggleStepExpand(n: number): void {
|
|
|
+
|
|
|
+ if (n >= this.vgStep) return; // 当前/未解锁不可切换
|
|
|
+
|
|
|
+ if (this.vgUserExpandedSteps.has(n)) this.vgUserExpandedSteps.delete(n);
|
|
|
+
|
|
|
+ else this.vgUserExpandedSteps.add(n);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ // 跳转到某个已完成步骤进行编辑(同时展开它)
|
|
|
+
|
|
|
+ vgEditStep(n: number): void {
|
|
|
+
|
|
|
+ if (n > this.vgStep) return; // 不能跳到未解锁
|
|
|
+
|
|
|
+ if (n < this.vgStep) {
|
|
|
+
|
|
|
+ const downstream = this.vgStep - n;
|
|
|
+
|
|
|
+ const ok = confirm(`返回步骤 ${n}「${this.vgStepLabels[n - 1]}」编辑后,需要重新确认下游 ${downstream} 个步骤,相关结果(脚本/配图/配音/合成)可能会被覆盖。是否继续?`);
|
|
|
+
|
|
|
+ if (!ok) return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.vgStep = n;
|
|
|
+
|
|
|
+ // 同时把该步骤从用户展开集合中清除(active 步骤会自动展开)
|
|
|
+
|
|
|
+ this.vgUserExpandedSteps.delete(n);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 重新生成脚本
|
|
|
+
|
|
|
+ vgRegenerateScript(): void {
|
|
|
+
|
|
|
+ this.vgGenerateScript();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private addDhVideoToManifest(videoUrl: string): void {
|
|
|
+
|
|
|
+ const now = new Date();
|
|
|
+
|
|
|
+ const id = `DH-${Date.now()}`;
|
|
|
+
|
|
|
+ const title = `数字人合成 - ${now.toLocaleString('zh-CN')}`;
|
|
|
+
|
|
|
+ const filename = videoUrl.split('/').pop() || `digital-human-${id}.mp4`;
|
|
|
+
|
|
|
+ const newEntry = {
|
|
|
+
|
|
|
+ id,
|
|
|
+
|
|
|
+ title,
|
|
|
+
|
|
|
+ filename,
|
|
|
+
|
|
|
+ filepath: videoUrl,
|
|
|
+
|
|
|
+ size: 0,
|
|
|
+
|
|
|
+ duration: 0,
|
|
|
+
|
|
|
+ created_at: now.toISOString(),
|
|
|
+
|
|
|
+ modified_at: now.toISOString(),
|
|
|
+
|
|
|
+ category: 'general',
|
|
|
+
|
|
|
+ tags: ['数字人', 'AI生成'],
|
|
|
+
|
|
|
+ description: `数字人合成视频 (${this.dhResolution})`,
|
|
|
+
|
|
|
+ source: 'generated',
|
|
|
+
|
|
|
+ metadata: {
|
|
|
+
|
|
|
+ resolution: this.dhResolution === '1080p' ? '1920x1080' : '1280x720',
|
|
|
+
|
|
|
+ format: 'mp4'
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ this.http.post<any>('/backend/api/manifest', newEntry).subscribe({
|
|
|
+
|
|
|
+ next: (res) => {
|
|
|
+
|
|
|
+ if (res?.success) {
|
|
|
+
|
|
|
+ this.managedVideos.unshift({
|
|
|
+
|
|
|
+ ...newEntry,
|
|
|
+
|
|
|
+ created_at: now,
|
|
|
+
|
|
|
+ modified_at: now,
|
|
|
+
|
|
|
+ source: 'generated' as const
|
|
|
+
|
|
|
+ } as ManagedVideo);
|
|
|
+
|
|
|
+ this.updateCategoryCounts();
|
|
|
+
|
|
|
+ console.log('📁 数字人视频已同步到视频管理:', id);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err) => console.warn('⚠️ 同步视频管理失败:', err)
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+}
|
|
|
+
|