fix: BGM 混音链路修复——URL 先下载到本地缓存再混音

- fix: 删除 BGM 预览硬编码开发者路径,改为使用 url 字段
- fix: BGM 混音前检测是否为 URL,先下载到 bgm_cache 本地缓存
- fix: Rust mix_bgm_to_video 恢复 validate_safe_path 校验,拒绝 URL
- feat: 新增 bgm_cache 目录及自动清理策略(30天/200MB上限)
- feat: Settings 缓存清理扩展为媒体缓存(video + BGM 统一清理)
- chore: BGM url 字段改为后端必填,同步 schema/model/seed/迁移
This commit is contained in:
小鱼开发
2026-05-25 00:05:02 +08:00
parent 818fe7cc03
commit 4af42c157e
11 changed files with 234 additions and 20 deletions
+4
View File
@@ -25,6 +25,8 @@ jobs:
name: Build macOS (Universal) name: Build macOS (Universal)
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'macos' }} if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'macos' }}
runs-on: macos-latest runs-on: macos-latest
permissions:
contents: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -121,6 +123,8 @@ jobs:
name: Build Windows (x64) name: Build Windows (x64)
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'windows' }} if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'windows' }}
runs-on: windows-latest runs-on: windows-latest
permissions:
contents: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -0,0 +1,41 @@
"""make_bgm_music_url_non_nullable
Revision ID: 7149f61a2f9c
Revises: 7172a476e5b2
Create Date: 2026-05-21 10:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7149f61a2f9c'
down_revision: Union[str, Sequence[str], None] = '100366516fbd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# BGM 云端化改造后,url 字段为必填(七牛云 CDN 地址)
op.alter_column(
'mjk_bgm_musics',
'url',
existing_type=sa.String(length=1024),
nullable=False,
comment='七牛云 URL',
)
def downgrade() -> None:
"""Downgrade schema."""
op.alter_column(
'mjk_bgm_musics',
'url',
existing_type=sa.String(length=1024),
nullable=True,
comment='七牛云 URL',
)
+2 -2
View File
@@ -36,8 +36,8 @@ class BgmMusic(BaseModelBigInt):
file_path: Mapped[str] = mapped_column( file_path: Mapped[str] = mapped_column(
String(512), nullable=False, comment="相对文件路径" String(512), nullable=False, comment="相对文件路径"
) )
url: Mapped[str | None] = mapped_column( url: Mapped[str] = mapped_column(
String(1024), nullable=True, comment="七牛云 URL" String(1024), nullable=False, comment="七牛云 URL"
) )
duration: Mapped[float] = mapped_column( duration: Mapped[float] = mapped_column(
Float, nullable=True, comment="时长(秒)" Float, nullable=True, comment="时长(秒)"
+1 -1
View File
@@ -14,7 +14,7 @@ class BgmMusicItem(BaseModel):
artist: str | None = Field(default=None, description="艺术家") artist: str | None = Field(default=None, description="艺术家")
category: str = Field(description="场景分类") category: str = Field(description="场景分类")
file_path: str = Field(description="相对文件路径") file_path: str = Field(description="相对文件路径")
url: str | None = Field(default=None, description="七牛云 URL") url: str = Field(description="七牛云 URL")
duration: float | None = Field(default=None, description="时长(秒)") duration: float | None = Field(default=None, description="时长(秒)")
sort_order: int = Field(default=0, description="排序权重") sort_order: int = Field(default=0, description="排序权重")
+1 -1
View File
@@ -56,7 +56,7 @@ async def seed_bgm():
artist=item.get("artist"), artist=item.get("artist"),
category=item["category"], category=item["category"],
file_path=item["file_path"], file_path=item["file_path"],
url=item.get("url"), url=item["url"],
duration=item.get("duration"), duration=item.get("duration"),
status=item.get("status", "active"), status=item.get("status", "active"),
sort_order=item.get("sort_order", idx), sort_order=item.get("sort_order", idx),
+2 -3
View File
@@ -504,7 +504,7 @@ pub async fn burn_ass_subtitle_with_fonts(
* 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。 * 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。
* 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。 * 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。
* *
* 注意:bgm_path 来自应用资源目录,不做 validate_safe_path 检查 * BGM 路径必须是本地文件(由前端下载到 bgm_cache 后传入)
*/ */
pub async fn mix_bgm_to_video( pub async fn mix_bgm_to_video(
app: &AppHandle, app: &AppHandle,
@@ -515,8 +515,7 @@ pub async fn mix_bgm_to_video(
bgm_volume: f64, bgm_volume: f64,
) -> Result<(), String> { ) -> Result<(), String> {
let safe_video = validate_safe_path(video_path)?; let safe_video = validate_safe_path(video_path)?;
// BGM 来自应用资源目录,直接传递(路径由前端通过 Tauri path API 解析) let safe_bgm = validate_safe_path(bgm_path)?;
let safe_bgm = bgm_path.to_string();
let safe_output = sanitize_output_path(output_path)?; let safe_output = sanitize_output_path(output_path)?;
// 构建 filter_complex: // 构建 filter_complex:
+136 -2
View File
@@ -165,6 +165,133 @@ fn get_video_cache_size_cmd() -> Result<u64, String> {
Ok(total) Ok(total)
} }
// ============================================================
// BGM 缓存清理
// ============================================================
/// 清理 bgm_cache 目录
///
/// 策略:和视频缓存一致
/// 1. 删除超过 30 天未修改的文件
/// 2. 总容量超过 200MB 时,按修改时间删最旧文件直到 100MB
fn clean_bgm_cache(app_data_dir: &std::path::Path) {
let cache_dir = app_data_dir.join("bgm_cache");
if !cache_dir.exists() {
return;
}
let max_age = std::time::Duration::from_secs(30 * 24 * 60 * 60);
let max_total_size: u64 = 200 * 1024 * 1024;
let target_size: u64 = 100 * 1024 * 1024;
let now = std::time::SystemTime::now();
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime, u64)> = Vec::new();
let mut total_size: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => {
eprintln!("[bgm_cache] 无法读取缓存目录: {}", e);
return;
}
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if !metadata.is_file() { continue; }
let mtime = metadata.modified().unwrap_or(now);
let size = metadata.len();
total_size += size;
entries.push((entry.path(), mtime, size));
}
// 1. 删除超过 30 天的文件
let mut deleted_size: u64 = 0;
for (path, mtime, size) in &entries {
if now.duration_since(*mtime).unwrap_or_default() > max_age {
if std::fs::remove_file(path).is_ok() {
deleted_size += size;
}
}
}
// 2. 容量超限,删最旧的
let remaining_size = total_size.saturating_sub(deleted_size);
if remaining_size > max_total_size {
let mut sorted = entries;
sorted.sort_by_key(|(_, mtime, _)| *mtime);
let mut to_free = remaining_size.saturating_sub(target_size);
for (path, _, size) in sorted {
if to_free == 0 { break; }
if path.exists() && std::fs::remove_file(&path).is_ok() {
to_free = to_free.saturating_sub(size);
}
}
}
}
/// 手动清理 bgm_cache 目录,返回释放的字节数
#[tauri::command]
fn clear_bgm_cache_cmd() -> Result<u64, String> {
let app_data_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| e.to_string())?;
let cache_dir = app_data_dir.join("bgm_cache");
if !cache_dir.exists() {
return Ok(0);
}
let mut freed: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if !metadata.is_file() { continue; }
let size = metadata.len();
if std::fs::remove_file(entry.path()).is_ok() {
freed += size;
}
}
Ok(freed)
}
/// 获取 bgm_cache 目录当前占用大小(字节)
#[tauri::command]
fn get_bgm_cache_size_cmd() -> Result<u64, String> {
let app_data_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| e.to_string())?;
let cache_dir = app_data_dir.join("bgm_cache");
if !cache_dir.exists() {
return Ok(0);
}
let mut total: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if metadata.is_file() {
total += metadata.len();
}
}
Ok(total)
}
/// 获取 BGM 缓存目录路径(供前端下载 BGM 时使用)
#[tauri::command]
fn get_bgm_cache_dir() -> Result<String, String> {
crate::storage::paths::get_bgm_cache_dir()
.map(|p| p.to_string_lossy().to_string())
.map_err(|e| e.to_string())
}
// ============================================================ // ============================================================
// 应用入口 // 应用入口
// ============================================================ // ============================================================
@@ -189,8 +316,12 @@ pub fn run() {
// 初始化应用数据目录(所有业务数据的根目录) // 初始化应用数据目录(所有业务数据的根目录)
if let Ok(app_data_dir) = app.path().app_local_data_dir() { if let Ok(app_data_dir) = app.path().app_local_data_dir() {
crate::storage::init_app_data_dir(app_data_dir.clone()); crate::storage::init_app_data_dir(app_data_dir.clone());
// 后台清理过期视频缓存,不阻塞首屏 // 后台清理过期视频缓存和 BGM 缓存,不阻塞首屏
std::thread::spawn(move || clean_video_cache(&app_data_dir)); let app_data_dir_clone = app_data_dir.clone();
std::thread::spawn(move || {
clean_video_cache(&app_data_dir_clone);
clean_bgm_cache(&app_data_dir_clone);
});
} }
// 窗口初始 visible=falsesetup 阶段先显示窗口 // 窗口初始 visible=falsesetup 阶段先显示窗口
@@ -330,6 +461,9 @@ pub fn run() {
// 缓存清理 // 缓存清理
get_video_cache_size_cmd, get_video_cache_size_cmd,
clear_video_cache_cmd, clear_video_cache_cmd,
get_bgm_cache_size_cmd,
clear_bgm_cache_cmd,
get_bgm_cache_dir,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+9
View File
@@ -120,3 +120,12 @@ pub fn get_cover_avatars_json_path() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?; let base = get_app_data_dir()?;
Ok(base.join("cover_avatars.json")) Ok(base.join("cover_avatars.json"))
} }
/// 获取 BGM 缓存目录
/// {app_local_data_dir}/bgm_cache/
pub fn get_bgm_cache_dir() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
let path = base.join("bgm_cache");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
+1 -1
View File
@@ -6,7 +6,7 @@ export interface BgmMusicItem {
artist: string | null; artist: string | null;
category: string; category: string;
filePath: string; filePath: string;
url: string | null; url: string;
duration: number | null; duration: number | null;
sortOrder: number; sortOrder: number;
} }
+13 -6
View File
@@ -132,11 +132,14 @@ export default function Settings() {
}, 2000); }, 2000);
}, []); }, []);
// 获取缓存大小 // 获取媒体缓存大小(视频缓存 + BGM 缓存)
const fetchCacheSize = useCallback(async () => { const fetchCacheSize = useCallback(async () => {
try { try {
const size = await invoke<number>('get_video_cache_size_cmd'); const [videoSize, bgmSize] = await Promise.all([
setCacheSize(size); invoke<number>('get_video_cache_size_cmd'),
invoke<number>('get_bgm_cache_size_cmd'),
]);
setCacheSize(videoSize + bgmSize);
} catch { } catch {
setCacheSize(0); setCacheSize(0);
} }
@@ -151,7 +154,7 @@ export default function Settings() {
}; };
}, [fetchCacheSize]); }, [fetchCacheSize]);
// 清理缓存 // 清理媒体缓存(视频缓存 + BGM 缓存)
const handleClearCache = async () => { const handleClearCache = async () => {
if (cacheSize === 0) { if (cacheSize === 0) {
toast.info('暂无缓存需要清理'); toast.info('暂无缓存需要清理');
@@ -159,9 +162,13 @@ export default function Settings() {
} }
setClearingCache(true); setClearingCache(true);
try { try {
const freed = await invoke<number>('clear_video_cache_cmd'); const [videoFreed, bgmFreed] = await Promise.all([
invoke<number>('clear_video_cache_cmd'),
invoke<number>('clear_bgm_cache_cmd'),
]);
const totalFreed = videoFreed + bgmFreed;
setCacheSize(0); setCacheSize(0);
toast.success(`已清理 ${formatBytes(freed)} 缓存`); toast.success(`已清理 ${formatBytes(totalFreed)} 缓存`);
} catch { } catch {
toast.error('清理缓存失败'); toast.error('清理缓存失败');
} finally { } finally {
@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from 'react';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog'; import { save } from '@tauri-apps/plugin-dialog';
import { exists } from '@tauri-apps/plugin-fs';
import { compositeApi } from '../../api/modules/videoComposite'; import { compositeApi } from '../../api/modules/videoComposite';
import { downloadFile } from '../../api/modules/videoCompose';
import { bgmMusicApi, type BgmMusicItem } from '../../api/modules/bgmMusic'; import { bgmMusicApi, type BgmMusicItem } from '../../api/modules/bgmMusic';
import { pointsApi } from '../../api/modules/points'; import { pointsApi } from '../../api/modules/points';
import { useProjectStore, saveMetaToLocalFile } from '../../store'; import { useProjectStore, saveMetaToLocalFile } from '../../store';
@@ -110,7 +112,11 @@ export default function VideoCompose() {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.onended = null; audioRef.current.onended = null;
} }
const audioSrc = item.url || (item.filePath ? `/Users/0fun/work/meijiaka-zy/mixkit_bgm/${item.filePath}` : ''); const audioSrc = item.url;
if (!audioSrc) {
toast.warning('该音乐暂无可用的试听链接');
return;
}
const audio = new Audio(audioSrc); const audio = new Audio(audioSrc);
audio.play().catch(() => {}); audio.play().catch(() => {});
audio.onended = () => setPreviewId(null); audio.onended = () => setPreviewId(null);
@@ -264,11 +270,25 @@ export default function VideoCompose() {
// 4. 如果选择了 BGM,混合背景音乐 // 4. 如果选择了 BGM,混合背景音乐
if (bgmMusicPath) { if (bgmMusicPath) {
useProgressStore.getState().update('正在混合背景音乐...'); useProgressStore.getState().update('正在混合背景音乐...');
const bgmFullPath = bgmMusicPath; let finalBgmPath = bgmMusicPath;
// BGM 为 URL 时,先下载到本地缓存
if (bgmMusicPath.startsWith('http')) {
const cacheDir = await invoke<string>('get_bgm_cache_dir');
const ext = bgmMusicPath.split('.').pop() || 'mp3';
const cachedPath = `${cacheDir}/bgm_${bgmMusicId}.${ext}`;
const fileExists = await exists(cachedPath);
if (!fileExists) {
useProgressStore.getState().update('正在下载背景音乐...');
await downloadFile(bgmMusicPath, cachedPath);
}
finalBgmPath = cachedPath;
}
const mixRes = await invoke<{ code: number; data?: string; message: string }>('mix_bgm_to_video', { const mixRes = await invoke<{ code: number; data?: string; message: string }>('mix_bgm_to_video', {
args: { args: {
videoPath: result, videoPath: result,
bgmPath: bgmFullPath, bgmPath: finalBgmPath,
outputPath: result, outputPath: result,
videoVolume: 1.0, videoVolume: 1.0,
bgmVolume: (bgmVolume ?? 0.25), bgmVolume: (bgmVolume ?? 0.25),
@@ -730,7 +750,7 @@ export default function VideoCompose() {
<div <div
className="modal-bgm-info" className="modal-bgm-info"
onClick={() => { onClick={() => {
setBgmMusic(item.id, item.title, item.url || item.filePath); setBgmMusic(item.id, item.title, item.url);
setBgmModalOpen(false); setBgmModalOpen(false);
}} }}
> >