From 4af42c157ec30d8812112122bc447e1b93c6dcb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Mon, 25 May 2026 00:05:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20BGM=20=E6=B7=B7=E9=9F=B3=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94URL=20=E5=85=88?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=88=B0=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=86=8D=E6=B7=B7=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/迁移 --- .github/workflows/release.yml | 4 + ...61a2f9c_make_bgm_music_url_non_nullable.py | 41 ++++++ python-api/app/models/bgm_music.py | 4 +- python-api/app/schemas/bgm_music.py | 2 +- python-api/scripts/seed_bgm.py | 2 +- tauri-app/src-tauri/src/ffmpeg_cmd.rs | 5 +- tauri-app/src-tauri/src/lib.rs | 138 +++++++++++++++++- tauri-app/src-tauri/src/storage/paths.rs | 9 ++ tauri-app/src/api/modules/bgmMusic.ts | 2 +- tauri-app/src/pages/Settings/Settings.tsx | 19 ++- .../src/pages/VideoCreation/VideoCompose.tsx | 28 +++- 11 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 python-api/alembic/versions/7149f61a2f9c_make_bgm_music_url_non_nullable.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc08549..d45ab52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,8 @@ jobs: name: Build macOS (Universal) if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'macos' }} runs-on: macos-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 @@ -121,6 +123,8 @@ jobs: name: Build Windows (x64) if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'windows' }} runs-on: windows-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 diff --git a/python-api/alembic/versions/7149f61a2f9c_make_bgm_music_url_non_nullable.py b/python-api/alembic/versions/7149f61a2f9c_make_bgm_music_url_non_nullable.py new file mode 100644 index 0000000..db5d314 --- /dev/null +++ b/python-api/alembic/versions/7149f61a2f9c_make_bgm_music_url_non_nullable.py @@ -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', + ) diff --git a/python-api/app/models/bgm_music.py b/python-api/app/models/bgm_music.py index 7c8aed4..d8cd403 100644 --- a/python-api/app/models/bgm_music.py +++ b/python-api/app/models/bgm_music.py @@ -36,8 +36,8 @@ class BgmMusic(BaseModelBigInt): file_path: Mapped[str] = mapped_column( String(512), nullable=False, comment="相对文件路径" ) - url: Mapped[str | None] = mapped_column( - String(1024), nullable=True, comment="七牛云 URL" + url: Mapped[str] = mapped_column( + String(1024), nullable=False, comment="七牛云 URL" ) duration: Mapped[float] = mapped_column( Float, nullable=True, comment="时长(秒)" diff --git a/python-api/app/schemas/bgm_music.py b/python-api/app/schemas/bgm_music.py index 772e29c..2eb12a4 100644 --- a/python-api/app/schemas/bgm_music.py +++ b/python-api/app/schemas/bgm_music.py @@ -14,7 +14,7 @@ class BgmMusicItem(BaseModel): artist: str | None = Field(default=None, description="艺术家") category: 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="时长(秒)") sort_order: int = Field(default=0, description="排序权重") diff --git a/python-api/scripts/seed_bgm.py b/python-api/scripts/seed_bgm.py index 2876cee..e4819fc 100644 --- a/python-api/scripts/seed_bgm.py +++ b/python-api/scripts/seed_bgm.py @@ -56,7 +56,7 @@ async def seed_bgm(): artist=item.get("artist"), category=item["category"], file_path=item["file_path"], - url=item.get("url"), + url=item["url"], duration=item.get("duration"), status=item.get("status", "active"), sort_order=item.get("sort_order", idx), diff --git a/tauri-app/src-tauri/src/ffmpeg_cmd.rs b/tauri-app/src-tauri/src/ffmpeg_cmd.rs index f787d3b..2d11f71 100644 --- a/tauri-app/src-tauri/src/ffmpeg_cmd.rs +++ b/tauri-app/src-tauri/src/ffmpeg_cmd.rs @@ -504,7 +504,7 @@ pub async fn burn_ass_subtitle_with_fonts( * 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。 * 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。 * - * 注意:bgm_path 来自应用资源目录,不做 validate_safe_path 检查。 + * BGM 路径必须是本地文件(由前端下载到 bgm_cache 后传入)。 */ pub async fn mix_bgm_to_video( app: &AppHandle, @@ -515,8 +515,7 @@ pub async fn mix_bgm_to_video( bgm_volume: f64, ) -> Result<(), String> { let safe_video = validate_safe_path(video_path)?; - // BGM 来自应用资源目录,直接传递(路径由前端通过 Tauri path API 解析) - let safe_bgm = bgm_path.to_string(); + let safe_bgm = validate_safe_path(bgm_path)?; let safe_output = sanitize_output_path(output_path)?; // 构建 filter_complex: diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 95b1243..148e523 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -165,6 +165,133 @@ fn get_video_cache_size_cmd() -> Result { 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 { + 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 { + 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 { + 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() { crate::storage::init_app_data_dir(app_data_dir.clone()); - // 后台清理过期视频缓存,不阻塞首屏 - std::thread::spawn(move || clean_video_cache(&app_data_dir)); + // 后台清理过期视频缓存和 BGM 缓存,不阻塞首屏 + 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=false,setup 阶段先显示窗口 @@ -330,6 +461,9 @@ pub fn run() { // 缓存清理 get_video_cache_size_cmd, clear_video_cache_cmd, + get_bgm_cache_size_cmd, + clear_bgm_cache_cmd, + get_bgm_cache_dir, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/tauri-app/src-tauri/src/storage/paths.rs b/tauri-app/src-tauri/src/storage/paths.rs index 7b9d0d9..2feb027 100644 --- a/tauri-app/src-tauri/src/storage/paths.rs +++ b/tauri-app/src-tauri/src/storage/paths.rs @@ -120,3 +120,12 @@ pub fn get_cover_avatars_json_path() -> Result { let base = get_app_data_dir()?; Ok(base.join("cover_avatars.json")) } + +/// 获取 BGM 缓存目录 +/// {app_local_data_dir}/bgm_cache/ +pub fn get_bgm_cache_dir() -> Result { + let base = get_app_data_dir()?; + let path = base.join("bgm_cache"); + crate::storage::engine::ensure_dir(&path)?; + Ok(path) +} diff --git a/tauri-app/src/api/modules/bgmMusic.ts b/tauri-app/src/api/modules/bgmMusic.ts index ff48251..3ece604 100644 --- a/tauri-app/src/api/modules/bgmMusic.ts +++ b/tauri-app/src/api/modules/bgmMusic.ts @@ -6,7 +6,7 @@ export interface BgmMusicItem { artist: string | null; category: string; filePath: string; - url: string | null; + url: string; duration: number | null; sortOrder: number; } diff --git a/tauri-app/src/pages/Settings/Settings.tsx b/tauri-app/src/pages/Settings/Settings.tsx index bc68bf5..8a274fa 100644 --- a/tauri-app/src/pages/Settings/Settings.tsx +++ b/tauri-app/src/pages/Settings/Settings.tsx @@ -132,11 +132,14 @@ export default function Settings() { }, 2000); }, []); - // 获取缓存大小 + // 获取媒体缓存大小(视频缓存 + BGM 缓存) const fetchCacheSize = useCallback(async () => { try { - const size = await invoke('get_video_cache_size_cmd'); - setCacheSize(size); + const [videoSize, bgmSize] = await Promise.all([ + invoke('get_video_cache_size_cmd'), + invoke('get_bgm_cache_size_cmd'), + ]); + setCacheSize(videoSize + bgmSize); } catch { setCacheSize(0); } @@ -151,7 +154,7 @@ export default function Settings() { }; }, [fetchCacheSize]); - // 清理缓存 + // 清理媒体缓存(视频缓存 + BGM 缓存) const handleClearCache = async () => { if (cacheSize === 0) { toast.info('暂无缓存需要清理'); @@ -159,9 +162,13 @@ export default function Settings() { } setClearingCache(true); try { - const freed = await invoke('clear_video_cache_cmd'); + const [videoFreed, bgmFreed] = await Promise.all([ + invoke('clear_video_cache_cmd'), + invoke('clear_bgm_cache_cmd'), + ]); + const totalFreed = videoFreed + bgmFreed; setCacheSize(0); - toast.success(`已清理 ${formatBytes(freed)} 缓存`); + toast.success(`已清理 ${formatBytes(totalFreed)} 缓存`); } catch { toast.error('清理缓存失败'); } finally { diff --git a/tauri-app/src/pages/VideoCreation/VideoCompose.tsx b/tauri-app/src/pages/VideoCreation/VideoCompose.tsx index 37a887e..2c5ac8e 100644 --- a/tauri-app/src/pages/VideoCreation/VideoCompose.tsx +++ b/tauri-app/src/pages/VideoCreation/VideoCompose.tsx @@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from 'react'; import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { save } from '@tauri-apps/plugin-dialog'; +import { exists } from '@tauri-apps/plugin-fs'; import { compositeApi } from '../../api/modules/videoComposite'; +import { downloadFile } from '../../api/modules/videoCompose'; import { bgmMusicApi, type BgmMusicItem } from '../../api/modules/bgmMusic'; import { pointsApi } from '../../api/modules/points'; import { useProjectStore, saveMetaToLocalFile } from '../../store'; @@ -110,7 +112,11 @@ export default function VideoCompose() { if (audioRef.current) { 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); audio.play().catch(() => {}); audio.onended = () => setPreviewId(null); @@ -264,11 +270,25 @@ export default function VideoCompose() { // 4. 如果选择了 BGM,混合背景音乐 if (bgmMusicPath) { useProgressStore.getState().update('正在混合背景音乐...'); - const bgmFullPath = bgmMusicPath; + let finalBgmPath = bgmMusicPath; + + // BGM 为 URL 时,先下载到本地缓存 + if (bgmMusicPath.startsWith('http')) { + const cacheDir = await invoke('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', { args: { videoPath: result, - bgmPath: bgmFullPath, + bgmPath: finalBgmPath, outputPath: result, videoVolume: 1.0, bgmVolume: (bgmVolume ?? 0.25), @@ -730,7 +750,7 @@ export default function VideoCompose() {
{ - setBgmMusic(item.id, item.title, item.url || item.filePath); + setBgmMusic(item.id, item.title, item.url); setBgmModalOpen(false); }} >