mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-09 01:27:27 +08:00
feat: 后端支持弹幕下载
This commit is contained in:
Generated
+93
@@ -271,10 +271,13 @@ dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"float-ord",
|
||||
"fs4",
|
||||
"memchr",
|
||||
"notify",
|
||||
"num_enum",
|
||||
"parking_lot 0.12.4",
|
||||
"prost",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"reqwest-retry",
|
||||
@@ -292,6 +295,7 @@ dependencies = [
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"yaserde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -867,6 +871,12 @@ version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.4"
|
||||
@@ -996,6 +1006,12 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-ord"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1897,6 +1913,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -3065,6 +3090,29 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.0"
|
||||
@@ -3587,6 +3635,18 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_tokenstream"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -5594,6 +5654,39 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
|
||||
|
||||
[[package]]
|
||||
name = "yaserde"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bfa0d2b420fd005aa9b6f99f9584ebd964e6865d7ca787304cc1a3366c39231"
|
||||
dependencies = [
|
||||
"log",
|
||||
"xml-rs",
|
||||
"yaserde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaserde_derive"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f785831c0e09e0f1a83f917054fd59c088f6561db5b2a42c1c3e1687329325f"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_tokenstream",
|
||||
"syn 2.0.104",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.0"
|
||||
|
||||
@@ -45,6 +45,10 @@ bytes = { version = "1.10.1" }
|
||||
fs4 = { version = "0.13.1" }
|
||||
num_enum = { version = "0.7.4" }
|
||||
chrono = { version = "0.4.41" }
|
||||
prost = { version = "0.14.1" }
|
||||
yaserde = { version = "0.12.0", features = ["yaserde_derive"] }
|
||||
float-ord = { version = "0.3.2" }
|
||||
memchr = { version = "2.7.5" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::time::Duration;
|
||||
use anyhow::{anyhow, Context};
|
||||
use bytes::Bytes;
|
||||
use parking_lot::RwLock;
|
||||
use prost::Message;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use reqwest_retry::{policies::ExponentialBackoff, Jitter, RetryTransientMiddleware};
|
||||
@@ -16,6 +17,7 @@ use tokio::task::JoinSet;
|
||||
|
||||
use crate::{
|
||||
extensions::AppHandleExt,
|
||||
protobuf::DmSegMobileReply,
|
||||
types::{
|
||||
bangumi_info::BangumiInfo, bangumi_media_url::BangumiMediaUrl, cheese_info::CheeseInfo,
|
||||
cheese_media_url::CheeseMediaUrl, fav_folders::FavFolders, fav_info::FavInfo,
|
||||
@@ -617,6 +619,57 @@ impl BiliClient {
|
||||
url_with_content_length
|
||||
}
|
||||
|
||||
pub async fn get_danmaku(
|
||||
&self,
|
||||
aid: i64,
|
||||
cid: i64,
|
||||
duration: u64,
|
||||
) -> anyhow::Result<Vec<DmSegMobileReply>> {
|
||||
let client = self.api_client.read().clone();
|
||||
// 以6分钟为单位分段
|
||||
let segment_count = duration.div_ceil(360);
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
for segment_index in 1..=segment_count {
|
||||
let client = client.clone();
|
||||
let cookie = self.get_cookie();
|
||||
|
||||
join_set.spawn(async move {
|
||||
// 发送获取分段弹幕的请求
|
||||
let params = json!({
|
||||
"type": 1,
|
||||
"oid": cid,
|
||||
"pid": aid,
|
||||
"segment_index": segment_index,
|
||||
});
|
||||
let http_resp = client
|
||||
.get("https://api.bilibili.com/x/v2/dm/web/seg.so")
|
||||
.query(¶ms)
|
||||
.header("cookie", cookie)
|
||||
.send()
|
||||
.await?;
|
||||
let status = http_resp.status();
|
||||
if status != StatusCode::OK {
|
||||
let body = http_resp.text().await?;
|
||||
return Err(anyhow!("预料之外的状态码({status}): {body}"));
|
||||
}
|
||||
let body = http_resp.bytes().await?;
|
||||
let reply =
|
||||
DmSegMobileReply::decode(body).context("将body解析为DmSegMobileReply失败")?;
|
||||
|
||||
Ok(reply)
|
||||
});
|
||||
}
|
||||
|
||||
let mut replies = Vec::new();
|
||||
while let Some(Ok(res)) = join_set.join_next().await {
|
||||
let reply = res?;
|
||||
replies.push(reply);
|
||||
}
|
||||
|
||||
Ok(replies)
|
||||
}
|
||||
|
||||
fn get_cookie(&self) -> String {
|
||||
let sessdata = self.app.get_config().read().sessdata.clone();
|
||||
format!("SESSDATA={sessdata}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -5,8 +5,10 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::danmaku_xml_to_ass::canvas::CanvasConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct Config {
|
||||
pub download_dir: PathBuf,
|
||||
pub enable_file_logger: bool,
|
||||
@@ -17,6 +19,9 @@ pub struct Config {
|
||||
pub download_video: bool,
|
||||
pub download_audio: bool,
|
||||
pub auto_merge: bool,
|
||||
pub download_xml_danmaku: bool,
|
||||
pub download_ass_danmaku: bool,
|
||||
pub download_json_danmaku: bool,
|
||||
pub dir_fmt: String,
|
||||
pub dir_fmt_for_part: String,
|
||||
pub time_fmt: String,
|
||||
@@ -24,6 +29,7 @@ pub struct Config {
|
||||
pub task_download_interval_sec: u64,
|
||||
pub chunk_concurrency: usize,
|
||||
pub chunk_download_interval_sec: u64,
|
||||
pub danmaku_config: CanvasConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -90,6 +96,9 @@ impl Config {
|
||||
download_video: true,
|
||||
download_audio: true,
|
||||
auto_merge: true,
|
||||
download_xml_danmaku: true,
|
||||
download_ass_danmaku: true,
|
||||
download_json_danmaku: true,
|
||||
dir_fmt: "{collection_title}/{episode_title}".to_string(),
|
||||
dir_fmt_for_part: DEFAULT_FMT_FOR_PART.to_string(),
|
||||
time_fmt: "%Y-%m-%d_%H-%M-%S".to_string(),
|
||||
@@ -97,6 +106,7 @@ impl Config {
|
||||
task_download_interval_sec: 0,
|
||||
chunk_concurrency: 16,
|
||||
chunk_download_interval_sec: 0,
|
||||
danmaku_config: CanvasConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
use anyhow::Result;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::io::{BufWriter, Write};
|
||||
|
||||
use super::canvas::CanvasConfig;
|
||||
use super::drawable::{DrawEffect, Drawable};
|
||||
|
||||
struct TimePoint {
|
||||
t: f64,
|
||||
}
|
||||
impl fmt::Display for TimePoint {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
#[allow(clippy::cast_lossless)]
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let secs = self.t.floor() as u32;
|
||||
let hour = secs / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
|
||||
let left = self.t - (hour * 3600) as f64 - (minutes * 60) as f64;
|
||||
|
||||
write!(f, "{hour}:{minutes:02}:{left:05.2}")
|
||||
}
|
||||
}
|
||||
|
||||
struct AssEffect {
|
||||
effect: DrawEffect,
|
||||
}
|
||||
impl fmt::Display for AssEffect {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self.effect {
|
||||
DrawEffect::Move { start, end } => {
|
||||
let (x0, y0) = start;
|
||||
let (x1, y1) = end;
|
||||
write!(f, "\\move({x0}, {y0}, {x1}, {y1})")
|
||||
}
|
||||
DrawEffect::Fixed {} => fmt::Result::Err(fmt::Error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasConfig {
|
||||
#[allow(clippy::cast_lossless)]
|
||||
pub fn ass_styles(&self) -> Vec<String> {
|
||||
let opacity = self.get_opacity();
|
||||
vec![
|
||||
// Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, \
|
||||
// Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, \
|
||||
// Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
format!(
|
||||
"Style: Float,{font},{font_size},&H{a:02x}FFFFFF,&H00FFFFFF,&H{a:02x}000000,&H00000000,\
|
||||
{bold}, 0, 0, 0, 100, 100, 0.00, 0.00, 1, \
|
||||
{outline}, 0, 7, 0, 0, 0, 1",
|
||||
a = opacity,
|
||||
font = self.font,
|
||||
font_size = self.font_size,
|
||||
bold = self.bold as u8,
|
||||
outline = self.outline,
|
||||
),
|
||||
format!(
|
||||
"Style: Bottom,{font},{font_size},&H{a:02x}FFFFFF,&H00FFFFFF,&H{a:02x}000000,&H00000000,\
|
||||
{bold}, 0, 0, 0, 100, 100, 0.00, 0.00, 1, \
|
||||
{outline}, 0, 7, 0, 0, 0, 1",
|
||||
a = opacity,
|
||||
font = self.font,
|
||||
font_size = self.font_size,
|
||||
bold = self.bold as u8,
|
||||
outline = self.outline,
|
||||
),
|
||||
format!(
|
||||
"Style: Top,{font},{font_size},&H{a:02x}FFFFFF,&H00FFFFFF,&H{a:02x}000000,&H00000000,\
|
||||
{bold}, 0, 0, 0, 100, 100, 0.00, 0.00, 1, \
|
||||
{outline}, 0, 7, 0, 0, 0, 1",
|
||||
a = opacity,
|
||||
font = self.font,
|
||||
font_size = self.font_size,
|
||||
bold = self.bold as u8,
|
||||
outline = self.outline,
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct CanvasStyles(Vec<String>);
|
||||
impl fmt::Display for CanvasStyles {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
for style in &self.0 {
|
||||
writeln!(f, "{style}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AssWriter<W: Write> {
|
||||
f: BufWriter<W>,
|
||||
title: String,
|
||||
canvas_config: CanvasConfig,
|
||||
}
|
||||
|
||||
impl<W: Write> AssWriter<W> {
|
||||
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Result<Self> {
|
||||
let mut this = AssWriter {
|
||||
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
|
||||
f: BufWriter::with_capacity(10 << 20, f),
|
||||
title,
|
||||
canvas_config,
|
||||
};
|
||||
|
||||
this.init()?;
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
write!(
|
||||
self.f,
|
||||
"\
|
||||
[Script Info]\n\
|
||||
; Script generated by bilibili-video-downloader (https://github.com/lanyeeee/bilibili-video-downloader)\n\
|
||||
Title: {title}\n\
|
||||
Script Updated By: bilibili-video-downloader (https://github.com/lanyeeee/bilibili-video-downloader)\n\
|
||||
ScriptType: v4.00+\n\
|
||||
PlayResX: {width}\n\
|
||||
PlayResY: {height}\n\
|
||||
Aspect Ratio: {width}:{height}\n\
|
||||
Collisions: Normal\n\
|
||||
WrapStyle: 2\n\
|
||||
ScaledBorderAndShadow: yes\n\
|
||||
YCbCr Matrix: TV.601\n\
|
||||
\n\
|
||||
\n\
|
||||
[V4+ Styles]\n\
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, \
|
||||
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, \
|
||||
Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n\
|
||||
{styles}\
|
||||
\n\
|
||||
[Events]\n\
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
|
||||
",
|
||||
title = self.title,
|
||||
width = self.canvas_config.width,
|
||||
height = self.canvas_config.height,
|
||||
styles = CanvasStyles(self.canvas_config.ass_styles()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&mut self, drawable: Drawable) -> Result<()> {
|
||||
writeln!(
|
||||
self.f,
|
||||
// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
"Dialogue: 2,{start},{end},{style},,0,0,0,,{{{effect}\\c&H{b:02x}{g:02x}{r:02x}&}}{text}",
|
||||
start = TimePoint {
|
||||
t: drawable.danmaku.timeline_s
|
||||
},
|
||||
end = TimePoint {
|
||||
t: drawable.danmaku.timeline_s + drawable.duration
|
||||
},
|
||||
style = drawable.style_name,
|
||||
effect = AssEffect {
|
||||
effect: drawable.effect
|
||||
},
|
||||
b = drawable.danmaku.rgb.2,
|
||||
g = drawable.danmaku.rgb.1,
|
||||
r = drawable.danmaku.rgb.0,
|
||||
text = escape_text(&drawable.danmaku.content),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_text(text: &str) -> Cow<str> {
|
||||
let text = text.trim();
|
||||
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
|
||||
Cow::from(text.replace('\n', "\\N"))
|
||||
} else {
|
||||
Cow::from(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use crate::danmaku_xml_to_ass::danmaku::Danmaku;
|
||||
|
||||
use super::CanvasConfig;
|
||||
|
||||
pub enum Collision {
|
||||
// 会越来越远
|
||||
#[allow(dead_code)]
|
||||
Separate {
|
||||
closest_dis: f64,
|
||||
},
|
||||
// 时间够可以追上,但是时间不够
|
||||
#[allow(dead_code)]
|
||||
NotEnoughTime {
|
||||
closest_dis: f64,
|
||||
},
|
||||
// 需要额外的时间才可以避免碰撞
|
||||
Collide {
|
||||
time_needed: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// 表示一个弹幕槽位
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Lane {
|
||||
last_shoot_time: f64,
|
||||
last_length: f64,
|
||||
}
|
||||
|
||||
impl Lane {
|
||||
pub fn draw(danmaku: &Danmaku, config: &CanvasConfig) -> Self {
|
||||
Lane {
|
||||
last_shoot_time: danmaku.timeline_s,
|
||||
last_length: danmaku.length(config),
|
||||
}
|
||||
}
|
||||
/// 如底部弹幕等不需要记录长度的
|
||||
#[allow(dead_code)]
|
||||
pub fn draw_fixed(danmaku: &Danmaku) -> Self {
|
||||
Lane {
|
||||
last_shoot_time: danmaku.timeline_s,
|
||||
last_length: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个槽位是否可以发射另外一条弹幕,返回可能的情形
|
||||
#[allow(clippy::cast_lossless)]
|
||||
pub fn available_for(&self, other: &Danmaku, config: &super::CanvasConfig) -> Collision {
|
||||
#[allow(non_snake_case)]
|
||||
let T = config.duration;
|
||||
#[allow(non_snake_case)]
|
||||
let W = config.width as f64;
|
||||
let gap = config.horizontal_gap;
|
||||
|
||||
// 先计算我的速度
|
||||
let t1 = self.last_shoot_time;
|
||||
let t2 = other.timeline_s;
|
||||
let l1 = self.last_length;
|
||||
let l2 = other.length(config);
|
||||
|
||||
let v1 = (W + l1) / T;
|
||||
let v2 = (W + l2) / T;
|
||||
|
||||
let delta_t = t2 - t1;
|
||||
// 第一条弹幕右边到屏幕右边的距离
|
||||
let delta_x = v1 * delta_t - l1;
|
||||
// 没有足够的空间,必定碰撞
|
||||
if delta_x < gap {
|
||||
if l2 <= l1 {
|
||||
// l2 比 l1 短,因此比它慢
|
||||
// 只需要把 l2 安排在 l1 之后就可以避免碰撞
|
||||
Collision::Collide {
|
||||
time_needed: (gap - delta_x) / v1,
|
||||
}
|
||||
} else {
|
||||
// 需要延长额外的时间,使得当第一条消失的时候,第二条也有足够的距离
|
||||
// 第一条消失的时间点是 (t1 + T)
|
||||
// 这个时候第二条的左侧应该在距离出发点 W - gap 处,
|
||||
// 第二条已经出发 (W - gap) / v2 时间,因此在 t1 + T - (W - gap) / v2 出发
|
||||
// 所需要的额外时间就 - t2
|
||||
// let time_needed = (t1 + T - (W - gap) / v2) - t2;
|
||||
let time_needed = (T - (W - gap) / v2) - delta_t;
|
||||
Collision::Collide { time_needed }
|
||||
}
|
||||
} else {
|
||||
// 第一条已经发射
|
||||
if l2 <= l1 {
|
||||
// 如果 l2 < l1,则它永远追不上前者,可以发射
|
||||
Collision::Separate {
|
||||
closest_dis: delta_x - gap,
|
||||
}
|
||||
} else {
|
||||
// 需要算追击问题了,
|
||||
// l1 已经消失,但是 l2 可能追上,我们计算 l1 刚好消失的时候:
|
||||
// 此刻是 t1 + T
|
||||
// l2 的头部应该在距离起点 v2 * (t1 + T - t2) 处
|
||||
let pos = v2 * (T - delta_t);
|
||||
if pos < (W - gap) {
|
||||
Collision::NotEnoughTime {
|
||||
closest_dis: (W - gap) - pos,
|
||||
}
|
||||
} else {
|
||||
// 需要额外的时间
|
||||
Collision::Collide {
|
||||
time_needed: (pos - (W - gap)) / v2,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 决定绘画策略
|
||||
|
||||
use float_ord::FloatOrd;
|
||||
use lane::{Collision, Lane};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::{
|
||||
danmaku::Danmaku,
|
||||
drawable::{DrawEffect, Drawable},
|
||||
};
|
||||
mod lane;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct CanvasConfig {
|
||||
/// 弹幕在屏幕上的【持续时间】,单位为秒,可以有小数
|
||||
pub duration: f64,
|
||||
/// 渲染的屏幕分辨率,这个并不会影响渲染区域的大小,只是字体的相对大小,可以不用改动
|
||||
pub width: u32,
|
||||
/// 渲染的屏幕分辨率,这个并不会影响渲染区域的大小,只是字体的相对大小,可以不用改动
|
||||
pub height: u32,
|
||||
/// 使用字体名称
|
||||
pub font: String,
|
||||
/// 弹幕字体大小
|
||||
pub font_size: u32,
|
||||
/// 是一个比例数,用来计算平衡不同字体的宽度
|
||||
/// 有的字体比较粗、比较宽,可以适当调大(如 1.4、1.6)
|
||||
/// 有的字体比较细、比较窄,可以适当调小(如 1.0、1.2)
|
||||
pub width_ratio: f64,
|
||||
/// 用来调整弹幕时间的水平距离,单位是像素,如果想拉开弹幕之间的距离,可以调大
|
||||
pub horizontal_gap: f64,
|
||||
/// 计算弹幕高度的数值,即【行高度/行间距】。数值越大,弹幕的垂直距离越大
|
||||
pub lane_size: u32,
|
||||
/// 【正常弹幕的屏幕填充占比】,默认为 50%,即“半屏填充”。
|
||||
pub float_percentage: f64,
|
||||
/// 屏幕上底部弹幕最多高度百分比
|
||||
#[serde(skip)]
|
||||
pub bottom_percentage: f64,
|
||||
/// 弹幕的不透明度,越小越透明,越大越不透明
|
||||
pub alpha: f64,
|
||||
/// 字体是否加粗
|
||||
pub bold: bool,
|
||||
/// 弹幕的描边宽度,单位为像素
|
||||
pub outline: f64,
|
||||
/// 弹幕时间轴偏移,>0 会让弹幕延后,<0 会让弹幕提前,单位为秒
|
||||
pub time_offset: f64,
|
||||
}
|
||||
|
||||
impl Default for CanvasConfig {
|
||||
fn default() -> Self {
|
||||
CanvasConfig {
|
||||
duration: 15.0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
font: "黑体".to_string(),
|
||||
font_size: 25,
|
||||
width_ratio: 1.2,
|
||||
horizontal_gap: 20.0,
|
||||
lane_size: 32,
|
||||
float_percentage: 0.5,
|
||||
bottom_percentage: 0.3,
|
||||
alpha: 0.7,
|
||||
bold: false,
|
||||
outline: 0.8,
|
||||
time_offset: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasConfig {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
#[allow(clippy::cast_lossless)]
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
pub fn canvas(self) -> Canvas {
|
||||
let float_lanes_cnt =
|
||||
(self.float_percentage * self.height as f64 / self.lane_size as f64) as usize;
|
||||
let bottom_lanes_cnt =
|
||||
(self.bottom_percentage * self.height as f64 / self.lane_size as f64) as usize;
|
||||
|
||||
Canvas {
|
||||
config: self,
|
||||
float_lanes: vec![None; float_lanes_cnt],
|
||||
bottom_lanes: vec![None; bottom_lanes_cnt],
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
pub fn get_opacity(&self) -> u8 {
|
||||
255 - (self.alpha * 255.) as u8
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Canvas {
|
||||
pub config: CanvasConfig,
|
||||
pub float_lanes: Vec<Option<Lane>>,
|
||||
#[allow(dead_code)]
|
||||
pub bottom_lanes: Vec<Option<Lane>>,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
pub fn draw(&mut self, mut danmaku: Danmaku) -> Option<Drawable> {
|
||||
use super::danmaku::DanmakuType::{Bottom, Float, Reverse, Top};
|
||||
danmaku.timeline_s += self.config.time_offset;
|
||||
if danmaku.timeline_s < 0.0 {
|
||||
return None;
|
||||
}
|
||||
match danmaku.r#type {
|
||||
Float => self.draw_float(danmaku),
|
||||
Bottom | Top | Reverse => {
|
||||
// 不喜欢底部弹幕,直接转成 Float
|
||||
// 这是 feature 不是 bug
|
||||
danmaku.r#type = Float;
|
||||
self.draw_float(danmaku)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_float(&mut self, mut danmaku: Danmaku) -> Option<Drawable> {
|
||||
let mut collisions = Vec::with_capacity(self.float_lanes.len());
|
||||
for (idx, lane) in self.float_lanes.iter_mut().enumerate() {
|
||||
match lane {
|
||||
// 优先画不存在的槽位
|
||||
None => {
|
||||
return Some(self.draw_float_in_lane(danmaku, idx));
|
||||
}
|
||||
Some(l) => {
|
||||
let col = l.available_for(&danmaku, &self.config);
|
||||
match col {
|
||||
Collision::Separate { .. } | Collision::NotEnoughTime { .. } => {
|
||||
return Some(self.draw_float_in_lane(danmaku, idx));
|
||||
}
|
||||
Collision::Collide { time_needed } => {
|
||||
collisions.push((FloatOrd(time_needed), idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 允许部分弹幕在延迟后填充
|
||||
if !collisions.is_empty() {
|
||||
collisions.sort_unstable();
|
||||
let (FloatOrd(time_need), lane_idx) = collisions[0];
|
||||
if time_need < 1.0 {
|
||||
// 只允许延迟 1s
|
||||
danmaku.timeline_s += time_need + 0.01; // 间隔也不要太小了
|
||||
return Some(self.draw_float_in_lane(danmaku, lane_idx));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
fn draw_float_in_lane(&mut self, danmaku: Danmaku, lane_idx: usize) -> Drawable {
|
||||
self.float_lanes[lane_idx] = Some(Lane::draw(&danmaku, &self.config));
|
||||
let y = lane_idx as i32 * self.config.lane_size as i32;
|
||||
let l = danmaku.length(&self.config);
|
||||
Drawable::new(
|
||||
danmaku,
|
||||
self.config.duration,
|
||||
"Float",
|
||||
DrawEffect::Move {
|
||||
start: (self.config.width as i32, y),
|
||||
end: (-(l as i32), y),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 一个弹幕实例,但是没有位置信息
|
||||
|
||||
use super::canvas::CanvasConfig;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DanmakuType {
|
||||
#[default]
|
||||
Float,
|
||||
Top,
|
||||
Bottom,
|
||||
Reverse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Danmaku {
|
||||
pub timeline_s: f64,
|
||||
pub content: String,
|
||||
pub r#type: DanmakuType,
|
||||
/// 虽然这里有 fontsize,但是我们实际上使用 canvas config 的 font size,
|
||||
/// 否在在调节分辨率的时候字体会发生变化。
|
||||
pub fontsize: u32,
|
||||
pub rgb: (u8, u8, u8),
|
||||
}
|
||||
|
||||
impl Danmaku {
|
||||
/// 计算弹幕的“像素长度”,会乘上一个缩放因子
|
||||
///
|
||||
/// 汉字算一个全宽,英文算2/3宽
|
||||
#[allow(clippy::cast_lossless)]
|
||||
pub fn length(&self, config: &CanvasConfig) -> f64 {
|
||||
let pts = config.font_size
|
||||
* self
|
||||
.content
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii() { 2 } else { 3 })
|
||||
.sum::<u32>()
|
||||
/ 3;
|
||||
|
||||
pts as f64 * config.width_ratio
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 可以绘制的实体
|
||||
|
||||
use super::danmaku::Danmaku;
|
||||
|
||||
/// 弹幕开始绘制的时间就是 danmaku 的时间
|
||||
pub struct Drawable {
|
||||
pub danmaku: Danmaku,
|
||||
/// 弹幕一共绘制的时间
|
||||
pub duration: f64,
|
||||
/// 弹幕的绘制 style
|
||||
pub style_name: &'static str,
|
||||
/// 绘制的“特效”
|
||||
pub effect: DrawEffect,
|
||||
}
|
||||
impl Drawable {
|
||||
pub fn new(
|
||||
danmaku: Danmaku,
|
||||
duration: f64,
|
||||
style_name: &'static str,
|
||||
effect: DrawEffect,
|
||||
) -> Self {
|
||||
Drawable {
|
||||
danmaku,
|
||||
duration,
|
||||
style_name,
|
||||
effect,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DrawEffect {
|
||||
Move {
|
||||
start: (i32, i32),
|
||||
end: (i32, i32),
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
Fixed {},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
pub mod ass_writer;
|
||||
pub mod canvas;
|
||||
pub mod danmaku;
|
||||
pub mod drawable;
|
||||
|
||||
use std::{cmp::Ordering, fs::File};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use ass_writer::AssWriter;
|
||||
use canvas::CanvasConfig;
|
||||
use danmaku::{Danmaku, DanmakuType};
|
||||
use yaserde::{YaDeserialize, YaSerialize};
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
#[yaserde(rename = "d")]
|
||||
pub struct DamakuXmlDTag {
|
||||
#[yaserde(attribute = true)]
|
||||
pub p: String,
|
||||
#[yaserde(text = true)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
#[yaserde(rename = "i")]
|
||||
pub struct DanmakuXmlITag {
|
||||
pub chatid: i64,
|
||||
#[yaserde(rename = "d")]
|
||||
pub elems: Vec<DamakuXmlDTag>,
|
||||
}
|
||||
|
||||
pub fn xml_to_ass(
|
||||
xml: &str,
|
||||
ass_file: File,
|
||||
title: String,
|
||||
config: CanvasConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut writer = AssWriter::new(ass_file, title, config.clone())?;
|
||||
let mut canvas = config.canvas();
|
||||
|
||||
let mut danmakus: Vec<Danmaku> = xml_to_danmakus(xml)?;
|
||||
danmakus.sort_by(|a, b| {
|
||||
a.timeline_s
|
||||
.partial_cmp(&b.timeline_s)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
});
|
||||
|
||||
for danmaku in danmakus {
|
||||
if let Some(drawable) = canvas.draw(danmaku) {
|
||||
writer.write(drawable)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
trait ToDanmakuType {
|
||||
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType>;
|
||||
}
|
||||
|
||||
impl ToDanmakuType for u32 {
|
||||
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType> {
|
||||
match self {
|
||||
1 => Ok(DanmakuType::Float),
|
||||
4 => Ok(DanmakuType::Bottom),
|
||||
5 => Ok(DanmakuType::Top),
|
||||
6 => Ok(DanmakuType::Reverse),
|
||||
_ => Err(anyhow!("未知的弹幕类型:{self}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
|
||||
let xml = sanitize_xml(xml);
|
||||
let i_tag: DanmakuXmlITag = yaserde::de::from_str(&xml).map_err(|e| anyhow!(e))?;
|
||||
|
||||
let mut danmakus = Vec::new();
|
||||
|
||||
for elem in i_tag.elems {
|
||||
let Some(content) = elem.body else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut p_attr = elem.p.split(',');
|
||||
|
||||
let Some(timeline_s) = p_attr.next().and_then(|s| s.parse::<f64>().ok()) else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有时间"));
|
||||
};
|
||||
|
||||
let Some(r#type) = p_attr
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.and_then(|num| num.to_danmaku_type().ok())
|
||||
else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有弹幕类型"));
|
||||
};
|
||||
|
||||
let Some(fontsize) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有字体大小"));
|
||||
};
|
||||
|
||||
let Some(rgb) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Err(anyhow!("弹幕`{content}`的p属性中没有颜色"));
|
||||
};
|
||||
|
||||
// rgb 是个数字,类似 0x010203
|
||||
let r = (rgb >> 16) & 0xff;
|
||||
let g = (rgb >> 8) & 0xff;
|
||||
let b = rgb & 0xff;
|
||||
|
||||
let danmaku = Danmaku {
|
||||
timeline_s,
|
||||
content,
|
||||
r#type,
|
||||
fontsize,
|
||||
rgb: (r as u8, g as u8, b as u8),
|
||||
};
|
||||
|
||||
danmakus.push(danmaku);
|
||||
}
|
||||
|
||||
Ok(danmakus)
|
||||
}
|
||||
|
||||
fn sanitize_xml(s: &str) -> String {
|
||||
fn is_valid_xml_char(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{0009}' |
|
||||
'\u{000A}' |
|
||||
'\u{000D}' |
|
||||
'\u{0020}'..='\u{D7FF}' |
|
||||
'\u{E000}'..='\u{FFFD}' |
|
||||
'\u{10000}'..='\u{10FFFF}'
|
||||
)
|
||||
}
|
||||
s.chars().filter(|&c| is_valid_xml_char(c)).collect()
|
||||
}
|
||||
@@ -11,7 +11,10 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
downloader::tasks::{audio_task::AudioTask, merge_task::MergeTask, video_task::VideoTask},
|
||||
downloader::tasks::{
|
||||
audio_task::AudioTask, danmaku_task::DanmakuTask, merge_task::MergeTask,
|
||||
video_task::VideoTask,
|
||||
},
|
||||
extensions::AppHandleExt,
|
||||
types::{
|
||||
audio_quality::AudioQuality,
|
||||
@@ -49,6 +52,7 @@ pub struct DownloadProgress {
|
||||
pub video_task: VideoTask,
|
||||
pub audio_task: AudioTask,
|
||||
pub merge_task: MergeTask,
|
||||
pub danmaku_task: DanmakuTask,
|
||||
pub create_ts: u64,
|
||||
pub completed_ts: Option<u64>,
|
||||
}
|
||||
@@ -116,6 +120,7 @@ impl DownloadProgress {
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
danmaku_task: tasks.danmaku,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -162,6 +167,7 @@ impl DownloadProgress {
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
danmaku_task: tasks.danmaku,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -297,12 +303,14 @@ impl DownloadProgress {
|
||||
self.video_task.is_completed()
|
||||
&& self.audio_task.is_completed()
|
||||
&& self.merge_task.is_completed()
|
||||
&& self.danmaku_task.is_completed()
|
||||
}
|
||||
|
||||
pub fn mark_uncompleted(&mut self) {
|
||||
self.video_task.mark_uncompleted();
|
||||
self.audio_task.mark_uncompleted();
|
||||
self.merge_task.completed = false;
|
||||
self.danmaku_task.completed = false;
|
||||
}
|
||||
|
||||
pub fn get_ids_string(&self) -> String {
|
||||
@@ -351,6 +359,7 @@ fn create_normal_progresses_for_single(
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
danmaku_task: tasks.danmaku,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -386,6 +395,7 @@ fn create_normal_progresses_for_single(
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
danmaku_task: tasks.danmaku,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -421,6 +431,7 @@ fn create_normal_progresses_for_single(
|
||||
video_task: tasks.video.clone(),
|
||||
audio_task: tasks.audio.clone(),
|
||||
merge_task: tasks.merge.clone(),
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -488,6 +499,7 @@ fn create_normal_progresses_for_season(
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
danmaku_task: tasks.danmaku,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -523,6 +535,7 @@ fn create_normal_progresses_for_season(
|
||||
video_task: tasks.video,
|
||||
audio_task: tasks.audio,
|
||||
merge_task: tasks.merge,
|
||||
danmaku_task: tasks.danmaku,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -559,6 +572,7 @@ fn create_normal_progresses_for_season(
|
||||
video_task: tasks.video.clone(),
|
||||
audio_task: tasks.audio.clone(),
|
||||
merge_task: tasks.merge.clone(),
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -576,6 +590,7 @@ struct Tasks {
|
||||
video: VideoTask,
|
||||
audio: AudioTask,
|
||||
merge: MergeTask,
|
||||
danmaku: DanmakuTask,
|
||||
}
|
||||
|
||||
impl Tasks {
|
||||
@@ -604,10 +619,18 @@ impl Tasks {
|
||||
completed: false,
|
||||
};
|
||||
|
||||
let danmaku = DanmakuTask {
|
||||
xml_selected: config.download_xml_danmaku,
|
||||
ass_selected: config.download_ass_danmaku,
|
||||
json_selected: config.download_json_danmaku,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
Self {
|
||||
video,
|
||||
audio,
|
||||
merge,
|
||||
danmaku,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,11 @@ use tokio::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
danmaku_xml_to_ass::xml_to_ass,
|
||||
events::DownloadEvent,
|
||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||
types::create_download_task_params::CreateDownloadTaskParams,
|
||||
utils::{self},
|
||||
utils::{self, ToXml},
|
||||
};
|
||||
|
||||
use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState};
|
||||
@@ -303,6 +304,13 @@ impl DownloadTask {
|
||||
tracing::debug!("{ids_string} `{filename}`视频和音频合并完成");
|
||||
}
|
||||
|
||||
if !progress.danmaku_task.is_completed() {
|
||||
self.download_danmaku(&progress)
|
||||
.await
|
||||
.context(format!("{ids_string} `{filename}`下载弹幕失败"))?;
|
||||
tracing::debug!("{ids_string} `{filename}`弹幕下载完成");
|
||||
}
|
||||
|
||||
let completed_ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
@@ -620,6 +628,46 @@ impl DownloadTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_danmaku(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||
let (aid, cid, duration) = (progress.aid, progress.cid, progress.duration);
|
||||
let danmaku_task = &progress.danmaku_task;
|
||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||
|
||||
let bili_client = self.app.get_bili_client();
|
||||
let replies = bili_client
|
||||
.get_danmaku(aid, cid, duration)
|
||||
.await
|
||||
.context("获取弹幕失败")?;
|
||||
|
||||
let xml = replies.to_xml(cid).context("将弹幕转换为XML失败")?;
|
||||
|
||||
if danmaku_task.xml_selected {
|
||||
let xml_path = episode_dir.join(format!("{filename}.弹幕.xml"));
|
||||
std::fs::write(&xml_path, &xml)
|
||||
.context(format!("保存弹幕XML到`{}`失败", xml_path.display()))?;
|
||||
}
|
||||
|
||||
if danmaku_task.ass_selected {
|
||||
let config = self.app.get_config().read().danmaku_config.clone();
|
||||
let ass_path = episode_dir.join(format!("{filename}.弹幕.ass"));
|
||||
let ass_file = File::create(&ass_path)
|
||||
.context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
|
||||
let title = filename.to_string();
|
||||
xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?;
|
||||
}
|
||||
|
||||
if danmaku_task.json_selected {
|
||||
let json_path = episode_dir.join(format!("{filename}.弹幕.json"));
|
||||
let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?;
|
||||
std::fs::write(&json_path, json_string)
|
||||
.context(format!("保存弹幕JSON到`{}`失败", json_path.display()))?;
|
||||
}
|
||||
|
||||
self.update_progress(|p| p.danmaku_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sleep_between_task(&self) {
|
||||
let task_id = &self.task_id;
|
||||
let mut remaining_sec = self.app.get_config().read().task_download_interval_sec;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct DanmakuTask {
|
||||
pub xml_selected: bool,
|
||||
pub ass_selected: bool,
|
||||
pub json_selected: bool,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
impl DanmakuTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.xml_selected && !self.ass_selected && !self.json_selected || self.completed
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod audio_task;
|
||||
pub mod danmaku_task;
|
||||
pub mod merge_task;
|
||||
pub mod video_task;
|
||||
|
||||
@@ -10,7 +10,6 @@ use crate::{
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogEvent {
|
||||
pub timestamp: String,
|
||||
pub level: LogLevel,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod bili_client;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod danmaku_xml_to_ass;
|
||||
mod downloader;
|
||||
mod errors;
|
||||
mod events;
|
||||
@@ -8,6 +9,9 @@ mod extensions;
|
||||
mod logger;
|
||||
mod types;
|
||||
mod utils;
|
||||
mod protobuf {
|
||||
include!("./bilibili.community.service.dm.v1.rs");
|
||||
}
|
||||
|
||||
use anyhow::Context;
|
||||
use commands::*;
|
||||
|
||||
@@ -7,6 +7,11 @@ use std::{
|
||||
use anyhow::{anyhow, Context};
|
||||
use byteorder::{BigEndian, ReadBytesExt};
|
||||
|
||||
use crate::{
|
||||
danmaku_xml_to_ass::{DamakuXmlDTag, DanmakuXmlITag},
|
||||
protobuf::DmSegMobileReply,
|
||||
};
|
||||
|
||||
pub fn filename_filter(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
@@ -124,3 +129,36 @@ pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result<bool> {
|
||||
|
||||
Ok(real_size == total_size && has_moov_box)
|
||||
}
|
||||
|
||||
pub trait ToXml {
|
||||
fn to_xml(&self, cid: i64) -> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
impl ToXml for Vec<DmSegMobileReply> {
|
||||
fn to_xml(&self, cid: i64) -> anyhow::Result<String> {
|
||||
let elems = self
|
||||
.iter()
|
||||
.flat_map(|reply| &reply.elems)
|
||||
.map(|elem| DamakuXmlDTag {
|
||||
p: format!(
|
||||
"{},{},{},{},{},{},{},{}",
|
||||
elem.progress / 1000,
|
||||
elem.mode,
|
||||
elem.fontsize,
|
||||
elem.color,
|
||||
elem.ctime,
|
||||
elem.pool,
|
||||
elem.mid_hash.clone(),
|
||||
elem.id_str.clone(),
|
||||
),
|
||||
body: Some(elem.content.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let i_tag = DanmakuXmlITag { chatid: cid, elems };
|
||||
|
||||
let xml = yaserde::ser::to_string(&i_tag).map_err(|e| anyhow!(e))?;
|
||||
|
||||
Ok(xml)
|
||||
}
|
||||
}
|
||||
|
||||
+58
-2
@@ -178,6 +178,61 @@ export type BadgeInfo = { bg_color: string; bg_color_night: string; text: string
|
||||
export type BangumiMediaUrl = { accept_format: string; code: number; seek_param: string; is_preview: number; fnval: number; video_project: boolean; fnver: number; type: string; bp: number; seek_type: string; result: string; vip_type: number | null; from: string; video_codecid: number; record_info: RecordInfo | null; is_drm: boolean; no_rexcode: number; format: string; support_formats: SupportFormatInBangumi[]; message: string; accept_quality: number[]; quality: number; timelength: number; durls: DurlInBangumi[]; has_paid: boolean; vip_status: number | null; error_code: number; dash: DashInBangumi | null; clip_info_list: ClipInfoList[]; accept_description: string[]; status: number }
|
||||
export type BangumiInfo = { activity: Activity; actors: string; alias: string; areas: Area[]; bkg_cover: string; cover: string; delivery_fragment_video: boolean; enable_vt: boolean; episodes: EpInBangumi[]; evaluate: string; hide_ep_vv_vt_dm: number; icon_font: IconFont; jp_title: string; link: string; media_id: number; mode: number; new_ep: NewEp; payment: PaymentInBangumi | null; play_strategy: PlayStrategy | null; positive: Positive; publish: Publish; rating: Rating | null; record: string; rights: RightsInBangumi; season_id: number; season_title: string; seasons: Season[]; section: SectionInBangumi[] | null; series: Series; share_copy: string; share_sub_title: string; share_url: string; show: Show; show_season_type: number; square_cover: string; staff: string; stat: StatInBangumi; status: number; styles: string[]; subtitle: string; title: string; total: number; type: number; up_info: UpInfoInBangumi | null; user_status: UserStatusInBangumi }
|
||||
export type Brief = { content: string; img: Img[]; title: string; type: number }
|
||||
export type CanvasConfig = {
|
||||
/**
|
||||
* 弹幕在屏幕上的【持续时间】,单位为秒,可以有小数
|
||||
*/
|
||||
duration: number;
|
||||
/**
|
||||
* 渲染的屏幕分辨率,这个并不会影响渲染区域的大小,只是字体的相对大小,可以不用改动
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* 渲染的屏幕分辨率,这个并不会影响渲染区域的大小,只是字体的相对大小,可以不用改动
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* 使用字体名称
|
||||
*/
|
||||
font: string;
|
||||
/**
|
||||
* 弹幕字体大小
|
||||
*/
|
||||
font_size: number;
|
||||
/**
|
||||
* 是一个比例数,用来计算平衡不同字体的宽度
|
||||
* 有的字体比较粗、比较宽,可以适当调大(如 1.4、1.6)
|
||||
* 有的字体比较细、比较窄,可以适当调小(如 1.0、1.2)
|
||||
*/
|
||||
width_ratio: number;
|
||||
/**
|
||||
* 用来调整弹幕时间的水平距离,单位是像素,如果想拉开弹幕之间的距离,可以调大
|
||||
*/
|
||||
horizontal_gap: number;
|
||||
/**
|
||||
* 计算弹幕高度的数值,即【行高度/行间距】。数值越大,弹幕的垂直距离越大
|
||||
*/
|
||||
lane_size: number;
|
||||
/**
|
||||
* 【正常弹幕的屏幕填充占比】,默认为 50%,即“半屏填充”。
|
||||
*/
|
||||
float_percentage: number;
|
||||
/**
|
||||
* 弹幕的不透明度,越小越透明,越大越不透明
|
||||
*/
|
||||
alpha: number;
|
||||
/**
|
||||
* 字体是否加粗
|
||||
*/
|
||||
bold: boolean;
|
||||
/**
|
||||
* 弹幕的描边宽度,单位为像素
|
||||
*/
|
||||
outline: number;
|
||||
/**
|
||||
* 弹幕时间轴偏移,>0 会让弹幕延后,<0 会让弹幕提前,单位为秒
|
||||
*/
|
||||
time_offset: number }
|
||||
export type CheeseInfo = { abtest_info: AbtestInfo; be_subscription: boolean; brief: Brief; consulting: Consulting; cooperation: Cooperation; course_content: string; cover: string; ep_count: number; episode_page: EpPage; episode_sort: number; episode_tag: EpTag; episodes: EpInCheese[]; expiry_day: number; expiry_info_content: string; faq: Faq; faq1: Faq1; is_enable_cash: boolean; is_series: boolean; live_ep_count: number; opened_ep_count: number; paid_jump: PaidJump; paid_view: boolean; payment: Payment; previewed_purchase_note: PreviewedPurchaseNote; purchase_format_note: PurchaseFormatNote; purchase_note: PurchaseNote; purchase_protocol: PurchaseProtocol; recommend_seasons: RecommendSeason[]; release_bottom_info: string; release_info: string; release_info2: string; release_status: string; season_id: number; season_tag: number; share_url: string; short_link: string; show_watermark: boolean; stat: StatInCheese; status: number; stop_sell: boolean; subscription_update_count_cycle_text: string; subtitle: string; title: string; up_info: UpInfoInCheese; update_status: number; user_status: UserStatusInCheese; watermark_interval: number }
|
||||
export type CheeseMediaUrl = { accept_format: string; code: number; seek_param: string; is_preview: number; fnval: number; video_project: boolean; play_view_business_info: PlayViewBusinessInfo | null; fnver: number; type: string; result: string; seek_type: string; from: string; video_codecid: number; no_rexcode: number; format: string; support_formats: SupportFormatInCheese[]; message: string; accept_quality: number[]; quality: number; timelength: number; durls: DurlInCheese[]; has_paid: boolean; dash: DashInCheese | null; accept_description: string[]; status: number }
|
||||
export type ClipInfoList = { materialNo: number; start: number; end: number; toastText: string; clipType: string }
|
||||
@@ -185,7 +240,7 @@ export type CntInfo = { collect: number; play: number; thumb_up: number; share:
|
||||
export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string }
|
||||
export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
||||
export type CommandError = { err_title: string; err_message: string }
|
||||
export type Config = { downloadDir: string; enableFileLogger: boolean; sessdata: string; preferVideoQuality: PreferVideoQuality; preferCodecType: PreferCodecType; preferAudioQuality: PreferAudioQuality; downloadVideo: boolean; downloadAudio: boolean; autoMerge: boolean; dirFmt: string; dirFmtForPart: string; timeFmt: string; taskConcurrency: number; taskDownloadIntervalSec: number; chunkConcurrency: number; chunkDownloadIntervalSec: number }
|
||||
export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig }
|
||||
export type Consulting = { consulting_flag: boolean; consulting_url: string }
|
||||
export type ContentList = { bold: boolean; content: string; number: string }
|
||||
export type Cooperation = { link: string }
|
||||
@@ -193,6 +248,7 @@ export type CreateBangumiDownloadTaskParams = { ep_ids: number[]; info: BangumiI
|
||||
export type CreateCheeseDownloadTaskParams = { ep_ids: number[]; info: CheeseInfo }
|
||||
export type CreateDownloadTaskParams = { Normal: CreateNormalDownloadTaskParams } | { Bangumi: CreateBangumiDownloadTaskParams } | { Cheese: CreateCheeseDownloadTaskParams }
|
||||
export type CreateNormalDownloadTaskParams = { info: NormalInfo; aid_cid_pairs: ([number, number | null])[] }
|
||||
export type DanmakuTask = { xml_selected: boolean; ass_selected: boolean; json_selected: boolean; completed: boolean }
|
||||
export type DashInBangumi = { duration: number; min_buffer_time: number; video: MediaInBangumi[]; audio: MediaInBangumi[] | null }
|
||||
export type DashInCheese = { duration: number; min_buffer_time: number; video: MediaInCheese[]; audio: MediaInCheese[] | null }
|
||||
export type DashInNormal = { duration: number; min_buffer_time: number; video: MediaInNormal[]; audio: MediaInNormal[] | null; dolby: Dolby; flac: Flac | null }
|
||||
@@ -202,7 +258,7 @@ export type DimensionInBangumi = { height: number; rotate: number; width: number
|
||||
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
||||
export type Dolby = { type: number; audio: MediaInNormal[] | null }
|
||||
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; 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; merge_task: MergeTask; danmaku_task: DanmakuTask; create_ts: number; completed_ts: number | null }
|
||||
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
||||
export type DurlDetailInBangumi = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
||||
export type DurlDetailInCheese = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
||||
|
||||
Reference in New Issue
Block a user