diff --git a/Cargo.lock b/Cargo.lock index 9c2e1ce..fc41130 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,12 +423,15 @@ dependencies = [ "entity", "env_logger", "filenamify", + "float-ord", "futures", "handlebars", "hex", "log", + "memchr", "migration", "once_cell", + "prost", "quick-xml", "rand", "regex", @@ -1001,6 +1004,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 = "flume" version = "0.11.0" @@ -1528,6 +1537,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -2149,6 +2167,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f5d036824e4761737860779c906171497f6d55681139d8312388f8fe398922" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19de2de2a00075bf566bee3bd4db014b11587e84184d3f7a791bc17f1a8e9e48" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -2857,7 +2898,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" dependencies = [ - "itertools", + "itertools 0.12.1", "nom", "unicode_categories", ] diff --git a/Cargo.toml b/Cargo.toml index 3394169..0cc8a46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,26 +13,29 @@ dirs = "5.0.1" entity = { path = "entity" } env_logger = "0.11.3" filenamify = "0.1.0" +float-ord = "0.3.2" futures = "0.3.30" handlebars = "5.1.2" hex = "0.4.3" log = "0.4.21" +memchr = "2.5.0" migration = { path = "migration" } once_cell = "1.19.0" +prost = "0.12.4" quick-xml = { version = "0.31.0", features = ["async-tokio"] } rand = "0.8.5" regex = "1.10.3" reqwest = { version = "0.12.0", features = [ - "json", - "stream", - "cookies", - "gzip", + "json", + "stream", + "cookies", + "gzip", ] } rsa = { version = "0.9.6", features = ["sha2"] } sea-orm = { version = "0.12", features = [ - "sqlx-sqlite", - "runtime-tokio-native-tls", - "macros", + "sqlx-sqlite", + "runtime-tokio-native-tls", + "macros", ] } serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0" diff --git a/entity/src/entities/page.rs b/entity/src/entities/page.rs index f8935a1..1fd5782 100644 --- a/entity/src/entities/page.rs +++ b/entity/src/entities/page.rs @@ -11,6 +11,9 @@ pub struct Model { pub cid: i32, pub pid: i32, pub name: String, + pub width: Option, + pub height: Option, + pub duration: u32, pub path: Option, pub image: Option, pub download_status: u32, diff --git a/migration/src/m20240322_000001_create_table.rs b/migration/src/m20240322_000001_create_table.rs index 5e18b5d..2cd40ef 100644 --- a/migration/src/m20240322_000001_create_table.rs +++ b/migration/src/m20240322_000001_create_table.rs @@ -84,6 +84,9 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(Page::Cid).unsigned().not_null()) .col(ColumnDef::new(Page::Pid).unsigned().not_null()) .col(ColumnDef::new(Page::Name).string().not_null()) + .col(ColumnDef::new(Page::Width).unsigned()) + .col(ColumnDef::new(Page::Height).unsigned()) + .col(ColumnDef::new(Page::Duration).unsigned().not_null()) .col(ColumnDef::new(Page::Path).string()) .col(ColumnDef::new(Page::Image).string()) .col(ColumnDef::new(Page::DownloadStatus).unsigned().not_null()) @@ -173,6 +176,9 @@ enum Page { Cid, Pid, Name, + Width, + Height, + Duration, Path, Image, DownloadStatus, diff --git a/src/bilibili/analyzer.rs b/src/bilibili/analyzer.rs index fc7ff14..3ec1a98 100644 --- a/src/bilibili/analyzer.rs +++ b/src/bilibili/analyzer.rs @@ -31,6 +31,7 @@ pub enum AudioQuality { Quality192k = 30280, } +#[allow(clippy::upper_case_acronyms)] #[derive(Debug, strum::EnumString, strum::Display, PartialEq, PartialOrd, Serialize, Deserialize)] pub enum VideoCodecs { #[strum(serialize = "hev")] diff --git a/src/bilibili/danmaku/ass_writer.rs b/src/bilibili/danmaku/ass_writer.rs new file mode 100644 index 0000000..a06782d --- /dev/null +++ b/src/bilibili/danmaku/ass_writer.rs @@ -0,0 +1,234 @@ +use std::borrow::Cow; +use std::fmt; +use std::pin::Pin; + +use anyhow::Result; +use tokio::io::{AsyncWrite, AsyncWriteExt, BufWriter}; + +use super::canvas::CanvasConfig; +use crate::bilibili::danmaku::{DrawEffect, Drawable}; + +struct TimePoint { + t: f64, +} +impl fmt::Display for TimePoint { + 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})") + } + } + } +} + +impl super::DanmakuOption { + pub fn ass_styles(&self) -> Vec { + 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 = self.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 = self.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 = self.opacity, + font = self.font, + font_size = self.font_size, + bold = self.bold as u8, + outline = self.outline, + ), + ] + } +} + +struct CanvasStyles(Vec); +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 { + f: Pin>>, + title: String, + canvas_config: CanvasConfig, +} + +impl AssWriter { + pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Self { + AssWriter { + // 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存 + f: Box::pin(BufWriter::with_capacity(10 << 20, f)), + title, + canvas_config, + } + } + + pub async fn construct(f: W, title: String, canvas_config: CanvasConfig) -> Result { + let mut res = Self::new(f, title, canvas_config); + res.init().await?; + Ok(res) + } + + pub async fn init(&mut self) -> Result<()> { + self.f + .write_all( + format!( + "\ + [Script Info]\n\ + ; Script generated by danmu2ass\n\ + Title: {title}\n\ + Script Updated By: danmu2ass (https://github.com/gwy15/danmu2ass)\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.danmaku_option.ass_styles()), + ) + .into_bytes() + .as_slice(), + ) + .await?; + Ok(()) + } + + pub async fn write(&mut self, drawable: Drawable) -> Result<()> { + self.f + .write_all( + format!( + // 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}\n", + start = TimePoint { + t: drawable.danmu.timeline_s + }, + end = TimePoint { + t: drawable.danmu.timeline_s + drawable.duration + }, + style = drawable.style_name, + effect = AssEffect { + effect: drawable.effect + }, + b = drawable.danmu.rgb.2, + g = drawable.danmu.rgb.1, + r = drawable.danmu.rgb.0, + text = escape_text(&drawable.danmu.content), + // text = (0..drawable.danmu.content.chars().count()).map(|_| '晚').collect::(), + ) + .into_bytes() + .as_slice(), + ) + .await?; + Ok(()) + } + + pub async fn flush(&mut self) -> Result<()> { + Ok(self.f.flush().await?) + } +} + +fn escape_text(text: &str) -> Cow { + let text = text.trim(); + if memchr::memchr(b'\n', text.as_bytes()).is_some() { + Cow::from(text.replace('\n', "\\N")) + } else { + Cow::from(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn time_point_fmt() { + assert_eq!(format!("{}", TimePoint { t: 0.0 }), "0:00:00.00"); + assert_eq!(format!("{}", TimePoint { t: 1.0 }), "0:00:01.00"); + assert_eq!(format!("{}", TimePoint { t: 60.0 }), "0:01:00.00"); + assert_eq!(format!("{}", TimePoint { t: 3600.0 }), "1:00:00.00"); + assert_eq!(format!("{}", TimePoint { t: 3600.0 + 60.0 }), "1:01:00.00"); + assert_eq!(format!("{}", TimePoint { t: 3600.0 + 60.0 + 1.0 }), "1:01:01.00"); + assert_eq!( + format!( + "{}", + TimePoint { + t: 3600.0 + 60.0 + 1.0 + 0.5 + } + ), + "1:01:01.50" + ); + assert_eq!( + format!( + "{}", + TimePoint { + t: 3600.0 + 1.0 + 0.01234 + } + ), + "1:00:01.01" + ); + } + + #[test] + fn test_escape_text() { + assert_eq!( + escape_text("呵\n呵\n比\n你\n们\n更\n喜\n欢\n晚\n晚").as_ref(), + r"呵\N呵\N比\N你\N们\N更\N喜\N欢\N晚\N晚" + ); + } +} diff --git a/src/bilibili/danmaku/canvas/lane.rs b/src/bilibili/danmaku/canvas/lane.rs new file mode 100644 index 0000000..ab9abc2 --- /dev/null +++ b/src/bilibili/danmaku/canvas/lane.rs @@ -0,0 +1,88 @@ +use super::CanvasConfig; +use crate::bilibili::danmaku::Danmu; + +pub enum Collision { + // 会越来越远 + Separate, + // 时间够可以追上,但是时间不够 + NotEnoughTime, + // 需要额外的时间才可以避免碰撞 + Collide { time_needed: f64 }, +} + +/// 表示一个弹幕槽位 +#[derive(Debug, Clone)] +pub struct Lane { + last_shoot_time: f64, + last_length: f64, +} + +impl Lane { + pub fn draw(danmu: &Danmu, config: &CanvasConfig) -> Self { + Lane { + last_shoot_time: danmu.timeline_s, + last_length: danmu.length(config), + } + } + + /// 这个槽位是否可以发射另外一条弹幕,返回可能的情形 + pub fn available_for(&self, other: &Danmu, config: &CanvasConfig) -> Collision { + #[allow(non_snake_case)] + let T = config.danmaku_option.duration; + #[allow(non_snake_case)] + let W = config.width as f64; + let gap = config.danmaku_option.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 + } else { + // 需要算追击问题了, + // l1 已经消失,但是 l2 可能追上,我们计算 l1 刚好消失的时候: + // 此刻是 t1 + T + // l2 的头部应该在距离起点 v2 * (t1 + T - t2) 处 + let pos = v2 * (T - delta_t); + if pos < (W - gap) { + Collision::NotEnoughTime + } else { + // 需要额外的时间 + Collision::Collide { + time_needed: (pos - (W - gap)) / v2, + } + } + } + } + } +} diff --git a/src/bilibili/danmaku/canvas/mod.rs b/src/bilibili/danmaku/canvas/mod.rs new file mode 100644 index 0000000..5e2ae66 --- /dev/null +++ b/src/bilibili/danmaku/canvas/mod.rs @@ -0,0 +1,173 @@ +//! 决定绘画策略 +mod lane; + +use anyhow::Result; +use float_ord::FloatOrd; +use lane::Lane; + +use super::{Danmu, Drawable}; +use crate::bilibili::danmaku::canvas::lane::Collision; +use crate::bilibili::danmaku::danmu::DanmuType; +use crate::bilibili::danmaku::DrawEffect; +use crate::bilibili::PageInfo; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct DanmakuOption { + pub duration: f64, + pub font: String, + pub font_size: u32, + pub width_ratio: f64, + /// 两条弹幕之间最小的水平距离 + pub horizontal_gap: f64, + /// lane 大小 + pub lane_size: u32, + /// 屏幕上滚动弹幕最多高度百分比 + pub float_percentage: f64, + /// 屏幕上底部弹幕最多高度百分比 + pub bottom_percentage: f64, + /// 透明度(0-255) + pub opacity: u8, + /// 是否加粗,1代表是,0代表否 + pub bold: bool, + /// 描边 + pub outline: f64, + /// 时间轴偏移 + pub time_offset: f64, +} + +impl Default for DanmakuOption { + fn default() -> Self { + Self { + duration: 15.0, + 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, + opacity: (0.3 * 255.0) as u8, + bold: true, + outline: 0.8, + time_offset: 0.0, + } + } +} + +#[derive(Clone)] +pub struct CanvasConfig { + pub width: u64, + pub height: u64, + pub danmaku_option: &'static DanmakuOption, +} +impl CanvasConfig { + pub fn new(danmaku_option: &'static DanmakuOption, page: &PageInfo) -> Self { + let (width, height) = Self::dimension(page); + Self { + width, + height, + danmaku_option, + } + } + + /// 获取画布的宽高 + fn dimension(page: &PageInfo) -> (u64, u64) { + let (width, height) = match &page.dimension { + Some(d) => { + if d.rotate == 0 { + (d.width, d.height) + } else { + (d.height, d.width) + } + } + None => (1280, 720), + }; + // 对于指定的字体大小,画布的大小同样会影响到字体的实际显示大小 + // 怀疑字体的大小会根据 height 缩放,尝试将视频的 height 对齐到 720 + ((720.0 / height as f64 * width as f64) as u64, 720) + } + + pub fn canvas(self) -> Canvas { + let float_lanes_cnt = + (self.danmaku_option.float_percentage * self.height as f64 / self.danmaku_option.lane_size as f64) as usize; + + Canvas { + config: self, + float_lanes: vec![None; float_lanes_cnt], + } + } +} + +pub struct Canvas { + pub config: CanvasConfig, + pub float_lanes: Vec>, +} + +impl Canvas { + pub fn draw(&mut self, mut danmu: Danmu) -> Result> { + danmu.timeline_s += self.config.danmaku_option.time_offset; + if danmu.timeline_s < 0.0 { + return Ok(None); + } + match danmu.r#type { + DanmuType::Float => Ok(self.draw_float(danmu)), + DanmuType::Bottom | DanmuType::Top | DanmuType::Reverse => { + // 不喜欢底部弹幕,直接转成 Bottom + // 这是 feature 不是 bug + danmu.r#type = DanmuType::Float; + Ok(self.draw_float(danmu)) + } + } + } + + fn draw_float(&mut self, mut danmu: Danmu) -> Option { + 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(danmu, idx)); + } + Some(l) => { + let col = l.available_for(&danmu, &self.config); + match col { + Collision::Separate | Collision::NotEnoughTime => { + return Some(self.draw_float_in_lane(danmu, 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 { + debug!("延迟弹幕 {} 秒", time_need); + // 只允许延迟 1s + danmu.timeline_s += time_need + 0.01; // 间隔也不要太小了 + return Some(self.draw_float_in_lane(danmu, lane_idx)); + } + } + debug!("skipping danmu: {}", danmu.content); + None + } + + fn draw_float_in_lane(&mut self, danmu: Danmu, lane_idx: usize) -> Drawable { + self.float_lanes[lane_idx] = Some(Lane::draw(&danmu, &self.config)); + let y = lane_idx as i32 * self.config.danmaku_option.lane_size as i32; + let l = danmu.length(&self.config); + Drawable::new( + danmu, + self.config.danmaku_option.duration, + "Float", + DrawEffect::Move { + start: (self.config.width as i32, y), + end: (-(l as i32), y), + }, + ) + } +} diff --git a/src/bilibili/danmaku/danmu.rs b/src/bilibili/danmaku/danmu.rs new file mode 100644 index 0000000..f5470e0 --- /dev/null +++ b/src/bilibili/danmaku/danmu.rs @@ -0,0 +1,52 @@ +//! 一个弹幕实例,但是没有位置信息 +use anyhow::Result; + +use super::canvas::CanvasConfig; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DanmuType { + #[default] + Float, + Top, + Bottom, + Reverse, +} + +impl DanmuType { + pub fn from_num(num: i32) -> Result { + Ok(match num { + 1 => DanmuType::Float, + 4 => DanmuType::Bottom, + 5 => DanmuType::Top, + 6 => DanmuType::Reverse, + _ => unreachable!(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Danmu { + pub timeline_s: f64, + pub content: String, + pub r#type: DanmuType, + /// 虽然这里有 fontsize,但是我们实际上使用 canvas config 的 font size, + /// 否在在调节分辨率的时候字体会发生变化。 + pub fontsize: u32, + pub rgb: (u8, u8, u8), +} + +impl Danmu { + /// 计算弹幕的“像素长度”,会乘上一个缩放因子 + /// + /// 汉字算一个全宽,英文算2/3宽 + pub fn length(&self, config: &CanvasConfig) -> f64 { + let pts = config.danmaku_option.font_size + * self + .content + .chars() + .map(|ch| if ch.is_ascii() { 2 } else { 3 }) + .sum::() + / 3; + + pts as f64 * config.danmaku_option.width_ratio + } +} diff --git a/src/bilibili/danmaku/drawable.rs b/src/bilibili/danmaku/drawable.rs new file mode 100644 index 0000000..2ed0dbc --- /dev/null +++ b/src/bilibili/danmaku/drawable.rs @@ -0,0 +1,28 @@ +//! 可以绘制的实体 + +use crate::bilibili::danmaku::Danmu; + +/// 弹幕开始绘制的时间就是 danmu 的时间 +pub struct Drawable { + pub danmu: Danmu, + /// 弹幕一共绘制的时间 + pub duration: f64, + /// 弹幕的绘制 style + pub style_name: &'static str, + /// 绘制的“特效” + pub effect: DrawEffect, +} +impl Drawable { + pub fn new(danmu: Danmu, duration: f64, style_name: &'static str, effect: DrawEffect) -> Self { + Drawable { + danmu, + duration, + style_name, + effect, + } + } +} + +pub enum DrawEffect { + Move { start: (i32, i32), end: (i32, i32) }, +} diff --git a/src/bilibili/danmaku/mod.rs b/src/bilibili/danmaku/mod.rs new file mode 100644 index 0000000..222e547 --- /dev/null +++ b/src/bilibili/danmaku/mod.rs @@ -0,0 +1,13 @@ +mod ass_writer; +mod canvas; +mod danmu; +mod drawable; +mod model; +mod writer; + +pub use ass_writer::AssWriter; +pub use canvas::DanmakuOption; +pub use danmu::Danmu; +pub use drawable::{DrawEffect, Drawable}; +pub use model::{DanmakuElem, DmSegMobileReply}; +pub use writer::DanmakuWriter; diff --git a/src/bilibili/danmaku/model.rs b/src/bilibili/danmaku/model.rs new file mode 100644 index 0000000..98b3d59 --- /dev/null +++ b/src/bilibili/danmaku/model.rs @@ -0,0 +1,84 @@ +//! 出于减少编译引入考虑,直接翻译了一下 pb,不引入 prost-build +//! +//! 可以看旁边的 dm.proto + +use prost::Message; + +use crate::bilibili::danmaku::danmu::{Danmu, DanmuType}; +/// 弹幕 pb 定义 +#[derive(Clone, Message)] +pub struct DanmakuElem { + /// 弹幕 dmid + #[prost(int64, tag = "1")] + pub id: i64, + + /// 弹幕出现位置(单位 ms) + #[prost(int32, tag = "2")] + pub progress: i32, + + /// 弹幕类型 + #[prost(int32, tag = "3")] + pub mode: i32, + + /// 弹幕字号 + #[prost(int32, tag = "4")] + pub fontsize: i32, + + /// 弹幕颜色 + #[prost(uint32, tag = "5")] + pub color: u32, + + /// 发送者 mid hash + #[prost(string, tag = "6")] + pub mid_hash: String, + + /// 弹幕正文 + #[prost(string, tag = "7")] + pub content: String, + + /// 弹幕发送时间 + #[prost(int64, tag = "8")] + pub ctime: i64, + + /// 弹幕权重 + #[prost(int32, tag = "9")] + pub weight: i32, + + /// 动作? + #[prost(string, tag = "10")] + pub action: String, + + /// 弹幕池 + #[prost(int32, tag = "11")] + pub pool: i32, + + /// 弹幕 dmid str + #[prost(string, tag = "12")] + pub dmid_str: String, + + /// 弹幕属性 + #[prost(int32, tag = "13")] + pub attr: i32, +} + +#[derive(Clone, Message)] +pub struct DmSegMobileReply { + #[prost(message, repeated, tag = "1")] + pub elems: Vec, +} + +impl From for Danmu { + fn from(elem: DanmakuElem) -> Self { + Self { + timeline_s: elem.progress as f64 / 1000.0, + content: elem.content, + r#type: DanmuType::from_num(elem.mode).unwrap_or_default(), + fontsize: elem.fontsize as u32, + rgb: ( + ((elem.color >> 16) & 0xFF) as u8, + ((elem.color >> 8) & 0xFF) as u8, + (elem.color & 0xFF) as u8, + ), + } + } +} diff --git a/src/bilibili/danmaku/writer.rs b/src/bilibili/danmaku/writer.rs new file mode 100644 index 0000000..dffce82 --- /dev/null +++ b/src/bilibili/danmaku/writer.rs @@ -0,0 +1,37 @@ +use std::path::PathBuf; + +use anyhow::Result; +use tokio::fs::{self, File}; + +use super::canvas::CanvasConfig; +use super::{AssWriter, Danmu}; +use crate::bilibili::PageInfo; +use crate::config::CONFIG; + +pub struct DanmakuWriter<'a> { + page: &'a PageInfo, + danmaku: Vec, +} + +impl<'a> DanmakuWriter<'a> { + pub fn new(page: &'a PageInfo, danmaku: Vec) -> Self { + DanmakuWriter { page, danmaku } + } + + pub async fn write(self, path: PathBuf) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + let canvas_config = CanvasConfig::new(&CONFIG.danmaku_option, self.page); + let mut writer = + AssWriter::construct(File::create(path).await?, self.page.name.clone(), canvas_config.clone()).await?; + let mut canvas = canvas_config.canvas(); + for danmuku in self.danmaku { + if let Some(drawable) = canvas.draw(danmuku)? { + writer.write(drawable).await?; + } + } + writer.flush().await?; + Ok(()) + } +} diff --git a/src/bilibili/mod.rs b/src/bilibili/mod.rs index bb36d8e..5c78aa7 100644 --- a/src/bilibili/mod.rs +++ b/src/bilibili/mod.rs @@ -1,13 +1,15 @@ pub use analyzer::{BestStream, FilterOption}; pub use client::{BiliClient, Client}; pub use credential::Credential; +pub use danmaku::DanmakuOption; pub use error::BiliError; pub use favorite_list::{FavoriteList, FavoriteListInfo, VideoInfo}; -pub use video::{PageInfo, Video}; +pub use video::{Dimension, PageInfo, Video}; mod analyzer; mod client; mod credential; +mod danmaku; mod error; mod favorite_list; mod video; diff --git a/src/bilibili/video.rs b/src/bilibili/video.rs index 307ee89..53c0752 100644 --- a/src/bilibili/video.rs +++ b/src/bilibili/video.rs @@ -1,8 +1,13 @@ use anyhow::{bail, Result}; +use futures::stream::FuturesUnordered; +use futures::TryStreamExt; +use prost::Message; use reqwest::Method; +use super::danmaku::{DanmakuElem, DanmakuWriter}; use crate::bilibili::analyzer::PageAnalyzer; use crate::bilibili::client::BiliClient; +use crate::bilibili::danmaku::DmSegMobileReply; use crate::bilibili::error::BiliError; static MASK_CODE: u64 = 2251799813685247; @@ -33,14 +38,22 @@ impl serde::Serialize for Tag { serializer.serialize_str(&self.tag_name) } } - #[derive(Debug, serde::Deserialize, Default)] pub struct PageInfo { pub cid: i32, pub page: i32, #[serde(rename = "part")] pub name: String, + pub duration: u32, pub first_frame: Option, + pub dimension: Option, +} + +#[derive(Debug, serde::Deserialize, Default)] +pub struct Dimension { + pub width: u32, + pub height: u32, + pub rotate: u32, } impl<'a> Video<'a> { @@ -89,6 +102,30 @@ impl<'a> Video<'a> { Ok(serde_json::from_value(res["data"].take())?) } + pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result { + let tasks = FuturesUnordered::new(); + for i in 1..=(page.duration + 359) / 360 { + tasks.push(self.get_danmaku_segment(page, i as i32)); + } + let result: Vec> = tasks.try_collect().await?; + let mut result: Vec = result.into_iter().flatten().collect(); + result.sort_by_key(|d| d.progress); + Ok(DanmakuWriter::new(page, result.into_iter().map(|x| x.into()).collect())) + } + + async fn get_danmaku_segment(&self, page: &PageInfo, segment_idx: i32) -> Result> { + let res = self + .client + .request(Method::GET, "http://api.bilibili.com/x/v2/dm/web/seg.so") + .query(&[("type", 1), ("oid", page.cid), ("segment_index", segment_idx)]) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + Ok(DmSegMobileReply::decode(res)?.elems) + } + pub async fn get_page_analyzer(&self, page: &PageInfo) -> Result { let mut res = self .client diff --git a/src/config.rs b/src/config.rs index 1722e16..69ce336 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,16 +7,15 @@ use arc_swap::ArcSwapOption; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use crate::bilibili::{Credential, FilterOption}; +use crate::bilibili::{Credential, DanmakuOption, FilterOption}; pub static CONFIG: Lazy = Lazy::new(|| { let config = Config::load().unwrap_or_else(|err| { warn!("Failed loading config: {err}"); - let new_config = Config::new(); - // 保存一次,确保配置文件存在 - new_config.save().unwrap(); - new_config + Config::new() }); + // 放到外面,确保新的配置项被保存 + config.save().unwrap(); // 检查配置文件内容 config.check(); config @@ -29,6 +28,8 @@ pub static CONFIG_DIR: Lazy = pub struct Config { pub credential: ArcSwapOption, pub filter_option: FilterOption, + #[serde(default)] + pub danmaku_option: DanmakuOption, pub favorite_list: HashMap, pub video_name: Cow<'static, str>, pub page_name: Cow<'static, str>, @@ -47,6 +48,7 @@ impl Config { Self { credential: ArcSwapOption::empty(), filter_option: FilterOption::default(), + danmaku_option: DanmakuOption::default(), favorite_list: HashMap::new(), video_name: Cow::Borrowed("{{bvid}}"), page_name: Cow::Borrowed("{{bvid}}"), diff --git a/src/core/command.rs b/src/core/command.rs index 0fdccf7..f532993 100644 --- a/src/core/command.rs +++ b/src/core/command.rs @@ -18,7 +18,7 @@ use super::status::{PageStatus, VideoStatus}; use super::utils::{ unhandled_videos_pages, update_pages_model, update_videos_model, ModelWrapper, NFOMode, NFOSerializer, TEMPLATE, }; -use crate::bilibili::{BestStream, BiliClient, BiliError, FavoriteList, FilterOption, PageInfo, Video}; +use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, FavoriteList, FilterOption, PageInfo, Video}; use crate::config::CONFIG; use crate::core::utils::{ create_video_pages, create_videos, exist_labels, filter_unfilled_videos, handle_favorite_info, total_video_count, @@ -199,13 +199,11 @@ pub async fn download_video_pages( } let mut status = VideoStatus::new(video_model.download_status); let seprate_status = status.should_run(); - let base_path = Path::new(&video_model.path); let upper_id = video_model.upper_id.to_string(); let base_upper_path = upper_path .join(upper_id.chars().next().unwrap().to_string()) .join(upper_id); - let is_single_page = video_model.single_page.unwrap(); // 对于单页视频,page 的下载已经足够 // 对于多页视频,page 下载仅包含了分集内容,需要额外补上视频的 poster 的 tvshow.nfo @@ -352,11 +350,12 @@ pub async fn download_page( "pid": page_model.pid, }), )?); - let (poster_path, video_path, nfo_path) = if is_single_page { + let (poster_path, video_path, nfo_path, danmaku_path) = if is_single_page { ( base_path.join(format!("{}-poster.jpg", &base_name)), base_path.join(format!("{}.mp4", &base_name)), base_path.join(format!("{}.nfo", &base_name)), + base_path.join(format!("{}.zh-CN.default.ass", &base_name)), ) } else { ( @@ -369,10 +368,27 @@ pub async fn download_page( base_path .join("Season 1") .join(format!("{} - S01E{:0>2}.nfo", &base_name, page_model.pid)), + base_path + .join("Season 1") + .join(format!("{} - S01E{:0>2}.zh-CN.default.ass", &base_name, page_model.pid)), ) }; + let dimension = if page_model.width.is_some() && page_model.height.is_some() { + Some(Dimension { + width: page_model.width.unwrap(), + height: page_model.height.unwrap(), + rotate: 0, + }) + } else { + None + }; + let page_info = PageInfo { + cid: page_model.cid, + duration: page_model.duration, + dimension, + ..Default::default() + }; let tasks: Vec>>>> = vec![ - // 暂时不支持下载字幕 Box::pin(fetch_page_poster( seprate_status[0], video_model, @@ -384,18 +400,25 @@ pub async fn download_page( seprate_status[1], bili_client, video_model, - &page_model, downloader, + &page_info, video_path.clone(), )), Box::pin(generate_page_nfo(seprate_status[2], video_model, &page_model, nfo_path)), + Box::pin(fetch_page_danmaku( + seprate_status[3], + bili_client, + video_model, + &page_info, + danmaku_path, + )), ]; let tasks: FuturesOrdered<_> = tasks.into_iter().collect(); let results: Vec> = tasks.collect().await; status.update_status(&results); results .iter() - .zip(["poster", "video", "nfo"]) + .zip(["poster", "video", "nfo", "danmaku"]) .for_each(|(res, task_name)| { if res.is_err() { error!( @@ -447,8 +470,8 @@ pub async fn fetch_page_video( should_run: bool, bili_client: &BiliClient, video_model: &video::Model, - page_model: &page::Model, downloader: &Downloader, + page_info: &PageInfo, page_path: PathBuf, ) -> Result<()> { if !should_run { @@ -456,10 +479,7 @@ pub async fn fetch_page_video( } let bili_video = Video::new(bili_client, video_model.bvid.clone()); let streams = bili_video - .get_page_analyzer(&PageInfo { - cid: page_model.cid, - ..Default::default() - }) + .get_page_analyzer(page_info) .await? .best_stream(&FilterOption::default())?; match streams { @@ -488,6 +508,25 @@ pub async fn fetch_page_video( Ok(()) } +pub async fn fetch_page_danmaku( + should_run: bool, + bili_client: &BiliClient, + video_model: &video::Model, + page_info: &PageInfo, + danmaku_path: PathBuf, +) -> Result<()> { + if !should_run { + return Ok(()); + } + let bili_video = Video::new(bili_client, video_model.bvid.clone()); + bili_video + .get_danmaku_writer(page_info) + .await? + .write(danmaku_path) + .await?; + Ok(()) +} + pub async fn generate_page_nfo( should_run: bool, video_model: &video::Model, diff --git a/src/core/status.rs b/src/core/status.rs index de069e3..129c5cf 100644 --- a/src/core/status.rs +++ b/src/core/status.rs @@ -1,5 +1,3 @@ -use core::fmt; - use anyhow::Result; static STATUS_MAX_RETRY: u32 = 0b100; @@ -94,16 +92,6 @@ impl Status { let helper = !0u32; (self.0 & (helper << (offset * 3)) & (helper >> (32 - 3 * offset - 3))) >> (offset * 3) } - - fn display_status(status: u32) -> String { - if status < STATUS_MAX_RETRY { - format!("failed {} times", status) - } else if status == STATUS_OK { - "ok".to_string() - } else { - "failed".to_string() - } - } } impl From for u32 { @@ -136,20 +124,6 @@ impl VideoStatus { } } -impl fmt::Display for VideoStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Video Cover: {}, Video NFO: {}, Up Avatar: {}, Up NFO: {}, Page Download: {}", - Status::display_status(self.0.get_status(0)), - Status::display_status(self.0.get_status(1)), - Status::display_status(self.0.get_status(2)), - Status::display_status(self.0.get_status(3)), - Status::display_status(self.0.get_status(4)) - ) - } -} - impl From for u32 { fn from(status: VideoStatus) -> Self { status.0.into() @@ -166,32 +140,17 @@ impl PageStatus { } pub fn set_mask(&mut self, clear: &[bool]) { - assert!(clear.len() == 3, "PageStatus should have 3 status"); + assert!(clear.len() == 4, "PageStatus should have 4 status"); self.0.set_mask(clear) } pub fn should_run(&self) -> Vec { - self.0.should_run(3) + self.0.should_run(4) } pub fn update_status(&mut self, result: &[Result<()>]) { - assert!( - result.len() >= 3, - "PageStatus should have at least 3 status, more status will be ignored" - ); - self.0.update_status(&result[..3]) - } -} - -impl fmt::Display for PageStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Page Cover: {}, Page Content: {}, Page NFO: {}", - Status::display_status(self.0.get_status(0)), - Status::display_status(self.0.get_status(1)), - Status::display_status(self.0.get_status(2)) - ) + assert!(result.len() == 4, "PageStatus should have 4 status"); + self.0.update_status(&result) } } diff --git a/src/core/utils.rs b/src/core/utils.rs index 5d7d3f9..dcf71f8 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -28,6 +28,7 @@ pub static TEMPLATE: Lazy = Lazy::new(|| { handlebars }); +#[allow(clippy::upper_case_acronyms)] pub enum NFOMode { MOVIE, TVSHOW, @@ -182,14 +183,29 @@ pub async fn create_video_pages( ) -> Result<()> { let page_models = pages_info .iter() - .map(move |p| page::ActiveModel { - video_id: Set(video_model.id), - cid: Set(p.cid), - pid: Set(p.page), - name: Set(p.name.clone()), - image: Set(p.first_frame.clone()), - download_status: Set(0), - ..Default::default() + .map(move |p| { + let (width, height) = match &p.dimension { + Some(d) => { + if d.rotate == 0 { + (Some(d.width), Some(d.height)) + } else { + (Some(d.height), Some(d.width)) + } + } + None => (None, None), + }; + page::ActiveModel { + video_id: Set(video_model.id), + cid: Set(p.cid), + pid: Set(p.page), + name: Set(p.name.clone()), + width: Set(width), + height: Set(height), + duration: Set(p.duration), + image: Set(p.first_frame.clone()), + download_status: Set(0), + ..Default::default() + } }) .collect::>(); page::Entity::insert_many(page_models)