feat: generate whole live with danmu

This commit is contained in:
Xinrea
2025-10-25 02:02:11 +08:00
parent 5981d97d5f
commit fc6c6adfce
8 changed files with 301 additions and 322 deletions

View File

@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use m3u8_rs::Map;
use tokio::io::AsyncWriteExt;
@@ -121,19 +121,29 @@ pub async fn playlist_to_video(
pub async fn playlists_to_video(
reporter: Option<&impl ProgressReporterTrait>,
playlists: &[&Path],
danmu_ass_files: Vec<Option<PathBuf>>,
output_path: &Path,
) -> Result<(), String> {
let mut to_remove = Vec::new();
let mut segments = Vec::new();
for (i, playlist) in playlists.iter().enumerate() {
let video_path = output_path.with_extension(format!("{}.mp4", i));
playlist_to_video(reporter, playlist, &video_path, None).await?;
let mut video_path = output_path.with_extension(format!("{}.mp4", i));
if let Err(e) = playlist_to_video(reporter, playlist, &video_path, None).await {
log::error!("Failed to generate playlist video: {e}");
continue;
}
to_remove.push(video_path.clone());
if let Some(danmu_ass_file) = &danmu_ass_files[i] {
video_path = super::encode_video_danmu(reporter, &video_path, danmu_ass_file).await?;
to_remove.push(video_path.clone());
}
segments.push(video_path);
}
super::general::concat_videos(reporter, &segments, output_path).await?;
// clean up segments
for segment in segments {
for segment in to_remove {
let _ = tokio::fs::remove_file(segment).await;
}

View File

@@ -422,6 +422,7 @@ pub async fn fetch_hls(state: state_type!(), uri: String) -> Result<Vec<u8>, Str
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn generate_whole_clip(
state: state_type!(),
encode_danmu: bool,
platform: String,
room_id: i64,
parent_id: String,
@@ -459,7 +460,7 @@ pub async fn generate_whole_clip(
tokio::spawn(async move {
match state_clone
.recorder_manager
.generate_whole_clip(Some(&reporter), platform, room_id, parent_id)
.generate_whole_clip(Some(&reporter), encode_danmu, platform, room_id, parent_id)
.await
{
Ok(()) => {

View File

@@ -1014,6 +1014,7 @@ struct GetFileSizeRequest {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GenerateWholeClipRequest {
encode_danmu: bool,
platform: String,
room_id: i64,
parent_id: String,
@@ -1023,7 +1024,14 @@ async fn handler_generate_whole_clip(
state: axum::extract::State<State>,
Json(param): Json<GenerateWholeClipRequest>,
) -> Result<Json<ApiResponse<TaskRow>>, ApiError> {
let task = generate_whole_clip(state.0, param.platform, param.room_id, param.parent_id).await?;
let task = generate_whole_clip(
state.0,
param.encode_danmu,
param.platform,
param.room_id,
param.parent_id,
)
.await?;
Ok(Json(ApiResponse::success(task)))
}

View File

@@ -25,6 +25,7 @@ static CANCEL_FLAG_MAP: LazyLock<Arc<RwLock<CancelFlagMap>>> =
pub struct ProgressReporter {
emitter: EventEmitter,
pub event_id: String,
#[allow(unused)]
pub cancel: Arc<AtomicBool>,
}

View File

@@ -5,7 +5,7 @@ use crate::database::recorder::RecorderRow;
use crate::database::video::VideoRow;
use crate::database::{Database, DatabaseError};
use crate::ffmpeg::{encode_video_danmu, transcode, Range};
use crate::progress::progress_reporter::{EventEmitter, ProgressReporter};
use crate::progress::progress_reporter::{EventEmitter, ProgressReporter, ProgressReporterTrait};
use crate::subtitle_generator::item_to_srt;
use crate::webhook::events::{self, Payload};
use crate::webhook::poster::WebhookPoster;
@@ -62,6 +62,12 @@ pub struct ClipRangeParams {
pub fix_encoding: bool,
}
pub struct RelatedPlaylist {
pub live_id: String,
pub title: String,
pub path: PathBuf,
}
pub enum RecorderType {
BiliBili(BiliRecorder),
Douyin(DouyinRecorder),
@@ -154,6 +160,8 @@ pub enum RecorderManagerError {
SubtitleGenerationFailed { error: String },
#[error("Invalid playlist without date time")]
InvalidPlaylistWithoutDateTime,
#[error("Archive danmu ass generation failed: {error}")]
ArchiveDanmuAssGenerationFailed { error: String },
}
impl From<RecorderManagerError> for String {
@@ -329,9 +337,39 @@ impl RecorderManager {
let live_record = live_record.unwrap();
let Ok(task) = self
.db
.generate_task(
"generate_whole_clip",
"",
&serde_json::json!({
"platform": platform.as_str(),
"room_id": room_id,
"parent_id": live_record.parent_id,
})
.to_string(),
)
.await
else {
log::error!("Failed to generate task");
return;
};
let Ok(reporter) = ProgressReporter::new(&self.emitter, &task.id).await else {
log::error!("Failed to create reporter");
let _ = self
.db
.update_task(&task.id, "failed", "Failed to create reporter", None)
.await;
return;
};
log::info!("Create task: {} {}", task.id, task.task_type);
if let Err(e) = self
.generate_whole_clip(
None,
Some(&reporter),
self.config.read().await.auto_generate.encode_danmu,
platform.as_str().to_string(),
room_id,
live_record.parent_id,
@@ -339,7 +377,33 @@ impl RecorderManager {
.await
{
log::error!("Failed to generate whole clip: {e}");
let _ = reporter
.finish(false, &format!("Failed to generate whole clip: {e}"))
.await;
let _ = self
.db
.update_task(
&task.id,
"failed",
&format!("Failed to generate whole clip: {e}"),
None,
)
.await;
return;
}
let _ = reporter
.finish(true, "Whole clip generated successfully")
.await;
let _ = self
.db
.update_task(
&task.id,
"success",
"Whole clip generated successfully",
None,
)
.await;
}
pub fn set_migrating(&self, migrating: bool) {
@@ -684,7 +748,7 @@ impl RecorderManager {
platform: &PlatformType,
room_id: i64,
parent_id: &str,
) -> Vec<(String, String)> {
) -> Vec<RelatedPlaylist> {
let cache_path = self.config.read().await.cache.clone();
let cache_path = Path::new(&cache_path);
let archives = self.db.get_archives_by_parent_id(room_id, parent_id).await;
@@ -709,15 +773,12 @@ impl RecorderManager {
.map(async |a| {
let work_dir =
CachePath::new(cache_path.to_path_buf(), *platform, room_id, a.1.as_str());
(
a.0.clone(),
work_dir
.with_filename("playlist.m3u8")
.full_path()
.to_str()
.unwrap()
.to_string(),
)
RelatedPlaylist {
live_id: a.1.clone(),
title: a.0.clone(),
path: work_dir.with_filename("playlist.m3u8").full_path(),
}
})
.collect::<Vec<_>>();
@@ -847,6 +908,47 @@ impl RecorderManager {
result.map_err(|e| RecorderManagerError::ClipError { err: e })
}
async fn generate_archive_danmu_ass(
&self,
platform: PlatformType,
room_id: i64,
live_id: &str,
) -> Result<PathBuf, RecorderManagerError> {
log::info!(
"Generate archive danmu ass file for {} {} {}",
platform.as_str(),
room_id,
live_id
);
let first_segment_timestamp_milis = self
.first_segment_timestamp(platform, room_id, live_id)
.await?;
let mut danmus = self.load_danmus(platform, room_id, live_id).await?;
danmus.retain(|x| x.ts >= first_segment_timestamp_milis);
for d in &mut danmus {
d.ts -= first_segment_timestamp_milis;
}
let ass_content = danmu2ass::danmu_to_ass(danmus);
let work_dir = CachePath::new(
self.config.read().await.cache.clone().into(),
platform,
room_id,
live_id,
);
let ass_file_path = work_dir.with_filename("danmu.ass");
if let Err(e) = write(&ass_file_path.full_path(), ass_content).await {
log::error!(
"Failed to write archive danmu ass file: {} {}",
ass_file_path.full_path().display(),
e
);
return Err(RecorderManagerError::ArchiveDanmuAssGenerationFailed {
error: e.to_string(),
});
}
Ok(ass_file_path.full_path())
}
pub async fn get_recorder_list(&self) -> RecorderList {
let mut summary = RecorderList {
count: 0,
@@ -1185,6 +1287,7 @@ impl RecorderManager {
pub async fn generate_whole_clip(
&self,
reporter: Option<&ProgressReporter>,
encode_danmu: bool,
platform: String,
room_id: i64,
parent_id: String,
@@ -1203,14 +1306,29 @@ impl RecorderManager {
return Ok(());
}
let title = playlists.first().unwrap().0.clone();
let playlists = playlists
.iter()
.map(|p| p.1.clone())
.collect::<Vec<String>>();
let title = playlists.first().unwrap().title.clone();
// generate archive danmu ass file for all playlists
let danmu_ass_files = if encode_danmu {
let danmu_ass_files = playlists
.iter()
.map(async |p| {
(self
.generate_archive_danmu_ass(platform, room_id, &p.live_id)
.await)
.ok()
})
.collect::<Vec<_>>();
futures::future::join_all(danmu_ass_files).await
} else {
Vec::new()
};
let timestamp = chrono::Local::now().format("%Y%m%d%H%M%S").to_string();
let sanitized_filename = sanitize_filename::sanitize(format!(
"[full][{platform:?}][{room_id}][{parent_id}]{title}.mp4"
"[full][{platform:?}][{room_id}][{parent_id}][{timestamp}]{title}.mp4"
));
let output_filename = Path::new(&sanitized_filename);
let cover_filename = output_filename.with_extension("jpg");
@@ -1218,20 +1336,18 @@ impl RecorderManager {
let output_path =
Path::new(&self.config.read().await.output.as_str()).join(output_filename);
log::info!("Concat playlists: {playlists:?}");
let playlists_refs: Vec<&Path> = playlists.iter().map(|p| p.path.as_path()).collect();
log::info!("Concat playlists: {playlists_refs:?}");
log::info!("Output path: {output_path:?}");
let owned_path_bufs: Vec<std::path::PathBuf> =
playlists.iter().map(std::path::PathBuf::from).collect();
let playlists_refs: Vec<&std::path::Path> = owned_path_bufs
.iter()
.map(std::path::PathBuf::as_path)
.collect();
if let Err(e) =
crate::ffmpeg::playlist::playlists_to_video(reporter, &playlists_refs, &output_path)
.await
if let Err(e) = crate::ffmpeg::playlist::playlists_to_video(
reporter,
&playlists_refs,
danmu_ass_files,
&output_path,
)
.await
{
log::error!("Failed to concat playlists: {e}");
return Err(RecorderManagerError::HLSError {

View File

@@ -14,6 +14,7 @@
let wholeClipArchives: RecordItem[] = [];
let isLoading = false;
let encodeDanmu = false;
// 当modal显示且有archive时加载相关片段
$: if (showModal && archive) {
@@ -33,7 +34,10 @@
// 处理封面
for (const archive of sameParentArchives) {
archive.cover = await get_cover("cache", archive.cover);
archive.cover = await get_cover(
"cache",
`${archive.platform}/${archive.room_id}/${archive.live_id}/cover.jpg`
);
}
// 按时间排序
@@ -55,6 +59,7 @@
async function generateWholeClip() {
try {
await invoke("generate_whole_clip", {
encodeDanmu: encodeDanmu,
platform: archive.platform,
roomId: archive.room_id,
parentId: archive.parent_id,
@@ -147,93 +152,127 @@
</p>
</div>
<!-- Scrollable List -->
<div class="flex-1 overflow-auto custom-scrollbar-light px-6 min-h-0">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div
class="flex items-center space-x-2 text-gray-500 dark:text-gray-400"
>
<!-- Main Content: Left (List) + Right (Summary) -->
<div class="flex-1 flex min-h-0">
<!-- Left: Scrollable List -->
<div class="flex-1 overflow-auto custom-scrollbar-light px-6 min-h-0">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div
class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"
></div>
<span>加载中...</span>
</div>
</div>
{:else if wholeClipArchives.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
未找到相关片段
</div>
{:else}
<div class="space-y-3 pb-4">
{#each wholeClipArchives as archiveItem, index (archiveItem.live_id)}
<div
class="flex items-center space-x-4 p-4 rounded-lg bg-gray-50 dark:bg-gray-700/30"
class="flex items-center space-x-2 text-gray-500 dark:text-gray-400"
>
<div
class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium"
class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"
></div>
<span>加载中...</span>
</div>
</div>
{:else if wholeClipArchives.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
未找到相关片段
</div>
{:else}
<div class="space-y-3 pb-4">
{#each wholeClipArchives as archiveItem, index (archiveItem.live_id)}
<div
class="flex items-center space-x-4 p-4 rounded-lg bg-gray-50 dark:bg-gray-700/30"
>
{index + 1}
</div>
{#if archiveItem.cover}
<img
src={archiveItem.cover}
alt="cover"
class="w-16 h-10 rounded object-cover flex-shrink-0"
/>
{/if}
<div class="flex-1 min-w-0">
<div
class="text-sm font-medium text-gray-900 dark:text-white truncate"
class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium"
>
{archiveItem.title}
{index + 1}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatTimestamp(archiveItem.created_at)} · {formatDuration(
archiveItem.length
)} · {formatSize(archiveItem.size)}
{#if archiveItem.cover}
<img
src={archiveItem.cover}
alt="cover"
class="w-16 h-10 rounded object-cover flex-shrink-0"
/>
{/if}
<div class="flex-1 min-w-0">
<div
class="text-sm font-medium text-gray-900 dark:text-white truncate"
>
{archiveItem.title}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{formatTimestamp(archiveItem.created_at)} · {formatDuration(
archiveItem.length
)} · {formatSize(archiveItem.size)}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Right: Fixed Summary -->
{#if !isLoading && wholeClipArchives.length > 0}
<div class="w-80 px-6 pb-6 flex-shrink-0 flex items-center">
<div
class="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 w-full"
>
<div class="flex items-center space-x-2 mb-2">
<FileVideo class="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span
class="text-sm font-medium text-blue-900 dark:text-blue-100"
>合成信息</span
>
</div>
<div class="text-sm text-blue-800 dark:text-blue-200">
{wholeClipArchives.length} 个片段 · 总时长 {formatDuration(
wholeClipArchives.reduce(
(sum, archiveItem) => sum + archiveItem.length,
0
)
)} · 总大小 {formatSize(
wholeClipArchives.reduce(
(sum, archiveItem) => sum + archiveItem.size,
0
)
)}
</div>
<!-- 压制弹幕选项 -->
<div
class="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<div
class="text-sm font-medium text-blue-900 dark:text-blue-100"
>
压制弹幕
</div>
<div
class="text-xs text-blue-700 dark:text-blue-300 mt-1"
>
将弹幕直接压制到视频中,生成包含弹幕的最终视频文件
</div>
</div>
<label
class="relative inline-flex items-center cursor-pointer"
>
<input
type="checkbox"
bind:checked={encodeDanmu}
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-blue-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-blue-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-blue-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-blue-600 peer-checked:bg-blue-600"
></div>
</label>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Fixed Summary -->
{#if !isLoading && wholeClipArchives.length > 0}
<div class="px-6 pb-6">
<div
class="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800"
>
<div class="flex items-center space-x-2 mb-2">
<FileVideo class="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span
class="text-sm font-medium text-blue-900 dark:text-blue-100"
>合成信息</span
>
</div>
<div class="text-sm text-blue-800 dark:text-blue-200">
{wholeClipArchives.length} 个片段 · 总时长 {formatDuration(
wholeClipArchives.reduce(
(sum, archiveItem) => sum + archiveItem.length,
0
)
)} · 总大小 {formatSize(
wholeClipArchives.reduce(
(sum, archiveItem) => sum + archiveItem.size,
0
)
)}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
如果片段分辨率不一致,将会消耗更多时间用于重新编码
</div>
</div>
</div>
{/if}
</div>
<!-- Footer -->

View File

@@ -19,6 +19,7 @@
import BilibiliIcon from "../lib/components/BilibiliIcon.svelte";
import DouyinIcon from "../lib/components/DouyinIcon.svelte";
import AutoRecordIcon from "../lib/components/AutoRecordIcon.svelte";
import GenerateWholeClipModal from "../lib/components/GenerateWholeClipModal.svelte";
import { onMount } from "svelte";
export let room_count = 0;
@@ -243,21 +244,9 @@
// 按层级顺序检查modal优先处理最上层的modal
// 如果点击在最上层modal内部则不处理任何modal关闭
// 最上层generateWholeClipModal
// 最上层generateWholeClipModal (handled by component)
if (generateWholeClipModal) {
const generateWholeClipModalEl = document.querySelector(
".generate-whole-clip-modal"
);
if (generateWholeClipModalEl) {
if (generateWholeClipModalEl.contains(clickedElement)) {
// 点击在generateWholeClipModal内部不关闭任何modal
return;
} else {
// 点击在generateWholeClipModal外部关闭它
generateWholeClipModal = false;
return;
}
}
return; // Let the component handle its own modal closing
}
// 第二层archiveModal
@@ -357,60 +346,15 @@
let generateWholeClipModal = false;
let generateWholeClipArchive: RecordItem = null;
let wholeClipArchives: RecordItem[] = [];
let isLoadingWholeClip = false;
async function openGenerateWholeClipModal(archive: RecordItem) {
generateWholeClipModal = true;
generateWholeClipArchive = archive;
await loadWholeClipArchives(
Number(archiveRoom.room_info.room_id),
archive.parent_id
);
}
async function loadWholeClipArchives(roomId: number, parentId: string) {
if (isLoadingWholeClip) return;
isLoadingWholeClip = true;
try {
// 获取与当前archive具有相同parent_id的所有archives
let sameParentArchives = (await invoke("get_archives_by_parent_id", {
roomId: roomId,
parentId: parentId,
})) as RecordItem[];
// 处理封面
for (const archive of sameParentArchives) {
archive.cover = await get_cover(
"cache",
`${archive.platform}/${archive.room_id}/${archive.live_id}/cover.jpg`
);
}
// 按时间排序
sameParentArchives.sort((a, b) => {
return (
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
});
wholeClipArchives = sameParentArchives;
} catch (error) {
console.error("Failed to load whole clip archives:", error);
wholeClipArchives = [];
} finally {
isLoadingWholeClip = false;
}
}
async function generateWholeClip() {
function handleWholeClipGenerated() {
generateWholeClipModal = false;
await invoke("generate_whole_clip", {
platform: generateWholeClipArchive.platform,
roomId: Number(generateWholeClipArchive.room_id),
parentId: generateWholeClipArchive.parent_id,
});
generateWholeClipArchive = null;
}
onMount(async () => {
@@ -1053,154 +997,14 @@
</div>
{/if}
{#if generateWholeClipModal}
<div
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
transition:fade={{ duration: 200 }}
>
<div
class="mac-modal generate-whole-clip-modal w-[800px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden flex flex-col max-h-[80vh]"
transition:scale={{ duration: 150, start: 0.95 }}
>
<!-- Header -->
<div
class="flex justify-between items-center px-6 py-4 border-b border-gray-200 dark:border-gray-700/50"
>
<div class="flex items-center space-x-3">
<h2 class="text-base font-medium text-gray-900 dark:text-white">
生成完整直播切片
</h2>
<span class="text-sm text-gray-500 dark:text-gray-400">
{generateWholeClipArchive?.title || "直播片段"}
</span>
</div>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
on:click={() => (generateWholeClipModal = false)}
>
<X class="w-5 h-5 dark:icon-white" />
</button>
</div>
<!-- Content -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Description -->
<div class="px-6 pt-6 pb-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
以下是属于同一场直播的所有片段,将按时间顺序合成为一个完整的视频文件:
</p>
</div>
<!-- Scrollable List -->
<div class="flex-1 overflow-auto custom-scrollbar-light px-6 min-h-0">
{#if isLoadingWholeClip}
<div class="flex items-center justify-center py-8">
<div
class="flex items-center space-x-2 text-gray-500 dark:text-gray-400"
>
<div
class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"
></div>
<span>加载中...</span>
</div>
</div>
{:else if wholeClipArchives.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
未找到相关片段
</div>
{:else}
<div class="space-y-3 pb-4">
{#each wholeClipArchives as archive, index (archive.live_id)}
<div
class="flex items-center space-x-4 p-4 rounded-lg bg-gray-50 dark:bg-gray-700/30"
>
<div
class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium"
>
{index + 1}
</div>
{#if archive.cover}
<img
src={archive.cover}
alt="cover"
class="w-16 h-10 rounded object-cover flex-shrink-0"
/>
{/if}
<div class="flex-1 min-w-0">
<div
class="text-sm font-medium text-gray-900 dark:text-white truncate"
>
{archive.title}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{format_ts(archive.created_at)} · {format_duration(
archive.length
)} · {format_size(archive.size)}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Fixed Summary -->
{#if !isLoadingWholeClip && wholeClipArchives.length > 0}
<div class="px-6 pb-6">
<div
class="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800"
>
<div class="flex items-center space-x-2 mb-2">
<FileVideo class="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span
class="text-sm font-medium text-blue-900 dark:text-blue-100"
>合成信息</span
>
</div>
<div class="text-sm text-blue-800 dark:text-blue-200">
{wholeClipArchives.length} 个片段 · 总时长 {format_duration(
wholeClipArchives.reduce(
(sum, archive) => sum + archive.length,
0
)
)} · 总大小 {format_size(
wholeClipArchives.reduce(
(sum, archive) => sum + archive.size,
0
)
)}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
如果片段分辨率不一致,将会消耗更多时间用于重新编码
</div>
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div
class="px-6 py-4 border-t border-gray-200 dark:border-gray-700/50 flex justify-end space-x-3"
>
<button
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
on:click={() => (generateWholeClipModal = false)}
>
取消
</button>
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoadingWholeClip || wholeClipArchives.length === 0}
on:click={generateWholeClip}
>
开始合成
</button>
</div>
</div>
</div>
{/if}
<!-- Generate Whole Clip Modal -->
<GenerateWholeClipModal
bind:showModal={generateWholeClipModal}
archive={generateWholeClipArchive}
roomId={generateWholeClipArchive?.room_id || 0}
platform={generateWholeClipArchive?.platform || ""}
on:generated={handleWholeClipGenerated}
/>
<svelte:window on:mousedown={handleModalClickOutside} />

View File

@@ -768,10 +768,10 @@
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
自动切片压制弹幕(暂时禁止)
自动切片压制弹幕
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
启用后,自动切片时会同时压制弹幕
启用后,自动切片时会同时压制弹幕,会显著增加生成时间
</p>
</div>
<label class="relative inline-block w-11 h-6">