import { Component, signal, NgZone, ChangeDetectorRef, ViewEncapsulation, inject } 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 { PortraitPipelineService, PortraitPipelineResult } from './services/portrait-pipeline.service'; import { DigitalHumanService, DigitalHumanRequest, DigitalHumanEvent } from './services/digital-human.service'; import { DigitalHumanFormComponent, TimbreOption, PromptPreset } from './components/digital-human-form/digital-human-form.component'; import { PortraitPrepareComponent, PortraitPrepareOutput } from './components/portrait-prepare/portrait-prepare.component'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { timeout, finalize, switchMap } from 'rxjs/operators'; import { of, Observable, from } from 'rxjs'; import { PIPELINES, PipelineDef, getSidebarPipelines } from './pipelines/pipeline-registry'; import { ImageToVideoComponent } from './pages/pipelines/image-to-video/image-to-video.component'; import { ActionTransferComponent } from './pages/pipelines/action-transfer/action-transfer.component'; import { AssetRemixComponent } from './pages/pipelines/asset-remix/asset-remix.component'; import { TopicToVideoComponent } from './pages/pipelines/topic-to-video/topic-to-video.component'; import { ImageGenerationComponent } from './pages/pipelines/image-generation/image-generation.component'; import { DraftsComponent } from './pages/drafts/drafts.component'; import { LibraryComponent } from './pages/library/library.component'; import { PipelineSessionService } from './services/pipeline-session.service'; import { QiniuUploadService } from './services/qiniu-upload.service'; import { BrowserFfmpegService, BrowserAudioExtractionResult } from './services/browser-ffmpeg.service'; import { PipelineRoute } from './pipelines/pipeline-registry'; import { AppSidebarComponent } from './components/app-sidebar/app-sidebar.component'; import { HomeComponent } from './pages/home/home.component'; import { RecentTaskPillComponent, RecentTaskView } from './components/recent-task-pill/recent-task-pill.component'; import { AppAssistantComponent } from './components/app-assistant/app-assistant.component'; import { AssistantSuggestion } from './services/assistant.service'; import { OFFICIAL_VOICE_LIBRARY, VoicePickerComponent, VoiceSelection } from './components/voice-picker/voice-picker.component'; // 类型定义 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; voiceName?: 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 }[]; /** P0+: 所属生成模式(来自 pipeline-registry,如 'digital_human' / 'image_to_video' 等) */ pipelineId?: 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; /** P0+: 所属生成模式 id(与 pipeline-registry.PIPELINES[*].id 对应) */ pipelineId?: 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, DigitalHumanFormComponent, PortraitPrepareComponent, ImageToVideoComponent, ActionTransferComponent, AssetRemixComponent, TopicToVideoComponent, ImageGenerationComponent, DraftsComponent, LibraryComponent, AppSidebarComponent, HomeComponent, RecentTaskPillComponent, AppAssistantComponent, VoicePickerComponent ], templateUrl: './app.html', styleUrl: './app.css', // 关键:app.css 包含全局 dh-* / pl-* / pipeline-* 等设计系统类, // 必须设为 None 才能让其作用到 standalone 子组件(图生视频/动作迁移/ // 素材合成/主题生视频)。否则 Angular 默认 Emulated 会给所有选择器 // 添加 [_ngcontent-xxx] 属性后缀,导致子组件 DOM 无法匹配,UI 失样。 encapsulation: ViewEncapsulation.None }) export class App { private pipelineSession = inject(PipelineSessionService); protected readonly title = signal('抖音AI视频生成系统'); // 当前激活的标签页 currentTab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' = 'home'; /** Pipeline 注册中心数据,模板中通过 *ngFor 动态渲染 */ readonly pipelines = PIPELINES; readonly sidebarPipelines = getSidebarPipelines(); 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, /** 当前选中音色来源:'cloned' = 用户复刻;'official' = 官方音色库 */ voiceSource: 'cloned' as 'cloned' | 'official', /** 仅 voiceSource='official' 时使用,对应火山 voice_type */ voiceType: '', /** 当前选中音色的显示名(UI 用) */ voiceName: '' }; /** 音色选择弹窗开关 */ voicePickerOpen: boolean = false; /** 传给弹窗用于回显高亮的当前选择 */ get currentVoiceSelection(): VoiceSelection | null { const f = this.vsSynthForm; if (f.voiceSource === 'official' && f.voiceType) { return { source: 'official', name: f.voiceName, id: f.voiceType, voiceType: f.voiceType }; } if (f.voiceSource === 'cloned' && f.timbreId) { return { source: 'cloned', name: f.voiceName || f.timbreId, id: f.timbreId, timbreId: f.timbreId }; } return null; } openVoicePicker(): void { this.voicePickerOpen = true; } closeVoicePicker(): void { this.voicePickerOpen = false; } onVoicePicked(sel: VoiceSelection): void { this.vsSynthForm.voiceSource = sel.source; this.vsSynthForm.voiceName = sel.name; if (sel.source === 'official') { this.vsSynthForm.voiceType = sel.voiceType || sel.id; this.vsSynthForm.timbreId = ''; } else { this.vsSynthForm.timbreId = sel.timbreId || sel.id; this.vsSynthForm.voiceType = ''; } this.vsSynthError = ''; this.voicePickerOpen = false; this.refreshView(); } private getOfficialVoiceName(voiceType: string): string { return OFFICIAL_VOICE_LIBRARY.find((item) => item.voiceType === voiceType)?.name || voiceType; } get vgCurrentVoiceSelection(): VoiceSelection | null { if (this.vgVoiceMode === 'clone' && this.vgVoiceTimbreId) { const matched = this.vsClonedTimbreList.find((item) => item.objectId === this.vgVoiceTimbreId); return { source: 'cloned', name: this.vgVoiceName || matched?.name || this.vgVoiceTimbreId, id: this.vgVoiceTimbreId, timbreId: this.vgVoiceTimbreId, speakerId: matched?.speakerId, }; } if (this.vgVoiceMode === 'system' && this.vgVoiceSpeakerId) { return { source: 'official', name: this.vgVoiceName || this.getOfficialVoiceName(this.vgVoiceSpeakerId), id: this.vgVoiceSpeakerId, voiceType: this.vgVoiceSpeakerId, }; } return null; } openVgVoicePicker(): void { this.vgVoicePickerOpen = true; } closeVgVoicePicker(): void { this.vgVoicePickerOpen = false; } onVgVoicePicked(sel: VoiceSelection): void { this.vgVoiceMode = sel.source === 'official' ? 'system' : 'clone'; this.vgVoiceName = sel.name; if (sel.source === 'official') { this.vgVoiceSpeakerId = sel.voiceType || sel.id; this.vgVoiceTimbreId = ''; } else { this.vgVoiceTimbreId = sel.timbreId || sel.id; this.vgVoiceSpeakerId = ''; } this.vgError = ''; this.vgVoicePickerOpen = false; this.refreshView(); } get dhCurrentVoiceSelection(): VoiceSelection | null { if (this.dhTtsVoiceSource === 'official' && this.dhTtsVoiceType) { return { source: 'official', name: this.dhTtsVoiceName || this.getOfficialVoiceName(this.dhTtsVoiceType), id: this.dhTtsVoiceType, voiceType: this.dhTtsVoiceType, }; } if (this.dhTtsVoiceSource === 'cloned' && this.dhTtsTimbreId) { const matched = this.vsClonedTimbreList.find((item) => item.objectId === this.dhTtsTimbreId); return { source: 'cloned', name: this.dhTtsVoiceName || matched?.name || this.dhTtsTimbreId, id: this.dhTtsTimbreId, timbreId: this.dhTtsTimbreId, speakerId: matched?.speakerId, }; } return null; } openDhVoicePicker(): void { this.dhVoicePickerOpen = true; } closeDhVoicePicker(): void { this.dhVoicePickerOpen = false; } onDhVoicePicked(sel: VoiceSelection): void { this.dhTtsVoiceSource = sel.source; this.dhTtsVoiceName = sel.name; if (sel.source === 'official') { this.dhTtsVoiceType = sel.voiceType || sel.id; this.dhTtsTimbreId = ''; } else { this.dhTtsTimbreId = sel.timbreId || sel.id; this.dhTtsVoiceType = ''; } this.dhError = ''; this.dhVoicePickerOpen = false; this.refreshView(); } 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 = ''; dhTtsVoiceSource: 'cloned' | 'official' = 'cloned'; dhTtsVoiceType: string = ''; dhTtsVoiceName: string = ''; dhVoicePickerOpen: boolean = false; dhTtsText: string = ''; dhUploadAudioFile: File | null = null; dhUploadAudioName: string = ''; dhUploadAudioUrl: string = ''; dhUploadingAudio: boolean = false; dhPrompt: string = ''; // dh tab:形象准备产物(来自 ),存在时优先于原图 dhPreparedPortraitUrl: string = ''; dhPreparedFullBodyUrl: string = ''; dhPreparedBenchmarkSummary: 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 = 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: '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' = '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; // ===== 数字人形象准备(自拍优化 / 对标重塑)===== // raw=直接使用原图;normalize=AI 提取正面全身图;benchmark=对标账号重塑 remixPortraitPrepareMode: 'raw' | 'normalize' | 'benchmark' = 'raw'; remixBenchmarkImageFile: File | null = null; remixBenchmarkImageName: string = ''; remixBenchmarkImagePreviewUrl: string = ''; remixPortraitPipelineRunning: boolean = false; remixPortraitPipelineStatus: string = ''; remixPortraitPipelineProgress: number = 0; remixPortraitPipelineError: string = ''; // pipeline 输出,若存在则数字人生成会优先使用 remixGeneratedPortraitUrl: string = ''; remixGeneratedFullBodyUrl: string = ''; remixGeneratedBenchmarkSummary: string = ''; // ===== 视频生成工作流 ===== vgStep: number = 1; // 1上传视频 2视频解析 3脚本生成 4配图生成 5配音 6数字人生成 readonly vgStepLabels = ['上传视频', '视频解析', '脚本生成', '配图生成', '语音配音', '视频合成']; readonly vgStepSubtitles = [ '选择需要拆解分析的视频源文件', 'AI 自动转录与内容理解', 'AI 生成分镜旁白与画面提示词', '为每个分镜生成 AI 配图', '逐段合成旁白音频', '汇总素材并合成最终视频' ]; // 用户手动展开的「已完成」步骤集合(active 步骤始终展开,locked 步骤始终折叠) vgUserExpandedSteps: Set = new Set(); 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'; vgVoiceName: string = ''; vgVoicePickerOpen: boolean = false; 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 = 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 portraitPipeline: PortraitPipelineService, private digitalHumanService: DigitalHumanService, private http: HttpClient, private qiniuUpload: QiniuUploadService, private browserFfmpeg: BrowserFfmpegService, private ngZone: NgZone, private cdr: ChangeDetectorRef) { void this.pipelineSession.bootstrap(); 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 { 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 { 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); const allowed = ['home','search','monitor','tasks','history','results','videos','voice-synthesis','pipelines','digital-human','image-generation','video-generation','image-to-video','action-transfer','asset-remix','topic-to-video','drafts','library']; // 旧 tab → 新页面的迁移映射(M4/M5/M6 重组后) // 'history' 完整功能被「我的创作」覆盖;'results' 被「素材库」替代。 const aliases: Record = { history: 'drafts', results: 'library', }; let target = savedTab; if (target && aliases[target]) target = aliases[target]; if (target && allowed.includes(target)) { this.currentTab = target as any; } } 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: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library'): void { this.currentTab = tab; try { localStorage.setItem(this.TAB_STORAGE_KEY, tab); } catch {} } /** 右下浮动 mini-pill:当前正在跑的任务(取 activeTasks 中第一个 processing/pending) */ get currentRunningTask(): RecentTaskView | null { const running = (this.activeTasks || []).find( (t) => t && (t.status === 'processing' || t.status === 'pending'), ); if (!running) return null; return { id: running.id, keyword: running.keyword, status: running.status, step: running.step, progress: Math.max(0, Math.min(100, running.progress ?? 0)), type: running.type, }; } /** assistant 浮窗是否展开(由组件回传);展开时 pill 让位到面板上方 */ assistantExpanded = false; /** 首屏内联对话页激活(由 app-home 通过 inlineChatChange 输出):pill / FAB 都隐藏 */ homeChatActive = false; /** * pill 隐藏条件: * 1. 已经在 tasks 页 —— 重复 * 2. 浮窗展开 —— 被遮挡 * 3. 首屏全宽对话页激活 —— 主区已是对话 */ get pillHidden(): boolean { return this.currentTab === 'tasks' || this.assistantExpanded || this.homeChatActive; } /** 助手内回复中的「直达 X 模式」按钮被点击 */ onAssistantSuggestion(s: AssistantSuggestion): void { if (!s?.tab) return; // 复用首屏跳转通道:含 payload 时同样走 sessionStorage 预填 this.onHomeNavigate({ tab: s.tab, payload: s.payload }); } /** * 「我的创作」页里点「继续编辑」/「再做一版」时调用。 * 此时 PipelineSessionService.active 已是目标 draft, * 切到对应 Pipeline tab 后,组件 ngOnInit 会读取 active 并 fromSnapshot。 */ onDraftsNavigate(route: PipelineRoute): void { this.setCurrentTab(route); } /** * 「素材库」页点某个卡片 → 打开其所属 draft(到「我的创作」详情)。 * 跳转后 session 仍保持 active = 该 draft,DraftsComponent 会从 list 中选中。 */ async onLibraryNavigate(ev: { draftId: string; pipelineId: PipelineRoute }): Promise { await this.pipelineSession.open(ev.draftId); this.setCurrentTab('drafts'); } /** 首屏组件输出:可能带 payload(如预填的主题) */ onHomeNavigate(ev: { tab: string; payload?: any }): void { this.setCurrentTab(ev.tab as any); // 主题预填:暂存到 sessionStorage,topic-to-video 组件初始化时自取 if (ev.payload?.topic && ev.tab === 'topic-to-video') { try { sessionStorage.setItem('t2v.prefillTopic', ev.payload.topic); } 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('/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('/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('/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>('/backend/api/remixes').subscribe({ next: (allRemixes) => { const remixByTaskId = new Map(); 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((resolve) => { this.http.post('/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(`/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(`/backend/api/tasks/${taskId}`).subscribe({ next: () => { console.log('🗑️ 任务已删除:', taskId); this.showToast('🗑️ 任务已删除', 'success'); }, error: (err) => { console.warn('⚠️ 任务删除失败:', err); this.showToast('⚠️ 后端删除失败,已从列表中移除', 'warn'); } }); } // 保存历史记录到后端 saveHistoryRecord(record: Partial): void { this.http.post('/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(`/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): void { this.http.post('/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(`/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(`/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(`/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>('/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(`/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('封面加载失败'); } 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(`${label}`); } private getErrorText(error: any, fallback: string): string { const candidates = [ error?.error?.error, error?.error?.message, error?.error?.msg, error?.error, error?.message, error, ]; for (const candidate of candidates) { const text = this.normalizeErrorText(candidate); if (text) return text; } return fallback; } private normalizeErrorText(value: any, seen = new Set()): string { if (value == null) return ''; if (typeof value === 'string') return value.trim(); if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (value instanceof Blob) return ''; if (typeof value !== 'object') return ''; if (seen.has(value)) return ''; seen.add(value); const nestedKeys = ['message', 'msg', 'error', 'detail', 'reason']; for (const key of nestedKeys) { const nested = this.normalizeErrorText(value?.[key], seen); if (nested) return nested; } try { const serialized = JSON.stringify(value); return serialized && serialized !== '{}' ? serialized : ''; } catch { return ''; } } private getOfficialTtsResourceId(voiceId: string): string { return voiceId.includes('_uranus_bigtts') ? 'seed-tts-2.0' : 'seed-tts-1.0'; } private applyVoiceSelectionToTtsPayload( payload: any, mode: 'system' | 'clone', systemVoiceId: string, timbreId: string, ): void { if (mode === 'clone' && timbreId.trim()) { payload.timbreId = timbreId.trim(); return; } const voiceId = systemVoiceId.trim() || 'zh_female_shuangkuai-am_16k'; payload.volcengine_voice_type = voiceId; payload.x_api_resource_id = this.getOfficialTtsResourceId(voiceId); } private async extractHttpBlobErrorMessage(error: any, fallback: string): Promise { 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 = new Set(); // 取消任务(实际生效:标记为 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(`/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>('/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 ''; } private createUploadedVideoEntry(file: File, url: string, title: string, description: string, tags: string[]): any { const ext = (file.name.match(/\.([a-z0-9]+)$/i)?.[1] || 'mp4').toLowerCase(); const now = new Date().toISOString(); return { id: `VID-${Date.now()}`, title: title || file.name.replace(/\.[^.]+$/, ''), filename: file.name, filepath: url, size: file.size, duration: 0, category: 'uploaded', tags, description: description || '用户上传的视频', source: 'uploaded', created_at: now, modified_at: now, metadata: { resolution: '未知', format: ext, }, }; } private toManagedVideo(entry: any): ManagedVideo { return { id: entry.id, title: entry.title || entry.filename, filename: entry.filename, filepath: entry.filepath || entry.url || `/backend/api/video/${entry.filename}`, size: entry.size || 0, duration: entry.duration || 0, created_at: entry.created_at ? new Date(entry.created_at) : new Date(), modified_at: entry.modified_at ? new Date(entry.modified_at) : new Date(), category: entry.category || 'uploaded', tags: entry.tags || [], description: entry.description || '', thumbnail: entry.thumbnail || '', source: entry.source || 'uploaded', metadata: entry.metadata || { resolution: '未知', format: 'mp4' }, whisper: entry.whisper || undefined, }; } // 获取来源文本 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(`/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(`/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 file = this.uploadFile; const title = this.uploadTitle || file.name.replace(/\.[^.]+$/, ''); const description = this.uploadDescription || '用户上传的视频'; const tags = this.uploadTags.trim() ? this.uploadTags.split(/[,,、\s]+/).filter(t => t.trim()) : []; let uploadedEntry: any = null; this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video').pipe( switchMap((event) => { if (event.state === 'progress') { this.uploadProgress = event.progress; this.refreshView(); return of(null); } const url = event.url || ''; if (!url) { throw new Error('上传失败:未返回视频 URL'); } uploadedEntry = this.createUploadedVideoEntry(file, url, title, description, tags); return this.http.post('/backend/api/manifest', uploadedEntry); }) ).subscribe({ next: (res) => { if (!res) return; this.isUploading = false; const entry = res?.video || res?.data || uploadedEntry; const newVideo = this.toManagedVideo(entry); this.uploadProgress = 100; this.managedVideos.unshift(newVideo); this.updateCategoryCounts(); this.showUploadModal = false; this.uploadFile = null; this.uploadFileName = ''; this.showToast(`✅ 视频「${newVideo.title}」上传成功!`, 'success'); this.refreshView(); }, error: (err: any) => { this.isUploading = false; this.uploadError = err?.error?.error || err?.message || '上传失败'; this.refreshView(); } }); } // ==================== 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 { 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 { 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 file = this.dhRefFile; const title = `数字人参考视频 - ${new Date().toLocaleString('zh-CN')}`; let uploadedEntry: any = null; this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video').pipe( switchMap((event) => { if (event.state === 'progress') { this.dhRefUploadProgress = event.progress; this.cdr.detectChanges(); return of(null); } const url = event.url || ''; if (!url) throw new Error('上传失败:未返回视频 URL'); uploadedEntry = this.createUploadedVideoEntry(file, url, title, '数字人参考视频', ['数字人']); return this.http.post('/backend/api/manifest', uploadedEntry); }) ).subscribe({ next: (res) => { if (!res) return; const entry = res?.video || res?.data || uploadedEntry; this.dhRefUploading = false; this.dhRefVideoId = entry.id; const newVideo = this.toManagedVideo(entry); if (!this.managedVideos.some(v => v.id === newVideo.id)) { this.managedVideos.unshift(newVideo); this.updateCategoryCounts(); } this.dhRefStartAnalysis(); this.cdr.detectChanges(); }, error: (err) => { this.dhRefUploading = false; this.dhRefError = err?.error?.error || err?.message || '上传失败'; this.cdr.detectChanges(); } }); } // 启动分析:≤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 { 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 { const video = this.managedVideos.find(v => v.id === this.dhRefVideoId); const source = this.dhRefFile || (video ? this.getVideoPlayUrl(video) : ''); if (!source) { this.dhRefAnalysisText = '缺少可访问的视频文件,无法提取音轨'; this.refreshView(); this.dhRefRunWhisperFallback(); return; } from(this.browserFfmpeg.extractAudioForGemini(source, `dh-ref-${Date.now()}.m4a`)).subscribe({ next: (audio: BrowserAudioExtractionResult) => { const audioSizeMB = 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(audio.base64, 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(); } }); }, 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('/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('/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(`${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(`${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('/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(`${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(`${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(`/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; } if (this.remixPortraitPipelineRunning) { this.remixDigitalHumanError = '形象图正在生成中,请稍候...'; this.refreshView(); return; } if (this.remixPortraitPrepareMode !== 'raw' && !this.remixGeneratedPortraitUrl) { this.remixDigitalHumanError = '请先点击「生成形象图」完成 AI 处理,或切换到「直接使用上传图」'; this.refreshView(); return; } this.remixDigitalHumanError = ''; 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; this.resetPortraitPipelineState(); } // ===== 数字人形象准备:自拍优化 / 对标重塑 ===== setRemixPortraitPrepareMode(mode: 'raw' | 'normalize' | 'benchmark'): void { if (this.remixPortraitPipelineRunning) { return; } this.remixPortraitPrepareMode = mode; // 切换模式时清空已生成结果,避免误用 this.remixGeneratedPortraitUrl = ''; this.remixGeneratedFullBodyUrl = ''; this.remixGeneratedBenchmarkSummary = ''; this.remixPortraitPipelineError = ''; this.remixPortraitPipelineStatus = ''; this.remixPortraitPipelineProgress = 0; if (mode !== 'benchmark') { this.clearRemixBenchmarkImage(); } this.refreshView(); } onRemixBenchmarkImageSelected(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', 'image/webp']; const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; if ((!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) || file.size <= 0) { this.remixPortraitPipelineError = '对标场景图仅支持 jpg、jpeg、png、gif、webp 格式'; input.value = ''; this.refreshView(); return; } if (file.size > 20 * 1024 * 1024) { this.remixPortraitPipelineError = '对标场景图大小不能超过 20MB'; input.value = ''; this.refreshView(); return; } this.revokeBenchmarkPreviewUrl(); this.remixBenchmarkImageFile = file; this.remixBenchmarkImageName = file.name; this.remixBenchmarkImagePreviewUrl = URL.createObjectURL(file); this.remixPortraitPipelineError = ''; this.remixGeneratedPortraitUrl = ''; input.value = ''; this.refreshView(); } clearRemixBenchmarkImage(): void { this.revokeBenchmarkPreviewUrl(); this.remixBenchmarkImageFile = null; this.remixBenchmarkImageName = ''; this.refreshView(); } private revokeBenchmarkPreviewUrl(): void { if (this.remixBenchmarkImagePreviewUrl) { URL.revokeObjectURL(this.remixBenchmarkImagePreviewUrl); this.remixBenchmarkImagePreviewUrl = ''; } } private resetPortraitPipelineState(): void { this.remixPortraitPrepareMode = 'raw'; this.revokeBenchmarkPreviewUrl(); this.remixBenchmarkImageFile = null; this.remixBenchmarkImageName = ''; this.remixPortraitPipelineRunning = false; this.remixPortraitPipelineStatus = ''; this.remixPortraitPipelineProgress = 0; this.remixPortraitPipelineError = ''; this.remixGeneratedPortraitUrl = ''; this.remixGeneratedFullBodyUrl = ''; this.remixGeneratedBenchmarkSummary = ''; } /** * 一键执行形象准备链路: * - normalize: 仅 Step1 自拍 → 正面全身图 * - benchmark: Step1 + Step2 + Step3 对标重塑 */ runPortraitPipeline(): void { if (this.remixPortraitPipelineRunning) { return; } if (!this.remixDigitalHumanImageFile) { this.remixPortraitPipelineError = '请先上传人物自拍/形象图'; this.refreshView(); return; } if (this.remixPortraitPrepareMode === 'benchmark' && !this.remixBenchmarkImageFile) { this.remixPortraitPipelineError = '请上传一张对标账号的场景图'; this.refreshView(); return; } this.remixPortraitPipelineRunning = true; this.remixPortraitPipelineError = ''; this.remixPortraitPipelineProgress = 0; this.remixPortraitPipelineStatus = '正在上传素材...'; this.refreshView(); const selfieFile = this.remixDigitalHumanImageFile; const benchmarkFile = this.remixPortraitPrepareMode === 'benchmark' ? this.remixBenchmarkImageFile : null; // 1) 上传自拍 → URL this.jimengService.uploadFileToParse( selfieFile, `portrait-selfie-${Date.now()}-${selfieFile.name}`, selfieFile.type || 'image/jpeg' ).subscribe({ next: (selfieUrl: string) => { // 2) 如需对标,先上传对标图 const benchmarkUpload$ = benchmarkFile ? this.jimengService.uploadFileToParse( benchmarkFile, `portrait-benchmark-${Date.now()}-${benchmarkFile.name}`, benchmarkFile.type || 'image/jpeg' ) : null; const next = (benchmarkUrl: string | null) => { this.portraitPipeline.buildBenchmarkedPortrait(selfieUrl, benchmarkUrl, (status, progress) => { this.ngZone.run(() => { this.remixPortraitPipelineStatus = status; this.remixPortraitPipelineProgress = Math.round(progress); this.refreshView(); }); }).subscribe({ next: (result: PortraitPipelineResult) => { this.ngZone.run(() => { this.remixGeneratedFullBodyUrl = result.fullBodyUrl; this.remixGeneratedPortraitUrl = result.finalPortraitUrl; this.remixGeneratedBenchmarkSummary = result.benchmarkPrompt?.merged || ''; this.remixPortraitPipelineRunning = false; this.remixPortraitPipelineStatus = '形象图生成完成'; this.remixPortraitPipelineProgress = 100; this.showToast('✅ 形象图生成完成,可继续配置数字人参数', 'success'); this.refreshView(); }); }, error: (err) => { this.ngZone.run(() => { this.remixPortraitPipelineRunning = false; this.remixPortraitPipelineError = this.getErrorText(err, '形象图生成失败'); this.showToast(`❌ ${this.remixPortraitPipelineError}`, 'error'); this.refreshView(); }); } }); }; if (benchmarkUpload$) { benchmarkUpload$.subscribe({ next: (benchmarkUrl: string) => next(benchmarkUrl), error: (err) => { this.ngZone.run(() => { this.remixPortraitPipelineRunning = false; this.remixPortraitPipelineError = this.getErrorText(err, '对标场景图上传失败'); this.showToast(`❌ ${this.remixPortraitPipelineError}`, 'error'); this.refreshView(); }); } }); } else { next(null); } }, error: (err) => { this.ngZone.run(() => { this.remixPortraitPipelineRunning = false; this.remixPortraitPipelineError = this.getErrorText(err, '形象图上传失败'); this.showToast(`❌ ${this.remixPortraitPipelineError}`, 'error'); this.refreshView(); }); } }); } resetGeneratedPortrait(): void { if (this.remixPortraitPipelineRunning) { return; } this.remixGeneratedPortraitUrl = ''; this.remixGeneratedFullBodyUrl = ''; this.remixGeneratedBenchmarkSummary = ''; this.remixPortraitPipelineStatus = ''; this.remixPortraitPipelineProgress = 0; this.remixPortraitPipelineError = ''; this.refreshView(); } // ===== 数字人共享表单:选项与回调(用于 )===== get remixTimbreOptions(): TimbreOption[] { return (this.vsClonedTimbreList || []).map(t => ({ id: t.objectId, name: t.name || t.objectId })); } readonly digitalHumanPromptPresets: PromptPreset[] = [ { label: '专业播报', value: '以新闻主播的语气朗读,吐字清晰、节奏稳定、表情自信端正。' }, { label: '亲切自然', value: '用朋友聊天的语气,亲切自然、面带微笑,语速适中略带轻松感。' }, { label: '科技感', value: '冷静理性的科技感口吻,节奏明快、抑扬有度,强调数据与关键词。' }, { label: '激情澎湃', value: '激情澎湃的演说家口吻,情感饱满、节奏起伏明显、富有感染力。' }, { label: '温柔治愈', value: '温柔轻缓的语气,气息柔和、节奏放慢,适合情感与生活类内容。' }, { label: '正式商务', value: '商务正式风格,语调克制、用词严谨,仪态端庄,适合企业宣传场景。' }, ]; /** * 视频重塑里: 的 (request) 事件入口。 * 接管 startDigitalHumanGeneration 的全部任务管理逻辑, * 内部链路改用 DigitalHumanService 编排执行。 */ onRemixDhRequest(req: DigitalHumanRequest): void { const video = this.remixTargetVideo; if (!video || this.isRemixSubmitting) { return; } // 原视频音频时长校验(保留旧约束) if (req.audio.source === 'original') { const safeDuration = Number(video.duration || 0); if (safeDuration > 60) { this.remixDigitalHumanError = '原视频音频时长超过 60 秒,当前数字人接口不支持'; this.showToast(`❌ ${this.remixDigitalHumanError}`, 'error'); this.refreshView(); return; } } // 同步音频来源到旧字段,让 step 5/6 已有 UI 仍能显示 this.remixDigitalHumanAudioSource = req.audio.source === 'tts' ? 'tts' : 'original'; this.remixDigitalHumanResolution = req.resolution || '1080p'; this.remixDigitalHumanFastMode = !!req.fastMode; this.remixDigitalHumanPrompt = req.prompt || ''; if (req.audio.source === 'tts') { this.remixDigitalHumanTtsText = req.audio.ttsText || ''; this.remixDigitalHumanTtsTimbreId = req.audio.ttsTimbreId || ''; } // 注入原视频 ID(form 没有这个字段,由父组件补齐) if (req.audio.source === 'original' && !req.audio.videoId) { req.audio.videoId = video.id; } if (req.audio.source === 'original') { req.audio.videoUrl = this.getVideoPlayUrl(video); } 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.digitalHumanService.generate(req).subscribe({ next: (evt: DigitalHumanEvent) => { this.ngZone.run(() => { if (evt.type === 'progress') { video.remixProgress = evt.progress; this.remixStatusText = evt.status; this.updateRemixTaskProgress('generate', evt.progress); this.refreshView(); } else { // result video.remixProgress = 100; this.remixResults.success = 1; this.remixFinalVideoUrl = evt.videoUrl; this.remixStatusText = '数字人视频生成完成'; this.remixDigitalHumanImageAssetUrl = evt.imageUrl; this.createDigitalHumanVersion(video, evt.videoUrl); this.completeRemixTask('completed', evt.videoUrl); this.saveRemixSession('completed'); video.isRemixing = false; this.isRemixSubmitting = false; this.remixStep = 6; this.showToast(`✅ 数字人视频已生成:${video.title}`, 'success', 6000); this.refreshView(); } }); }, error: (err: any) => { this.ngZone.run(() => { this.handleDigitalHumanGenerationError(video, this.getErrorText(err, '数字人生成失败')); }); } }); } // 加载该视频关联的 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(`/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('/backend/api/health').subscribe({ next: () => { // 后端可用,调用 Whisper 转录 this.whisperStatusText = '正在调用 Whisper 语音识别,视频较长时可能需要数分钟...'; this.http.post('/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; // 若已通过形象准备链路生成对标/规范化形象图,则直接复用其 URL,跳过原图上传 const ensureImage$: Observable = this.remixGeneratedPortraitUrl ? of(this.remixGeneratedPortraitUrl) : this.jimengService.uploadFileToParse( this.remixDigitalHumanImageFile, `digital-human-${Date.now()}-${this.remixDigitalHumanImageFile.name}`, this.remixDigitalHumanImageFile.type || 'image/jpeg' ); ensureImage$.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('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(); const source = this.getVideoPlayUrl(video); from(this.browserFfmpeg.extractAudioBlob( source, `${video.filename.replace(/\.[^.]+$/, '') || 'source-audio'}.m4a`, )).subscribe({ next: (audioFile: File) => { 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: (err: any) => { this.handleDigitalHumanGenerationError(video, err?.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 { 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 filename = `voice-clone-${Date.now()}-${this.vsCloneAudioFile.name}`; this.qiniuUpload.uploadFile(this.vsCloneAudioFile, filename, this.vsCloneAudioFile.type, 'audio').subscribe({ next: (url) => { this.vsCloneUploading = false; 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, audioText, language, demoText } = this.vsCloneForm; if (!name.trim()) { this.vsCloneError = '请输入音色名称'; 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) => { // 自动从后端挑选一个未训练的 speaker_id this.http.post('/backend/api/voice/auto-speaker-id', {}).subscribe({ next: (idRes) => { const pickedSpeakerId = String(idRes?.speaker_id || '').trim(); if (!pickedSpeakerId) { this.vsCloneLoading = false; this.vsCloneError = idRes?.error || '未能获取可用的 speaker_id'; this.showToast(`❌ ${this.vsCloneError}`, 'error'); this.refreshView(); return; } this.showToast(`🎯 已自动分配 speaker_id:${pickedSpeakerId}`, 'info', 3000); this.http.post('https://server.fmode.cn/api/volcengine/tts/voice_clone', { token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600', name: name.trim(), speaker_id: pickedSpeakerId, 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 newEntry = { objectId: timbre.objectId, name: timbre.name || name.trim(), speakerId: timbre.speaker_id || pickedSpeakerId }; const existingIndex = this.vsClonedTimbreList.findIndex(t => t.objectId === timbre.objectId); if (existingIndex >= 0) { // 同一 speaker_id 复刻会返回相同 objectId:更新现有条目(含名称)并置顶 const next = [...this.vsClonedTimbreList]; next.splice(existingIndex, 1); next.unshift({ ...this.vsClonedTimbreList[existingIndex], ...newEntry }); this.vsClonedTimbreList = next; } else { this.vsClonedTimbreList = [newEntry, ...this.vsClonedTimbreList]; } this.saveClonedTimbreList(); // 同步到本地后端,让 /api/voice/auto-speaker-id 后续能跳过此 speaker_id const modelInfo = (Array.isArray(timbre.models) && timbre.models[0]) || {}; this.http.post('/backend/api/voice-profiles/sync', { id: timbre.objectId, timbre_id: timbre.objectId, name: timbre.name || name.trim(), speaker_id: timbre.speaker_id || pickedSpeakerId, demo_audio: modelInfo.demo_audio || '', latest_audio_url: modelInfo.demo_audio || '', x_api_resource_id: modelInfo.x_api_resource_id || '', icl_speaker_id: modelInfo.icl_speaker_id || '', model_version: modelInfo.version || '', status: timbre.status ?? null, status_label: res?.data?.tip || '', message: res?.data?.tip || '', created_at: timbre.createdAt || undefined }).subscribe({ next: () => {}, error: (e) => console.warn('voice-profiles/sync failed', e) }); 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(); } }); }, error: (err) => { this.vsCloneLoading = false; const errBody = err?.error; this.vsCloneError = errBody?.error || this.getErrorText(err, '获取可用 speaker_id 失败'); 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, voiceType, voiceSource, text, format, sampleRate, speechRate, loudnessRate } = this.vsSynthForm; const usingOfficial = voiceSource === 'official'; const voiceId = usingOfficial ? (voiceType || '').trim() : (timbreId || '').trim(); if (!voiceId) { this.vsSynthError = '请先选择声音模型'; this.refreshView(); return; } if (!text.trim()) { this.vsSynthError = '请输入要合成的文本内容'; this.refreshView(); return; } this.vsSynthLoading = true; this.vsSynthError = ''; this.vsSynthAudioUrl = ''; this.vsSynthWorkId = ''; this.refreshView(); const body: any = { token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600', text: text.trim(), isStream: false, audio_params: { format: format || 'mp3', sample_rate: sampleRate || 24000, speech_rate: speechRate || 0, loudness_rate: loudnessRate || 0 } }; if (usingOfficial) { // _uranus_bigtts 后缀对应豆包语音合成模型 2.0,需要 seed-tts-2.0 资源; // 其它后缀回退到 seed-tts-1.0,可按需扩展。 body.volcengine_voice_type = voiceId; body.x_api_resource_id = this.getOfficialTtsResourceId(voiceId); } else { body.timbreId = voiceId; } this.http.post('https://server.fmode.cn/api/volcengine/tts/unidirectional', body).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.vsSynthForm.voiceSource = 'cloned'; this.vsSynthForm.voiceType = ''; const matched = this.vsClonedTimbreList.find(t => t.objectId === objectId); this.vsSynthForm.voiceName = matched?.name || 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.dhPreparedPortraitUrl = ''; this.dhPreparedFullBodyUrl = ''; this.dhPreparedBenchmarkSummary = ''; this.refreshView(); } /** dh tab: 的 (prepared) 事件回调 */ onDhPortraitPrepared(out: PortraitPrepareOutput): void { this.dhPreparedPortraitUrl = out.portraitUrl; this.dhPreparedFullBodyUrl = out.fullBodyUrl; this.dhPreparedBenchmarkSummary = out.benchmarkSummary; 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.dhTtsVoiceSource = 'cloned'; this.dhTtsVoiceType = ''; this.dhTtsVoiceName = ''; 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('数字人'); }); } private toDigitalHumanSnapshot(): Record { return { imageName: this.dhImageName, imageUrl: this.dhImageAssetUrl || this.dhPreparedPortraitUrl || '', preparedPortraitUrl: this.dhPreparedPortraitUrl, preparedFullBodyUrl: this.dhPreparedFullBodyUrl, preparedBenchmarkSummary: this.dhPreparedBenchmarkSummary, audioSource: this.dhAudioSource, ttsTimbreId: this.dhTtsTimbreId, ttsVoiceSource: this.dhTtsVoiceSource, ttsVoiceType: this.dhTtsVoiceType, ttsVoiceName: this.dhTtsVoiceName, ttsText: this.dhTtsText, uploadAudioName: this.dhUploadAudioName, uploadAudioUrl: this.dhUploadAudioUrl, prompt: this.dhPrompt, fastMode: this.dhFastMode, resolution: this.dhResolution, step: this.dhStep, progress: this.dhProgress, statusText: this.dhStatusText, resultVideoUrl: this.dhResultVideoUrl, }; } private deriveDigitalHumanDraftTitle(): string { const script = (this.dhTtsText || '').replace(/\s+/g, ' ').trim(); if (script) return `数字人合成 · ${script.slice(0, 24)}`; if (this.dhImageName) return `数字人合成 · ${this.dhImageName}`; return `数字人合成 · ${new Date().toLocaleString('zh-CN')}`; } private syncDigitalHumanDraft(status: 'draft' | 'running' = 'draft'): void { const title = this.deriveDigitalHumanDraftTitle(); this.pipelineSession.ensureActive('digital-human', () => this.toDigitalHumanSnapshot(), title); this.pipelineSession.patch({ title, snapshot: this.toDigitalHumanSnapshot(), thumbnail: this.dhImageAssetUrl || this.dhPreparedPortraitUrl || undefined, }); if (status === 'running') this.pipelineSession.markRunning(); } private completeDigitalHumanDraft(videoUrl: string, imageUrl?: string, audioUrl?: string): void { this.syncDigitalHumanDraft('running'); if (imageUrl) { this.pipelineSession.upsertArtifact( (a) => a.type === 'image' && a.title === '数字人形象图', { type: 'image', url: imageUrl, title: '数字人形象图', extras: { imageName: this.dhImageName }, }, ); } if (audioUrl) { this.pipelineSession.upsertArtifact( (a) => a.type === 'audio' && a.title === '数字人口播音频', { type: 'audio', url: audioUrl, title: '数字人口播音频', extras: { source: this.dhAudioSource, voiceSource: this.dhAudioSource === 'tts' ? this.dhTtsVoiceSource : undefined, voiceType: this.dhAudioSource === 'tts' ? this.dhTtsVoiceType : undefined, voiceName: this.dhAudioSource === 'tts' ? this.dhTtsVoiceName : undefined, timbreId: this.dhAudioSource === 'tts' ? this.dhTtsTimbreId : undefined, audioName: this.dhUploadAudioName, }, }, ); } this.pipelineSession.patch({ title: this.deriveDigitalHumanDraftTitle(), snapshot: this.toDigitalHumanSnapshot(), thumbnail: imageUrl || this.dhImageAssetUrl || this.dhPreparedPortraitUrl || undefined, }); this.pipelineSession.finalize(videoUrl, { prompt: this.dhPrompt, resolution: this.dhResolution, audioSource: this.dhAudioSource, ttsVoiceSource: this.dhAudioSource === 'tts' ? this.dhTtsVoiceSource : undefined, ttsVoiceType: this.dhAudioSource === 'tts' ? this.dhTtsVoiceType : undefined, ttsVoiceName: this.dhAudioSource === 'tts' ? this.dhTtsVoiceName : undefined, ttsTimbreId: this.dhAudioSource === 'tts' ? this.dhTtsTimbreId : undefined, }); } // ======================================================================== // 数字人合成页: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('/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.dhCurrentVoiceSelection) { this.dhError = '请先选择声音模型'; 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.syncDigitalHumanDraft('running'); this.refreshView(); // 通过 DigitalHumanService 串联:上传图 → tts/upload 音频 → identify → generate // 若已通过 生成对标/规范化形象图,则直接复用其 URL const req: DigitalHumanRequest = { portrait: this.dhPreparedPortraitUrl ? { imageUrl: this.dhPreparedPortraitUrl } : { imageFile: this.dhImageFile! }, audio: this.dhAudioSource === 'tts' ? { source: 'tts', ttsText: this.dhTtsText.trim(), ttsTimbreId: this.dhTtsTimbreId.trim(), ttsVoiceSource: this.dhTtsVoiceSource, ttsVoiceType: this.dhTtsVoiceType.trim(), } : { source: 'upload', audioFile: this.dhUploadAudioFile! }, prompt: this.dhPrompt || '', fastMode: this.dhFastMode, resolution: this.dhResolution }; this.digitalHumanService.generate(req).subscribe({ next: (evt: DigitalHumanEvent) => { this.ngZone.run(() => { if (evt.type === 'progress') { this.dhProgress = evt.progress; this.dhStatusText = evt.status; // 同步老的子状态,让 checklist UI 仍能显示阶段 if (evt.stage === 'prepare-audio') { this.dhSynthesizing = this.dhAudioSource === 'tts'; this.dhUploadingAudio = this.dhAudioSource === 'upload'; } else { this.dhSynthesizing = false; this.dhUploadingAudio = false; } this.refreshView(); } else { // result this.dhProgress = 100; this.dhResultVideoUrl = evt.videoUrl; this.dhImageAssetUrl = evt.imageUrl; this.dhUploadAudioUrl = evt.audioUrl; this.dhStatusText = '数字人视频生成完成!'; this.dhLoading = false; this.dhStep = 'done'; this.dhSynthesizing = false; this.dhUploadingAudio = false; this.completeDigitalHumanDraft(evt.videoUrl, evt.imageUrl, evt.audioUrl); this.showToast('✅ 数字人视频已生成', 'success', 6000); // 保存到结果 this.saveResult({ type: 'video', url: evt.videoUrl, title: `数字人合成 - ${new Date().toLocaleString('zh-CN')}`, quality: this.dhResolution === '1080p' ? '1080P' : '720P', duration: '数字人视频', cost: 0, segments: [{ id: 'DH-01', videoUrl: evt.videoUrl, prompt: this.dhPrompt, status: 'completed' }] }); this.saveHistoryRecord({ keyword: '数字人合成', status: 'completed', cost: 0, duration: '数字人视频', styleName: '数字人合成', resultUrl: evt.videoUrl, quality: this.dhResolution === '1080p' ? '1080P' : '720P', segmentCount: 1, successCount: 1 }); this.addDhVideoToManifest(evt.videoUrl); this.refreshView(); } }); }, error: (err: any) => { this.ngZone.run(() => { this.dhHandleError(this.getErrorText(err, '数字人视频生成失败')); }); } }); } /** @deprecated 旧的同名方法已合并到 dhStartGeneration,保留以避免破坏现有调用。 */ private _legacyDhUploadFlow(): void { if (!this.dhImageFile) return; 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('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.completeDigitalHumanDraft(result.videoUrl, this.dhImageAssetUrl || undefined, this.dhUploadAudioUrl || undefined); 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; if (this.pipelineSession.active()?.pipelineId === 'digital-human') { this.pipelineSession.fail(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.vgVoiceName = ''; 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((resolve) => { this.http.post('/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, voiceName: this.vgVoiceName, 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.vgVoiceName = d.voiceName || ''; 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 file = this.vgSourceFile; const title = `视频生成源 - ${new Date().toLocaleString('zh-CN')}`; let uploadedEntry: any = null; this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type || 'video/mp4', 'video').pipe( switchMap((event) => { if (event.state === 'progress') { this.vgUploadProgress = event.progress; this.cdr.detectChanges(); return of(null); } const url = event.url || ''; if (!url) throw new Error('上传失败:未返回视频 URL'); uploadedEntry = this.createUploadedVideoEntry(file, url, title, '视频生成源素材', ['视频生成']); return this.http.post('/backend/api/manifest', uploadedEntry); }) ).subscribe({ next: (res) => { if (!res) return; const entry = res?.video || res?.data || uploadedEntry; this.vgUploading = false; this.vgVideoId = entry.id; const newVideo = this.toManagedVideo(entry); if (!this.managedVideos.some(v => v.id === newVideo.id)) { this.managedVideos.unshift(newVideo); this.updateCategoryCounts(); } this.vgStep = 2; this.vgCreateTask(); this.vgUpdateTaskProgress('vg-analyze', 15); this.vgStartAnalysis(); this.cdr.detectChanges(); }, error: (err: any) => { this.vgUploading = false; this.vgError = err?.error?.error || err?.message || '上传失败'; this.cdr.detectChanges(); } }); } // 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.wasm 提取音轨 → 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 { 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.wasm 提取音轨 → Gemini 音频识别(分析+转录) private vgRunAudioExtractAndGemini(): void { const video = this.managedVideos.find(v => v.id === this.vgVideoId); const source = this.vgSourceFile || (video ? this.getVideoPlayUrl(video) : ''); if (!source) { this.vgAnalysisText = '缺少可访问的视频文件,无法提取音轨'; this.refreshView(); this.vgRunWhisperFallback(); return; } from(this.browserFfmpeg.extractAudioForGemini(source, `vg-${Date.now()}.m4a`)).subscribe({ next: (audio: BrowserAudioExtractionResult) => { const audioSizeMB = 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(audio.base64, audio.mimeType || 'audio/mp4', 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(); } }); }, 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('/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 failures: string[] = []; const synthNext = () => { if (index >= segments.length) { this.vgSynthesizing = false; const voicedSegments = segments.filter((item) => item.narration.trim()); const total = voicedSegments.length || segments.length; const firstFailure = failures[0] ? `首个错误:${failures[0]}` : ''; if (this.vgAudioUrls.length === total) { this.vgUpdateTaskProgress('vg-voice', 80); this.showToast(`配音完成 (${this.vgAudioUrls.length}/${total})`, 'success'); } else if (this.vgAudioUrls.length > 0) { this.vgError = `配音部分完成:${this.vgAudioUrls.length}/${total} 段成功,失败 ${total - this.vgAudioUrls.length} 段。${firstFailure}`; this.showToast(this.vgError, 'warn'); } else { this.vgError = `配音失败:${total} 段均未生成音频。${firstFailure}`; this.showToast(this.vgError, 'error'); } 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 } }; this.applyVoiceSelectionToTtsPayload( payload, this.vgVoiceMode, this.vgVoiceSpeakerId || 'zh_female_shuangkuai-am_16k', this.vgVoiceTimbreId || '', ); this.http.post(`${this.voiceTtsBaseUrl}/unidirectional`, payload).subscribe({ next: (res) => { const audioUrl = res?.data?.audioUrl || ''; if (audioUrl) { this.vgAudioUrls.push({ segId: seg.id, audioUrl }); } else { const message = this.getErrorText(res, '未返回 audioUrl'); failures.push(`${seg.id}: ${message}`); console.warn(`配音片段 ${seg.id}: 未返回 audioUrl`, res); } index++; this.refreshView(); synthNext(); }, error: (err) => { failures.push(`${seg.id}: ${this.getErrorText(err, 'TTS 请求失败')}`); 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('/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('/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) }); } }