feat: basic danmu encoding configuration (close #103)

This commit is contained in:
Xinrea
2025-10-25 13:16:59 +08:00
parent b7a76e8f10
commit a1e57c5b9c
9 changed files with 173 additions and 13 deletions

View File

@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use chrono::Local;
use serde::{Deserialize, Serialize};
use crate::recorder_manager::ClipRangeParams;
use crate::{danmu2ass::Danmu2AssOptions, recorder_manager::ClipRangeParams};
#[derive(Deserialize, Serialize, Clone)]
pub struct Config {
@@ -37,6 +37,8 @@ pub struct Config {
pub whisper_language: String,
#[serde(default = "default_webhook_url")]
pub webhook_url: String,
#[serde(default = "default_danmu_ass_options")]
pub danmu_ass_options: Danmu2AssOptions,
}
#[derive(Deserialize, Serialize, Clone)]
@@ -45,6 +47,10 @@ pub struct AutoGenerateConfig {
pub encode_danmu: bool,
}
fn default_danmu_ass_options() -> Danmu2AssOptions {
Danmu2AssOptions::default()
}
fn default_auto_subtitle() -> bool {
false
}
@@ -130,6 +136,7 @@ impl Config {
config_path: config_path.to_str().unwrap().into(),
whisper_language: default_whisper_language(),
webhook_url: default_webhook_url(),
danmu_ass_options: default_danmu_ass_options(),
};
config.save();
@@ -162,6 +169,12 @@ impl Config {
self.save();
}
#[allow(dead_code)]
pub fn set_danmu_ass_options(&mut self, options: Danmu2AssOptions) {
self.danmu_ass_options = options;
self.save();
}
pub fn generate_clip_name(&self, params: &ClipRangeParams) -> PathBuf {
// get format config
// filter special characters from title to make sure file name is valid

View File

@@ -1,4 +1,5 @@
use recorder::danmu::DanmuEntry;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
// code reference: https://github.com/tiansh/us-danmaku/blob/master/bilibili/bilibili_ASS_Danmaku_Downloader.user.js
@@ -30,9 +31,32 @@ const BOTTOM_RESERVED: f64 = 50.0;
const R2L_TIME: f64 = 8.0;
const MAX_DELAY: f64 = 6.0;
pub fn danmu_to_ass(danmus: Vec<DanmuEntry>) -> String {
#[derive(Deserialize, Serialize, Clone)]
pub struct Danmu2AssOptions {
pub font_size: f64,
pub opacity: f64, // 透明度,范围 0.0-1.00.0为完全透明1.0为完全不透明
}
impl Default for Danmu2AssOptions {
fn default() -> Self {
Self {
font_size: 36.0,
opacity: 0.8, // 默认80%透明度
}
}
}
pub fn danmu_to_ass(danmus: Vec<DanmuEntry>, options: Danmu2AssOptions) -> String {
let font_size = options.font_size; // Default font size
let opacity = options.opacity; // 透明度参数
// 将透明度转换为十六进制Alpha值 (0.0-1.0 -> 0x00-0xFF)
let alpha = ((1.0 - opacity) * 255.0) as u8;
let alpha_hex = format!("{:02X}", alpha);
// ASS header
let header = r"[Script Info]
let header = format!(
r"[Script Info]
Title: Bilibili Danmaku
ScriptType: v4.00+
Collisions: Normal
@@ -42,14 +66,15 @@ Timer: 10.0000
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,微软雅黑,36,&H7fFFFFFF,&H7fFFFFFF,&H7f000000,&H7f000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
Style: Default,微软雅黑,{},&H{}FFFFFF,&H{}FFFFFF,&H{}000000,&H{}000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
";
",
font_size, alpha_hex, alpha_hex, alpha_hex, alpha_hex
);
let mut normal = normal_danmaku();
let font_size = 36.0; // Default font size
// Convert danmus to ASS events
let events = danmus

View File

@@ -1,4 +1,5 @@
use crate::config::Config;
use crate::danmu2ass::Danmu2AssOptions;
use crate::state::State;
use crate::state_type;
@@ -280,3 +281,18 @@ pub async fn update_webhook_url(state: state_type!(), webhook_url: String) -> Re
state.config.write().await.save();
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn update_danmu_ass_options(
state: state_type!(),
font_size: f64,
opacity: f64,
) -> Result<(), ()> {
log::info!("Updating danmu ass options");
state
.config
.write()
.await
.set_danmu_ass_options(Danmu2AssOptions { font_size, opacity });
Ok(())
}

View File

@@ -333,7 +333,10 @@ pub async fn export_danmu(
}
if options.ass {
Ok(danmu2ass::danmu_to_ass(danmus))
Ok(danmu2ass::danmu_to_ass(
danmus,
danmu2ass::Danmu2AssOptions::default(),
))
} else {
// map and join entries
Ok(danmus

View File

@@ -14,10 +14,11 @@ use crate::{
add_account, get_account_count, get_accounts, get_qr, get_qr_status, remove_account,
},
config::{
get_config, update_auto_generate, update_clip_name_format, update_notify,
update_openai_api_endpoint, update_openai_api_key, update_status_check_interval,
update_subtitle_generator_type, update_subtitle_setting, update_webhook_url,
update_whisper_language, update_whisper_model, update_whisper_prompt,
get_config, update_auto_generate, update_clip_name_format, update_danmu_ass_options,
update_notify, update_openai_api_endpoint, update_openai_api_key,
update_status_check_interval, update_subtitle_generator_type, update_subtitle_setting,
update_webhook_url, update_whisper_language, update_whisper_model,
update_whisper_prompt,
},
message::{delete_message, get_messages, read_message},
recorder::{
@@ -1050,6 +1051,21 @@ async fn handler_get_archives_by_parent_id(
Ok(Json(ApiResponse::success(archives)))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateDanmuAssOptionsRequest {
font_size: f64,
opacity: f64,
}
async fn handler_update_danmu_ass_options(
state: axum::extract::State<State>,
Json(param): Json<UpdateDanmuAssOptionsRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
update_danmu_ass_options(state.0, param.font_size, param.opacity).await;
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BatchImportExternalVideosRequest {

View File

@@ -430,6 +430,7 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
crate::handlers::config::update_status_check_interval,
crate::handlers::config::update_whisper_language,
crate::handlers::config::update_webhook_url,
crate::handlers::config::update_danmu_ass_options,
crate::handlers::message::get_messages,
crate::handlers::message::read_message,
crate::handlers::message::delete_message,

View File

@@ -888,7 +888,8 @@ impl RecorderManager {
return Ok(clip_file);
}
let ass_content = danmu2ass::danmu_to_ass(danmus);
let ass_content =
danmu2ass::danmu_to_ass(danmus, self.config.read().await.danmu_ass_options.clone());
// dump ass_content into a temp file
let ass_file_path = clip_file.with_extension("ass");
if let Err(e) = write(&ass_file_path, ass_content).await {
@@ -928,7 +929,8 @@ impl RecorderManager {
for d in &mut danmus {
d.ts -= first_segment_timestamp_milis;
}
let ass_content = danmu2ass::danmu_to_ass(danmus);
let ass_content =
danmu2ass::danmu_to_ass(danmus, self.config.read().await.danmu_ass_options.clone());
let work_dir = CachePath::new(
self.config.read().await.cache.clone().into(),
platform,

View File

@@ -150,6 +150,12 @@ export interface Config {
status_check_interval: number;
whisper_language: string;
webhook_url: string;
danmu_ass_options: Danmu2AssOptions;
}
export interface Danmu2AssOptions {
font_size: number;
opacity: number;
}
export interface AutoGenerateConfig {

View File

@@ -38,6 +38,10 @@
status_check_interval: 30, // 默认30秒
whisper_language: "",
webhook_url: "",
danmu_ass_options: {
font_size: 36,
opacity: 0.8,
},
};
let showModal = false;
@@ -148,6 +152,13 @@
});
}
async function update_danmu_ass_options() {
await invoke("update_danmu_ass_options", {
fontSize: setting_model.danmu_ass_options.font_size,
opacity: setting_model.danmu_ass_options.opacity,
});
}
onMount(async () => {
await get_config();
});
@@ -720,6 +731,73 @@
</div>
</div>
<!-- Danmu Style Settings -->
<div class="space-y-4">
<h2
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
>
<Captions class="w-5 h-5 dark:icon-white" />
<span>弹幕压制样式</span>
</h2>
<div
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"
>
<!-- Font Size -->
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
字体大小
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
设置弹幕字体大小
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="number"
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white w-24"
bind:value={setting_model.danmu_ass_options.font_size}
on:blur={update_danmu_ass_options}
min="12"
max="72"
step="1"
/>
</div>
</div>
</div>
<!-- Opacity -->
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
不透明度
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
设置弹幕不透明度,范围
0.0-1.00.0为完全透明1.0为完全不透明
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="number"
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white w-24"
bind:value={setting_model.danmu_ass_options.opacity}
on:blur={update_danmu_ass_options}
min="0.0"
max="1.0"
step="0.1"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Auto Clip Settings -->
<div class="space-y-4">
<h2