mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-08 00:56:51 +08:00
refactor: 将自动合并任务与嵌入章节任务合并成视频处理任务
This commit is contained in:
@@ -13,9 +13,8 @@ use crate::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
downloader::tasks::{
|
downloader::tasks::{
|
||||||
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
||||||
embed_chapter_task::EmbedChapterTask, embed_skip_task::EmbedSkipTask, json_task::JsonTask,
|
json_task::JsonTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
||||||
merge_task::MergeTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
video_process_task::VideoProcessTask, video_task::VideoTask,
|
||||||
video_task::VideoTask,
|
|
||||||
},
|
},
|
||||||
extensions::AppHandleExt,
|
extensions::AppHandleExt,
|
||||||
types::{
|
types::{
|
||||||
@@ -53,9 +52,7 @@ pub struct DownloadProgress {
|
|||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub video_task: VideoTask,
|
pub video_task: VideoTask,
|
||||||
pub audio_task: AudioTask,
|
pub audio_task: AudioTask,
|
||||||
pub merge_task: MergeTask,
|
pub video_process_task: VideoProcessTask,
|
||||||
pub embed_chapter_task: EmbedChapterTask,
|
|
||||||
pub embed_skip_task: EmbedSkipTask,
|
|
||||||
pub subtitle_task: SubtitleTask,
|
pub subtitle_task: SubtitleTask,
|
||||||
pub danmaku_task: DanmakuTask,
|
pub danmaku_task: DanmakuTask,
|
||||||
pub cover_task: CoverTask,
|
pub cover_task: CoverTask,
|
||||||
@@ -127,9 +124,7 @@ impl DownloadProgress {
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
video_process_task: tasks.video_process,
|
||||||
embed_chapter_task: tasks.embed_chapter,
|
|
||||||
embed_skip_task: tasks.embed_skip,
|
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -180,9 +175,7 @@ impl DownloadProgress {
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
video_process_task: tasks.video_process,
|
||||||
embed_chapter_task: tasks.embed_chapter,
|
|
||||||
embed_skip_task: tasks.embed_skip,
|
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -326,8 +319,7 @@ impl DownloadProgress {
|
|||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
self.video_task.is_completed()
|
self.video_task.is_completed()
|
||||||
&& self.audio_task.is_completed()
|
&& self.audio_task.is_completed()
|
||||||
&& self.merge_task.is_completed()
|
&& self.video_process_task.is_completed()
|
||||||
&& self.embed_chapter_task.is_completed()
|
|
||||||
&& self.danmaku_task.is_completed()
|
&& self.danmaku_task.is_completed()
|
||||||
&& self.subtitle_task.is_completed()
|
&& self.subtitle_task.is_completed()
|
||||||
&& self.cover_task.is_completed()
|
&& self.cover_task.is_completed()
|
||||||
@@ -338,8 +330,7 @@ impl DownloadProgress {
|
|||||||
pub fn mark_uncompleted(&mut self) {
|
pub fn mark_uncompleted(&mut self) {
|
||||||
self.video_task.mark_uncompleted();
|
self.video_task.mark_uncompleted();
|
||||||
self.audio_task.mark_uncompleted();
|
self.audio_task.mark_uncompleted();
|
||||||
self.merge_task.completed = false;
|
self.video_process_task.completed = false;
|
||||||
self.embed_chapter_task.completed = false;
|
|
||||||
self.danmaku_task.completed = false;
|
self.danmaku_task.completed = false;
|
||||||
self.subtitle_task.completed = false;
|
self.subtitle_task.completed = false;
|
||||||
self.cover_task.completed = false;
|
self.cover_task.completed = false;
|
||||||
@@ -392,9 +383,7 @@ fn create_normal_progresses_for_single(
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
video_process_task: tasks.video_process,
|
||||||
embed_chapter_task: tasks.embed_chapter,
|
|
||||||
embed_skip_task: tasks.embed_skip,
|
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -434,9 +423,7 @@ fn create_normal_progresses_for_single(
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
video_process_task: tasks.video_process,
|
||||||
embed_chapter_task: tasks.embed_chapter,
|
|
||||||
embed_skip_task: tasks.embed_skip,
|
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -476,9 +463,7 @@ fn create_normal_progresses_for_single(
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video.clone(),
|
video_task: tasks.video.clone(),
|
||||||
audio_task: tasks.audio.clone(),
|
audio_task: tasks.audio.clone(),
|
||||||
merge_task: tasks.merge.clone(),
|
video_process_task: tasks.video_process.clone(),
|
||||||
embed_chapter_task: tasks.embed_chapter.clone(),
|
|
||||||
embed_skip_task: tasks.embed_skip.clone(),
|
|
||||||
danmaku_task: tasks.danmaku.clone(),
|
danmaku_task: tasks.danmaku.clone(),
|
||||||
subtitle_task: tasks.subtitle.clone(),
|
subtitle_task: tasks.subtitle.clone(),
|
||||||
cover_task: tasks.cover.clone(),
|
cover_task: tasks.cover.clone(),
|
||||||
@@ -550,9 +535,7 @@ fn create_normal_progresses_for_season(
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
video_process_task: tasks.video_process,
|
||||||
embed_chapter_task: tasks.embed_chapter,
|
|
||||||
embed_skip_task: tasks.embed_skip,
|
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -592,9 +575,7 @@ fn create_normal_progresses_for_season(
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
video_process_task: tasks.video_process,
|
||||||
embed_chapter_task: tasks.embed_chapter,
|
|
||||||
embed_skip_task: tasks.embed_skip,
|
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -635,9 +616,7 @@ fn create_normal_progresses_for_season(
|
|||||||
filename: String::new(),
|
filename: String::new(),
|
||||||
video_task: tasks.video.clone(),
|
video_task: tasks.video.clone(),
|
||||||
audio_task: tasks.audio.clone(),
|
audio_task: tasks.audio.clone(),
|
||||||
merge_task: tasks.merge.clone(),
|
video_process_task: tasks.video_process.clone(),
|
||||||
embed_chapter_task: tasks.embed_chapter.clone(),
|
|
||||||
embed_skip_task: tasks.embed_skip.clone(),
|
|
||||||
danmaku_task: tasks.danmaku.clone(),
|
danmaku_task: tasks.danmaku.clone(),
|
||||||
subtitle_task: tasks.subtitle.clone(),
|
subtitle_task: tasks.subtitle.clone(),
|
||||||
cover_task: tasks.cover.clone(),
|
cover_task: tasks.cover.clone(),
|
||||||
@@ -659,9 +638,7 @@ fn create_normal_progresses_for_season(
|
|||||||
struct Tasks {
|
struct Tasks {
|
||||||
video: VideoTask,
|
video: VideoTask,
|
||||||
audio: AudioTask,
|
audio: AudioTask,
|
||||||
merge: MergeTask,
|
video_process: VideoProcessTask,
|
||||||
embed_chapter: EmbedChapterTask,
|
|
||||||
embed_skip: EmbedSkipTask,
|
|
||||||
danmaku: DanmakuTask,
|
danmaku: DanmakuTask,
|
||||||
subtitle: SubtitleTask,
|
subtitle: SubtitleTask,
|
||||||
cover: CoverTask,
|
cover: CoverTask,
|
||||||
@@ -690,18 +667,10 @@ impl Tasks {
|
|||||||
completed: false,
|
completed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let merge = MergeTask {
|
let video_process = VideoProcessTask {
|
||||||
selected: config.auto_merge,
|
merge_selected: config.auto_merge,
|
||||||
completed: false,
|
embed_chapter_selected: config.embed_chapter,
|
||||||
};
|
embed_skip_selected: config.embed_skip,
|
||||||
|
|
||||||
let embed_chapter = EmbedChapterTask {
|
|
||||||
selected: config.embed_chapter,
|
|
||||||
completed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let embed_skip = EmbedSkipTask {
|
|
||||||
selected: config.embed_skip,
|
|
||||||
completed: false,
|
completed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -736,9 +705,7 @@ impl Tasks {
|
|||||||
Self {
|
Self {
|
||||||
video,
|
video,
|
||||||
audio,
|
audio,
|
||||||
merge,
|
video_process,
|
||||||
embed_chapter,
|
|
||||||
embed_skip,
|
|
||||||
danmaku,
|
danmaku,
|
||||||
subtitle,
|
subtitle,
|
||||||
cover,
|
cover,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::{
|
|||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::Context;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_specta::Event;
|
use tauri_specta::Event;
|
||||||
@@ -13,11 +13,9 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloader::chapter_segments::{ChapterSegment, ChapterSegments},
|
|
||||||
events::DownloadEvent,
|
events::DownloadEvent,
|
||||||
extensions::{AnyhowErrorToStringChain, AppHandleExt, GetOrInitPlayerInfo},
|
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||||
types::{create_download_task_params::CreateDownloadTaskParams, player_info::PlayerInfo},
|
types::create_download_task_params::CreateDownloadTaskParams,
|
||||||
utils::{self},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState};
|
use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState};
|
||||||
@@ -279,9 +277,7 @@ impl DownloadTask {
|
|||||||
|
|
||||||
let video_task = &progress.video_task;
|
let video_task = &progress.video_task;
|
||||||
let audio_task = &progress.audio_task;
|
let audio_task = &progress.audio_task;
|
||||||
let merge_task = &progress.merge_task;
|
let video_process_task = &progress.video_process_task;
|
||||||
let embed_chapter_task = &progress.embed_chapter_task;
|
|
||||||
let embed_skip_task = &progress.embed_skip_task;
|
|
||||||
let danmaku_task = &progress.danmaku_task;
|
let danmaku_task = &progress.danmaku_task;
|
||||||
let subtitle_task = &progress.subtitle_task;
|
let subtitle_task = &progress.subtitle_task;
|
||||||
let cover_task = &progress.cover_task;
|
let cover_task = &progress.cover_task;
|
||||||
@@ -292,7 +288,6 @@ impl DownloadTask {
|
|||||||
let mut episode_info = None;
|
let mut episode_info = None;
|
||||||
|
|
||||||
if !video_task.is_completed() && video_task.content_length != 0 {
|
if !video_task.is_completed() && video_task.content_length != 0 {
|
||||||
// 如果视频任务被选中且未完成且有要下载的内容,则下载视频
|
|
||||||
video_task
|
video_task
|
||||||
.process(self, &progress)
|
.process(self, &progress)
|
||||||
.await
|
.await
|
||||||
@@ -301,7 +296,6 @@ impl DownloadTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !audio_task.is_completed() && audio_task.content_length != 0 {
|
if !audio_task.is_completed() && audio_task.content_length != 0 {
|
||||||
// 如果音频任务被选中且未完成且有要下载的内容,则下载音频
|
|
||||||
audio_task
|
audio_task
|
||||||
.process(self, &progress)
|
.process(self, &progress)
|
||||||
.await
|
.await
|
||||||
@@ -309,30 +303,12 @@ impl DownloadTask {
|
|||||||
tracing::debug!("{ids_string} `{filename}`音频下载完成");
|
tracing::debug!("{ids_string} `{filename}`音频下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
let merge_completed = merge_task.is_completed();
|
if !video_process_task.is_completed() {
|
||||||
let embed_completed = embed_chapter_task.is_completed() && embed_skip_task.is_completed();
|
video_process_task
|
||||||
|
.process(self, &progress, &mut player_info)
|
||||||
if !merge_completed && !embed_completed {
|
|
||||||
// 如果合并任务和嵌入任务都未完成,则调用merge_and_embed,将两个任务通过一个ffmpeg命令完成
|
|
||||||
self.merge_and_embed(&progress, &mut player_info)
|
|
||||||
.await
|
.await
|
||||||
.context(format!(
|
.context(format!("{ids_string} `{filename}`视频处理失败"))?;
|
||||||
"{ids_string} `{filename}`自动合并+嵌入章节元数据失败"
|
tracing::debug!("{ids_string} `{filename}`视频处理完成");
|
||||||
))?;
|
|
||||||
tracing::debug!("{ids_string} `{filename}`自动合并+嵌入章节元数据完成");
|
|
||||||
} else if !merge_completed {
|
|
||||||
// 如果合并任务未完成,嵌入任务已完成,则只合并
|
|
||||||
merge_task
|
|
||||||
.process(self, &progress)
|
|
||||||
.await
|
|
||||||
.context(format!("{ids_string} `{filename}`自动合并失败"))?;
|
|
||||||
tracing::debug!("{ids_string} `{filename}`自动合并完成");
|
|
||||||
} else if !embed_completed {
|
|
||||||
// 如果嵌入任务未完成,合并任务已完成,则只嵌入
|
|
||||||
self.embed_chapter_segments(&progress, &mut player_info)
|
|
||||||
.await
|
|
||||||
.context(format!("{ids_string} `{filename}`嵌入章节元数据失败"))?;
|
|
||||||
tracing::debug!("{ids_string} `{filename}`嵌入章节元数据完成");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !danmaku_task.is_completed() {
|
if !danmaku_task.is_completed() {
|
||||||
@@ -386,267 +362,6 @@ impl DownloadTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_chapter_segments(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
progress: &DownloadProgress,
|
|
||||||
player_info: &mut Option<PlayerInfo>,
|
|
||||||
) -> anyhow::Result<ChapterSegments> {
|
|
||||||
let mut chapter_segments = ChapterSegments {
|
|
||||||
segments: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !progress.embed_chapter_task.is_completed() {
|
|
||||||
let player_info = player_info.get_or_init(&self.app, progress).await?;
|
|
||||||
let segments = player_info
|
|
||||||
.view_points
|
|
||||||
.iter()
|
|
||||||
.map(|vp| ChapterSegment {
|
|
||||||
title: vp.content.clone(),
|
|
||||||
start: vp.from,
|
|
||||||
end: vp.to,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
chapter_segments = ChapterSegments { segments };
|
|
||||||
}
|
|
||||||
|
|
||||||
if !progress.embed_skip_task.is_completed() {
|
|
||||||
let bili_client = self.app.get_bili_client();
|
|
||||||
let Some(bvid) = &progress.bvid else {
|
|
||||||
return Ok(chapter_segments);
|
|
||||||
};
|
|
||||||
let cid = Some(progress.cid);
|
|
||||||
|
|
||||||
let skip_segments = bili_client.get_skip_segments(bvid, cid).await?;
|
|
||||||
for segment in skip_segments.0 {
|
|
||||||
if let Some(chapter_segment) = segment.into_chapter_segment() {
|
|
||||||
chapter_segments.insert(chapter_segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(chapter_segments)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn merge_and_embed(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
progress: &DownloadProgress,
|
|
||||||
player_info: &mut Option<PlayerInfo>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
|
||||||
|
|
||||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
|
||||||
if !video_path.exists() {
|
|
||||||
self.update_progress(|p| {
|
|
||||||
if !p.embed_chapter_task.is_completed() {
|
|
||||||
p.embed_chapter_task.completed = true;
|
|
||||||
}
|
|
||||||
if !p.embed_skip_task.is_completed() {
|
|
||||||
p.embed_skip_task.completed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.merge_task.completed = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
|
||||||
if !audio_path.exists() {
|
|
||||||
// 如果音频文件不存在,则只嵌入章节元数据
|
|
||||||
self.embed_chapter_segments(progress, player_info)
|
|
||||||
.await
|
|
||||||
.context("嵌入章节元数据失败")?;
|
|
||||||
|
|
||||||
self.update_progress(|p| p.merge_task.completed = true);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let chapter_segments = self.create_chapter_segments(progress, player_info).await?;
|
|
||||||
let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration);
|
|
||||||
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
|
||||||
|
|
||||||
std::fs::write(&metadata_path, metadata_content)
|
|
||||||
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
|
||||||
|
|
||||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
|
||||||
|
|
||||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
||||||
let video_path_clone = video_path.clone();
|
|
||||||
let audio_path_clone = audio_path.clone();
|
|
||||||
let metadata_path_clone = metadata_path.clone();
|
|
||||||
let output_path_clone = output_path.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut command = std::process::Command::new(ffmpeg_program);
|
|
||||||
|
|
||||||
command
|
|
||||||
.arg("-i")
|
|
||||||
.arg(video_path_clone)
|
|
||||||
.arg("-i")
|
|
||||||
.arg(audio_path_clone)
|
|
||||||
.arg("-i")
|
|
||||||
.arg(metadata_path_clone)
|
|
||||||
.arg("-map_metadata")
|
|
||||||
.arg("2")
|
|
||||||
.arg("-c")
|
|
||||||
.arg("copy")
|
|
||||||
.arg("-map")
|
|
||||||
.arg("0:v:0")
|
|
||||||
.arg("-map")
|
|
||||||
.arg("1:a:0")
|
|
||||||
.arg(output_path_clone)
|
|
||||||
.arg("-y");
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
// 隐藏窗口
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
command.creation_flags(0x0800_0000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = command.output();
|
|
||||||
|
|
||||||
let _ = tx.send(output);
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = rx.await??;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
|
||||||
.context(format!("STDERR: {stderr}"))
|
|
||||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::remove_file(&video_path)
|
|
||||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
|
||||||
std::fs::remove_file(&audio_path)
|
|
||||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
|
||||||
std::fs::rename(&output_path, &video_path).context(format!(
|
|
||||||
"将`{}`重命名为`{}`失败",
|
|
||||||
output_path.display(),
|
|
||||||
video_path.display()
|
|
||||||
))?;
|
|
||||||
std::fs::remove_file(&metadata_path).context(format!(
|
|
||||||
"删除章节元数据文件`{}`失败",
|
|
||||||
metadata_path.display()
|
|
||||||
))?;
|
|
||||||
|
|
||||||
self.update_progress(|p| {
|
|
||||||
if !p.embed_chapter_task.is_completed() {
|
|
||||||
p.embed_chapter_task.completed = true;
|
|
||||||
}
|
|
||||||
if !p.embed_skip_task.is_completed() {
|
|
||||||
p.embed_skip_task.completed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.merge_task.completed = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn embed_chapter_segments(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
progress: &DownloadProgress,
|
|
||||||
player_info: &mut Option<PlayerInfo>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
|
||||||
|
|
||||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
|
||||||
if !video_path.exists() {
|
|
||||||
self.update_progress(|p| {
|
|
||||||
if !p.embed_chapter_task.is_completed() {
|
|
||||||
p.embed_chapter_task.completed = true;
|
|
||||||
}
|
|
||||||
if !p.embed_skip_task.is_completed() {
|
|
||||||
p.embed_skip_task.completed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
|
||||||
let output_path = episode_dir.join(format!("{filename}-embed.mp4"));
|
|
||||||
|
|
||||||
let chapter_segments = self.create_chapter_segments(progress, player_info).await?;
|
|
||||||
let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration);
|
|
||||||
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
|
||||||
|
|
||||||
std::fs::write(&metadata_path, metadata_content)
|
|
||||||
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
||||||
let video_path_clone = video_path.clone();
|
|
||||||
let metadata_path_clone = metadata_path.clone();
|
|
||||||
let output_path_clone = output_path.clone();
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let mut command = std::process::Command::new(ffmpeg_program);
|
|
||||||
|
|
||||||
command
|
|
||||||
.arg("-i")
|
|
||||||
.arg(video_path_clone)
|
|
||||||
.arg("-i")
|
|
||||||
.arg(metadata_path_clone)
|
|
||||||
.arg("-map_metadata")
|
|
||||||
.arg("1")
|
|
||||||
.arg("-c")
|
|
||||||
.arg("copy")
|
|
||||||
.arg(output_path_clone)
|
|
||||||
.arg("-y");
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
// 隐藏窗口
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
command.creation_flags(0x0800_0000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = command.output();
|
|
||||||
|
|
||||||
let _ = tx.send(output);
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = rx.await??;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
|
||||||
.context(format!("STDERR: {stderr}"))
|
|
||||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::remove_file(&video_path)
|
|
||||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
|
||||||
std::fs::rename(&output_path, &video_path).context(format!(
|
|
||||||
"将`{}`重命名为`{}`失败",
|
|
||||||
output_path.display(),
|
|
||||||
video_path.display()
|
|
||||||
))?;
|
|
||||||
std::fs::remove_file(&metadata_path).context(format!(
|
|
||||||
"删除章节元数据文件`{}`失败",
|
|
||||||
metadata_path.display()
|
|
||||||
))?;
|
|
||||||
|
|
||||||
self.update_progress(|p| {
|
|
||||||
if !p.embed_chapter_task.is_completed() {
|
|
||||||
p.embed_chapter_task.completed = true;
|
|
||||||
}
|
|
||||||
if !p.embed_skip_task.is_completed() {
|
|
||||||
p.embed_skip_task.completed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sleep_between_task(&self) {
|
async fn sleep_between_task(&self) {
|
||||||
let task_id = &self.task_id;
|
let task_id = &self.task_id;
|
||||||
let mut remaining_sec = self.app.get_config().read().task_download_interval_sec;
|
let mut remaining_sec = self.app.get_config().read().task_download_interval_sec;
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use specta::Type;
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
|
||||||
pub struct EmbedChapterTask {
|
|
||||||
pub selected: bool,
|
|
||||||
pub completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EmbedChapterTask {
|
|
||||||
pub fn is_completed(&self) -> bool {
|
|
||||||
!self.selected || self.completed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use specta::Type;
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
|
||||||
pub struct EmbedSkipTask {
|
|
||||||
pub selected: bool,
|
|
||||||
pub completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EmbedSkipTask {
|
|
||||||
pub fn is_completed(&self) -> bool {
|
|
||||||
!self.selected || self.completed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use specta::Type;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
downloader::{download_progress::DownloadProgress, download_task::DownloadTask},
|
|
||||||
utils,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
|
||||||
pub struct MergeTask {
|
|
||||||
pub selected: bool,
|
|
||||||
pub completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MergeTask {
|
|
||||||
pub fn is_completed(&self) -> bool {
|
|
||||||
!self.selected || self.completed
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn process(
|
|
||||||
&self,
|
|
||||||
download_task: &Arc<DownloadTask>,
|
|
||||||
progress: &DownloadProgress,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
|
||||||
|
|
||||||
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
|
||||||
if !video_path.exists() {
|
|
||||||
download_task.update_progress(|p| p.merge_task.completed = true);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
|
||||||
if !audio_path.exists() {
|
|
||||||
download_task.update_progress(|p| p.merge_task.completed = true);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
|
||||||
|
|
||||||
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
||||||
let video_path_clone = video_path.clone();
|
|
||||||
let audio_path_clone = audio_path.clone();
|
|
||||||
let output_path_clone = output_path.clone();
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let mut command = std::process::Command::new(ffmpeg_program);
|
|
||||||
|
|
||||||
command
|
|
||||||
.arg("-i")
|
|
||||||
.arg(video_path_clone)
|
|
||||||
.arg("-i")
|
|
||||||
.arg(audio_path_clone)
|
|
||||||
.arg("-c")
|
|
||||||
.arg("copy")
|
|
||||||
.arg("-map")
|
|
||||||
.arg("0:v:0")
|
|
||||||
.arg("-map")
|
|
||||||
.arg("1:a:0")
|
|
||||||
.arg(output_path_clone)
|
|
||||||
.arg("-y");
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
// 隐藏窗口
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
command.creation_flags(0x0800_0000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = command.output();
|
|
||||||
|
|
||||||
let _ = tx.send(output);
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = rx.await??;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let err = anyhow!(format!("STDOUT: {stdout}"))
|
|
||||||
.context(format!("STDERR: {stderr}"))
|
|
||||||
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::remove_file(&video_path)
|
|
||||||
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
|
||||||
std::fs::remove_file(&audio_path)
|
|
||||||
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
|
||||||
std::fs::rename(&output_path, &video_path).context(format!(
|
|
||||||
"将`{}`重命名为`{}`失败",
|
|
||||||
output_path.display(),
|
|
||||||
video_path.display()
|
|
||||||
))?;
|
|
||||||
|
|
||||||
download_task.update_progress(|p| p.merge_task.completed = true);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
pub mod audio_task;
|
pub mod audio_task;
|
||||||
pub mod cover_task;
|
pub mod cover_task;
|
||||||
pub mod danmaku_task;
|
pub mod danmaku_task;
|
||||||
pub mod embed_chapter_task;
|
|
||||||
pub mod embed_skip_task;
|
|
||||||
pub mod json_task;
|
pub mod json_task;
|
||||||
pub mod merge_task;
|
|
||||||
pub mod nfo_task;
|
pub mod nfo_task;
|
||||||
pub mod subtitle_task;
|
pub mod subtitle_task;
|
||||||
|
pub mod video_process_task;
|
||||||
pub mod video_task;
|
pub mod video_task;
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
downloader::{
|
||||||
|
chapter_segments::{ChapterSegment, ChapterSegments},
|
||||||
|
download_progress::DownloadProgress,
|
||||||
|
download_task::DownloadTask,
|
||||||
|
},
|
||||||
|
extensions::{AppHandleExt, GetOrInitPlayerInfo},
|
||||||
|
types::player_info::PlayerInfo,
|
||||||
|
utils,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
|
pub struct VideoProcessTask {
|
||||||
|
pub merge_selected: bool,
|
||||||
|
pub embed_chapter_selected: bool,
|
||||||
|
pub embed_skip_selected: bool,
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoProcessTask {
|
||||||
|
pub fn is_completed(&self) -> bool {
|
||||||
|
!self.merge_selected && !self.embed_chapter_selected && !self.embed_skip_selected
|
||||||
|
|| self.completed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process(
|
||||||
|
&self,
|
||||||
|
download_task: &Arc<DownloadTask>,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
player_info: &mut Option<PlayerInfo>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let embed_selected = self.embed_chapter_selected || self.embed_skip_selected;
|
||||||
|
|
||||||
|
if self.merge_selected && embed_selected {
|
||||||
|
self.merge_and_embed(download_task, progress, player_info)
|
||||||
|
.await
|
||||||
|
.context("自动合并+嵌入章节元数据失败")?;
|
||||||
|
} else if self.merge_selected {
|
||||||
|
println!("merge1");
|
||||||
|
self.merge(download_task, progress)
|
||||||
|
.await
|
||||||
|
.context("自动合并失败")?;
|
||||||
|
} else if embed_selected {
|
||||||
|
self.embed(download_task, progress, player_info)
|
||||||
|
.await
|
||||||
|
.context("嵌入章节元数据失败")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn merge_and_embed(
|
||||||
|
&self,
|
||||||
|
download_task: &Arc<DownloadTask>,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
player_info: &mut Option<PlayerInfo>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
|
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||||
|
if !video_path.exists() {
|
||||||
|
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||||
|
if !audio_path.exists() {
|
||||||
|
// 如果音频文件不存在,则只嵌入章节元数据
|
||||||
|
self.embed(download_task, progress, player_info)
|
||||||
|
.await
|
||||||
|
.context("嵌入章节元数据失败")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let chapter_segments = self
|
||||||
|
.create_chapter_segments(&download_task.app, progress, player_info)
|
||||||
|
.await?;
|
||||||
|
let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration);
|
||||||
|
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
||||||
|
|
||||||
|
std::fs::write(&metadata_path, metadata_content)
|
||||||
|
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
||||||
|
|
||||||
|
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||||
|
|
||||||
|
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let video_path_clone = video_path.clone();
|
||||||
|
let audio_path_clone = audio_path.clone();
|
||||||
|
let metadata_path_clone = metadata_path.clone();
|
||||||
|
let output_path_clone = output_path.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
|
command
|
||||||
|
.arg("-i")
|
||||||
|
.arg(video_path_clone)
|
||||||
|
.arg("-i")
|
||||||
|
.arg(audio_path_clone)
|
||||||
|
.arg("-i")
|
||||||
|
.arg(metadata_path_clone)
|
||||||
|
.arg("-map_metadata")
|
||||||
|
.arg("2")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("copy")
|
||||||
|
.arg("-map")
|
||||||
|
.arg("0:v:0")
|
||||||
|
.arg("-map")
|
||||||
|
.arg("1:a:0")
|
||||||
|
.arg(output_path_clone)
|
||||||
|
.arg("-y");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// 隐藏窗口
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = command.output();
|
||||||
|
|
||||||
|
let _ = tx.send(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = rx.await??;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||||
|
.context(format!("STDERR: {stderr}"))
|
||||||
|
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&video_path)
|
||||||
|
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
|
std::fs::remove_file(&audio_path)
|
||||||
|
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||||
|
std::fs::rename(&output_path, &video_path).context(format!(
|
||||||
|
"将`{}`重命名为`{}`失败",
|
||||||
|
output_path.display(),
|
||||||
|
video_path.display()
|
||||||
|
))?;
|
||||||
|
std::fs::remove_file(&metadata_path).context(format!(
|
||||||
|
"删除章节元数据文件`{}`失败",
|
||||||
|
metadata_path.display()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn merge(
|
||||||
|
&self,
|
||||||
|
download_task: &Arc<DownloadTask>,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
|
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||||
|
if !video_path.exists() {
|
||||||
|
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||||
|
if !audio_path.exists() {
|
||||||
|
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||||
|
|
||||||
|
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let video_path_clone = video_path.clone();
|
||||||
|
let audio_path_clone = audio_path.clone();
|
||||||
|
let output_path_clone = output_path.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
|
command
|
||||||
|
.arg("-i")
|
||||||
|
.arg(video_path_clone)
|
||||||
|
.arg("-i")
|
||||||
|
.arg(audio_path_clone)
|
||||||
|
.arg("-c")
|
||||||
|
.arg("copy")
|
||||||
|
.arg("-map")
|
||||||
|
.arg("0:v:0")
|
||||||
|
.arg("-map")
|
||||||
|
.arg("1:a:0")
|
||||||
|
.arg(output_path_clone)
|
||||||
|
.arg("-y");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// 隐藏窗口
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = command.output();
|
||||||
|
|
||||||
|
let _ = tx.send(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = rx.await??;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||||
|
.context(format!("STDERR: {stderr}"))
|
||||||
|
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&video_path)
|
||||||
|
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
|
std::fs::remove_file(&audio_path)
|
||||||
|
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||||
|
std::fs::rename(&output_path, &video_path).context(format!(
|
||||||
|
"将`{}`重命名为`{}`失败",
|
||||||
|
output_path.display(),
|
||||||
|
video_path.display()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embed(
|
||||||
|
&self,
|
||||||
|
download_task: &Arc<DownloadTask>,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
player_info: &mut Option<PlayerInfo>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
|
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||||
|
if !video_path.exists() {
|
||||||
|
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||||
|
let output_path = episode_dir.join(format!("{filename}-embed.mp4"));
|
||||||
|
|
||||||
|
let chapter_segments = self
|
||||||
|
.create_chapter_segments(&download_task.app, progress, player_info)
|
||||||
|
.await?;
|
||||||
|
let metadata_content = chapter_segments.generate_chapter_metadata(progress.duration);
|
||||||
|
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
||||||
|
|
||||||
|
std::fs::write(&metadata_path, metadata_content)
|
||||||
|
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let video_path_clone = video_path.clone();
|
||||||
|
let metadata_path_clone = metadata_path.clone();
|
||||||
|
let output_path_clone = output_path.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
|
command
|
||||||
|
.arg("-i")
|
||||||
|
.arg(video_path_clone)
|
||||||
|
.arg("-i")
|
||||||
|
.arg(metadata_path_clone)
|
||||||
|
.arg("-map_metadata")
|
||||||
|
.arg("1")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("copy")
|
||||||
|
.arg(output_path_clone)
|
||||||
|
.arg("-y");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// 隐藏窗口
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = command.output();
|
||||||
|
|
||||||
|
let _ = tx.send(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = rx.await??;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||||
|
.context(format!("STDERR: {stderr}"))
|
||||||
|
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&video_path)
|
||||||
|
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
|
std::fs::rename(&output_path, &video_path).context(format!(
|
||||||
|
"将`{}`重命名为`{}`失败",
|
||||||
|
output_path.display(),
|
||||||
|
video_path.display()
|
||||||
|
))?;
|
||||||
|
std::fs::remove_file(&metadata_path).context(format!(
|
||||||
|
"删除章节元数据文件`{}`失败",
|
||||||
|
metadata_path.display()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
download_task.update_progress(|p| p.video_process_task.completed = true);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_chapter_segments(
|
||||||
|
&self,
|
||||||
|
app: &AppHandle,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
player_info: &mut Option<PlayerInfo>,
|
||||||
|
) -> anyhow::Result<ChapterSegments> {
|
||||||
|
let mut chapter_segments = ChapterSegments {
|
||||||
|
segments: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.embed_chapter_selected {
|
||||||
|
let player_info = player_info.get_or_init(app, progress).await?;
|
||||||
|
let segments = player_info
|
||||||
|
.view_points
|
||||||
|
.iter()
|
||||||
|
.map(|vp| ChapterSegment {
|
||||||
|
title: vp.content.clone(),
|
||||||
|
start: vp.from,
|
||||||
|
end: vp.to,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
chapter_segments = ChapterSegments { segments };
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.embed_skip_selected {
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let Some(bvid) = &progress.bvid else {
|
||||||
|
return Ok(chapter_segments);
|
||||||
|
};
|
||||||
|
let cid = Some(progress.cid);
|
||||||
|
|
||||||
|
let skip_segments = bili_client.get_skip_segments(bvid, cid).await?;
|
||||||
|
for segment in skip_segments.0 {
|
||||||
|
if let Some(chapter_segment) = segment.into_chapter_segment() {
|
||||||
|
chapter_segments.insert(chapter_segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(chapter_segments)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-4
@@ -263,11 +263,9 @@ export type Dimension = { width: number; height: number; rotate: number }
|
|||||||
export type DimensionInBangumi = { height: number; rotate: number; width: number }
|
export type DimensionInBangumi = { height: number; rotate: number; width: number }
|
||||||
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
||||||
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
|
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
|
||||||
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; merge_task: MergeTask; embed_chapter_task: EmbedChapterTask; embed_skip_task: EmbedSkipTask; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; cover_task: CoverTask; nfo_task: NfoTask; json_task: JsonTask; create_ts: number; completed_ts: number | null }
|
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; video_process_task: VideoProcessTask; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; cover_task: CoverTask; nfo_task: NfoTask; json_task: JsonTask; create_ts: number; completed_ts: number | null }
|
||||||
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
||||||
export type Ed = { end: number; start: number }
|
export type Ed = { end: number; start: number }
|
||||||
export type EmbedChapterTask = { selected: boolean; completed: boolean }
|
|
||||||
export type EmbedSkipTask = { selected: boolean; completed: boolean }
|
|
||||||
export type EpInBangumi = { aid: number; badge: string; badge_info: BadgeInfoInBangumi; badge_type: number | null; bvid: string | null; cid: number; cover: string; dimension: DimensionInBangumi | null; duration: number | null; enable_vt: boolean; ep_id: number; from: string | null; id: number; is_view_hide: boolean; link: string; link_type: string | null; long_title: string | null; pub_time: number; pv: number; release_date: string | null; rights: RightsInBangumiEp | null; section_type: number; share_copy: string | null; share_url: string | null; short_link: string | null; showDrmLoginDialog: boolean; show_title: string | null; skip: Skip | null; status: number; subtitle: string | null; title: string; vid: string | null; icon_font: IconFont | null }
|
export type EpInBangumi = { aid: number; badge: string; badge_info: BadgeInfoInBangumi; badge_type: number | null; bvid: string | null; cid: number; cover: string; dimension: DimensionInBangumi | null; duration: number | null; enable_vt: boolean; ep_id: number; from: string | null; id: number; is_view_hide: boolean; link: string; link_type: string | null; long_title: string | null; pub_time: number; pv: number; release_date: string | null; rights: RightsInBangumiEp | null; section_type: number; share_copy: string | null; share_url: string | null; short_link: string | null; showDrmLoginDialog: boolean; show_title: string | null; skip: Skip | null; status: number; subtitle: string | null; title: string; vid: string | null; icon_font: IconFont | null }
|
||||||
export type EpInBangumiFollow = { season_id: number; media_id: number; season_type: number; season_type_name: string; title: string; cover: string; total_count: number; is_finish: number; is_started: number; is_play: number; badge: string; badge_type: number; rights: RightsInBangumiFollow; stat: StatInBangumiFollow; new_ep: NewEpInBangumiFollow; rating: RatingInBangumiFollow | null; square_cover: string; season_status: number; season_title: string; badge_ep: string; media_attr: number; season_attr: number; evaluate: string; areas: AreaInBangumiFollow[]; subtitle: string; first_ep: number; can_watch: number; release_date_show: string | null; series: SeriesInBangumiFollow; publish: PublishInBangumiFollow; mode: number; section: SectionInBangumiFollow[]; url: string; badge_info: BadgeInfoInBangumiFollow; renewal_time: string | null; first_ep_info: FirstEpInfo; formal_ep_count: number | null; short_url: string; badge_infos: BadgeInfos | null; season_version: string | null; horizontal_cover_16_9: string | null; horizontal_cover_16_10: string | null; subtitle_14: string | null; viewable_crowd_type: number; producers?: Producer[]; summary: string; styles?: string[]; follow_status: number; is_new: number; progress: string; both_follow: boolean; subtitle_25: string | null }
|
export type EpInBangumiFollow = { season_id: number; media_id: number; season_type: number; season_type_name: string; title: string; cover: string; total_count: number; is_finish: number; is_started: number; is_play: number; badge: string; badge_type: number; rights: RightsInBangumiFollow; stat: StatInBangumiFollow; new_ep: NewEpInBangumiFollow; rating: RatingInBangumiFollow | null; square_cover: string; season_status: number; season_title: string; badge_ep: string; media_attr: number; season_attr: number; evaluate: string; areas: AreaInBangumiFollow[]; subtitle: string; first_ep: number; can_watch: number; release_date_show: string | null; series: SeriesInBangumiFollow; publish: PublishInBangumiFollow; mode: number; section: SectionInBangumiFollow[]; url: string; badge_info: BadgeInfoInBangumiFollow; renewal_time: string | null; first_ep_info: FirstEpInfo; formal_ep_count: number | null; short_url: string; badge_infos: BadgeInfos | null; season_version: string | null; horizontal_cover_16_9: string | null; horizontal_cover_16_10: string | null; subtitle_14: string | null; viewable_crowd_type: number; producers?: Producer[]; summary: string; styles?: string[]; follow_status: number; is_new: number; progress: string; both_follow: boolean; subtitle_25: string | null }
|
||||||
export type EpInCheese = { aid: number; catalogue_index: number; cid: number; cover: string; duration: number; ep_status: number; episode_can_view: boolean; from: string; id: number; index: number; label: string | null; page: number; play: number; play_way: number; playable: boolean; release_date: number; show_vt: boolean; status: number; subtitle: string; title: string; watched: boolean; watchedHistory: number }
|
export type EpInCheese = { aid: number; catalogue_index: number; cid: number; cover: string; duration: number; ep_status: number; episode_can_view: boolean; from: string; id: number; index: number; label: string | null; page: number; play: number; play_way: number; playable: boolean; release_date: number; show_vt: boolean; status: number; subtitle: string; title: string; watched: boolean; watchedHistory: number }
|
||||||
@@ -308,7 +306,6 @@ export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR"
|
|||||||
export type MediaChunk = { start: number; end: number; completed: boolean }
|
export type MediaChunk = { start: number; end: number; completed: boolean }
|
||||||
export type MediaInFav = { id: number; type: number; title: string; cover: string; intro: string; page: number; duration: number; upper: UpperInMedia; attr: number; cnt_info: CntInfoInMedia; link: string; ctime: number; pubtime: number; fav_time: number; bv_id: string; bvid: string; ugc: Ugc | null; media_list_link: string }
|
export type MediaInFav = { id: number; type: number; title: string; cover: string; intro: string; page: number; duration: number; upper: UpperInMedia; attr: number; cnt_info: CntInfoInMedia; link: string; ctime: number; pubtime: number; fav_time: number; bv_id: string; bvid: string; ugc: Ugc | null; media_list_link: string }
|
||||||
export type MediaInWatchLater = { aid: number; videos: number; tid: number; tname: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; redirect_url: string | null; mission_id: number | null; rights: RightsInWatchLater; owner: OwnerInWatchLater; stat: StatInWatchLater; dynamic: string; dimension: DimensionInWatchLater; short_link_v2: string; up_from_v2: number | null; first_frame: string | null; pub_location: string | null; cover43: string; tidv2: number; tnamev2: string; pid_v2: number; pid_name_v2: string; page: PageInWatchLater; count: number; cid: number; progress: number; add_at: number; bvid: string; uri: string; enable_vt: number; view_text_1: string; card_type: number; left_icon_type: number; left_text: string; right_icon_type: number; right_text: string; arc_state: number; pgc_label: string; show_up: boolean; forbid_fav: boolean; forbid_sort: boolean; season_title: string; long_title: string; index_title: string; c_source: string; season_id: number | null }
|
export type MediaInWatchLater = { aid: number; videos: number; tid: number; tname: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; state: number; duration: number; redirect_url: string | null; mission_id: number | null; rights: RightsInWatchLater; owner: OwnerInWatchLater; stat: StatInWatchLater; dynamic: string; dimension: DimensionInWatchLater; short_link_v2: string; up_from_v2: number | null; first_frame: string | null; pub_location: string | null; cover43: string; tidv2: number; tnamev2: string; pid_v2: number; pid_name_v2: string; page: PageInWatchLater; count: number; cid: number; progress: number; add_at: number; bvid: string; uri: string; enable_vt: number; view_text_1: string; card_type: number; left_icon_type: number; left_text: string; right_icon_type: number; right_text: string; arc_state: number; pgc_label: string; show_up: boolean; forbid_fav: boolean; forbid_sort: boolean; season_title: string; long_title: string; index_title: string; c_source: string; season_id: number | null }
|
||||||
export type MergeTask = { selected: boolean; completed: boolean }
|
|
||||||
export type MetaInUserVideo = { id: number; title: string; cover: string; mid: number; intro: string; sign_state: number; attribute: number; stat: StatInUserVideo; ep_count: number; first_aid: number | null; ptime: number; ep_num: number }
|
export type MetaInUserVideo = { id: number; title: string; cover: string; mid: number; intro: string; sign_state: number; attribute: number; stat: StatInUserVideo; ep_count: number; first_aid: number | null; ptime: number; ep_num: number }
|
||||||
export type MultiImg = { color: string; medium_remind: string }
|
export type MultiImg = { color: string; medium_remind: string }
|
||||||
export type NewEp = { desc: string; id: number; is_new: number; title: string }
|
export type NewEp = { desc: string; id: number; is_new: number; title: string }
|
||||||
@@ -394,6 +391,7 @@ export type UserStatusInCheese = { bp: number; expire_at: number; favored: numbe
|
|||||||
export type UserVideoInfo = { list: UserVideoList; page: PageInUserVideo }
|
export type UserVideoInfo = { list: UserVideoList; page: PageInUserVideo }
|
||||||
export type UserVideoList = { vlist: EpInUserVideo[] }
|
export type UserVideoList = { vlist: EpInUserVideo[] }
|
||||||
export type UserVideoSearchResult = UserVideoInfo
|
export type UserVideoSearchResult = UserVideoInfo
|
||||||
|
export type VideoProcessTask = { merge_selected: boolean; embed_chapter_selected: boolean; embed_skip_selected: boolean; completed: boolean }
|
||||||
export type VideoQuality = "Unknown" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K"
|
export type VideoQuality = "Unknown" | "240P" | "360P" | "480P" | "720P" | "720P60" | "1080P" | "AiRepair" | "1080P+" | "1080P60" | "4K" | "HDR" | "Dolby" | "8K"
|
||||||
export type VideoTask = { selected: boolean; url: string; video_quality: VideoQuality; codec_type: CodecType; content_length: number; chunks: MediaChunk[]; completed: boolean }
|
export type VideoTask = { selected: boolean; url: string; video_quality: VideoQuality; codec_type: CodecType; content_length: number; chunks: MediaChunk[]; completed: boolean }
|
||||||
export type VipInUserInfo = { type: number; status: number; due_date: number; vip_pay_type: number; theme_type: number; label: LabelInUserInfo; avatar_subscript: number; nickname_color: string; role: number; avatar_subscript_url: string; tv_vip_status: number; tv_vip_pay_type: number; tv_due_date: number }
|
export type VipInUserInfo = { type: number; status: number; due_date: number; vip_pay_type: number; theme_type: number; label: LabelInUserInfo; avatar_subscript: number; nickname_color: string; role: number; avatar_subscript_url: string; tv_vip_status: number; tv_vip_pay_type: number; tv_due_date: number }
|
||||||
|
|||||||
@@ -96,9 +96,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const videoTask = progressData.video_task
|
const videoTask = progressData.video_task
|
||||||
const audioTask = progressData.audio_task
|
const audioTask = progressData.audio_task
|
||||||
const mergeTask = progressData.merge_task
|
const videoProcessTask = progressData.video_process_task
|
||||||
const embedChapterTask = progressData.embed_chapter_task
|
|
||||||
const embedSkipTask = progressData.embed_skip_task
|
|
||||||
const danmakuTask = progressData.danmaku_task
|
const danmakuTask = progressData.danmaku_task
|
||||||
const subtitleTask = progressData.subtitle_task
|
const subtitleTask = progressData.subtitle_task
|
||||||
const coverTask = progressData.cover_task
|
const coverTask = progressData.cover_task
|
||||||
@@ -106,9 +104,6 @@ onMounted(async () => {
|
|||||||
const jsonTask = progressData.json_task
|
const jsonTask = progressData.json_task
|
||||||
|
|
||||||
const danmakuSelected = danmakuTask.xml_selected || danmakuTask.ass_selected || danmakuTask.json_selected
|
const danmakuSelected = danmakuTask.xml_selected || danmakuTask.ass_selected || danmakuTask.json_selected
|
||||||
const embedding =
|
|
||||||
(embedChapterTask.selected && !embedChapterTask.completed) ||
|
|
||||||
(embedSkipTask.selected && !embedSkipTask.completed)
|
|
||||||
|
|
||||||
if (videoTask.selected && !videoTask.completed && videoTask.content_length > 0) {
|
if (videoTask.selected && !videoTask.completed && videoTask.content_length > 0) {
|
||||||
const chunkCount = progressData.video_task.chunks.length
|
const chunkCount = progressData.video_task.chunks.length
|
||||||
@@ -120,15 +115,18 @@ onMounted(async () => {
|
|||||||
const completedChunks = progressData.audio_task.chunks.filter((chunk) => chunk.completed).length
|
const completedChunks = progressData.audio_task.chunks.filter((chunk) => chunk.completed).length
|
||||||
progressData.percentage = (completedChunks / chunkCount) * 100
|
progressData.percentage = (completedChunks / chunkCount) * 100
|
||||||
progressData.taskIndicator = `音频分片 ${completedChunks}/${chunkCount}`
|
progressData.taskIndicator = `音频分片 ${completedChunks}/${chunkCount}`
|
||||||
} else if (mergeTask.selected && !mergeTask.completed && embedding) {
|
} else if (!videoProcessTask.completed) {
|
||||||
progressData.percentage = 100
|
const embedSelected = videoProcessTask.embed_chapter_selected || videoProcessTask.embed_skip_selected
|
||||||
progressData.taskIndicator = '自动合并+嵌入章节元数据'
|
if (videoProcessTask.merge_selected && embedSelected) {
|
||||||
} else if (mergeTask.selected && !mergeTask.completed) {
|
progressData.percentage = 100
|
||||||
progressData.percentage = 100
|
progressData.taskIndicator = '自动合并+嵌入章节元数据'
|
||||||
progressData.taskIndicator = '自动合并'
|
} else if (videoProcessTask.merge_selected) {
|
||||||
} else if (embedding) {
|
progressData.percentage = 100
|
||||||
progressData.percentage = 100
|
progressData.taskIndicator = '自动合并'
|
||||||
progressData.taskIndicator = '嵌入章节元数据'
|
} else if (embedSelected) {
|
||||||
|
progressData.percentage = 100
|
||||||
|
progressData.taskIndicator = '嵌入章节元数据'
|
||||||
|
}
|
||||||
} else if (danmakuSelected && !danmakuTask.completed) {
|
} else if (danmakuSelected && !danmakuTask.completed) {
|
||||||
progressData.percentage = 100
|
progressData.percentage = 100
|
||||||
progressData.taskIndicator = '弹幕'
|
progressData.taskIndicator = '弹幕'
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ function handleSearchClick() {
|
|||||||
音频(音质:{{ p.audio_task.audio_quality }})
|
音频(音质:{{ p.audio_task.audio_quality }})
|
||||||
</ColorfulTag>
|
</ColorfulTag>
|
||||||
|
|
||||||
<ColorfulTag v-if="p.merge_task.selected" color="rose">自动合并</ColorfulTag>
|
<ColorfulTag v-if="p.video_process_task.merge_selected" color="rose">自动合并</ColorfulTag>
|
||||||
<ColorfulTag v-if="p.embed_chapter_task.selected" color="rose">标记章节</ColorfulTag>
|
<ColorfulTag v-if="p.video_process_task.embed_chapter_selected" color="rose">标记章节</ColorfulTag>
|
||||||
<ColorfulTag v-if="p.embed_skip_task.selected" color="rose">标记广告</ColorfulTag>
|
<ColorfulTag v-if="p.video_process_task.embed_skip_selected" color="rose">标记广告</ColorfulTag>
|
||||||
|
|
||||||
<ColorfulTag v-if="p.danmaku_task.xml_selected" color="purple">xml弹幕</ColorfulTag>
|
<ColorfulTag v-if="p.danmaku_task.xml_selected" color="purple">xml弹幕</ColorfulTag>
|
||||||
<ColorfulTag v-if="p.danmaku_task.ass_selected" color="purple">ass弹幕</ColorfulTag>
|
<ColorfulTag v-if="p.danmaku_task.ass_selected" color="purple">ass弹幕</ColorfulTag>
|
||||||
|
|||||||
Reference in New Issue
Block a user