mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-24 20:15:34 +08:00
feat: generate whole live with danmu
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(()) => {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user