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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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="时长(秒)"
|
||||
|
||||
@@ -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="排序权重")
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -165,6 +165,133 @@ fn get_video_cache_size_cmd() -> Result<u64, String> {
|
||||
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() {
|
||||
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");
|
||||
|
||||
@@ -120,3 +120,12 @@ pub fn get_cover_avatars_json_path() -> Result<PathBuf, StorageError> {
|
||||
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<PathBuf, StorageError> {
|
||||
let base = get_app_data_dir()?;
|
||||
let path = base.join("bgm_cache");
|
||||
crate::storage::engine::ensure_dir(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface BgmMusicItem {
|
||||
artist: string | null;
|
||||
category: string;
|
||||
filePath: string;
|
||||
url: string | null;
|
||||
url: string;
|
||||
duration: number | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
@@ -132,11 +132,14 @@ export default function Settings() {
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
// 获取缓存大小
|
||||
// 获取媒体缓存大小(视频缓存 + BGM 缓存)
|
||||
const fetchCacheSize = useCallback(async () => {
|
||||
try {
|
||||
const size = await invoke<number>('get_video_cache_size_cmd');
|
||||
setCacheSize(size);
|
||||
const [videoSize, bgmSize] = await Promise.all([
|
||||
invoke<number>('get_video_cache_size_cmd'),
|
||||
invoke<number>('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<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);
|
||||
toast.success(`已清理 ${formatBytes(freed)} 缓存`);
|
||||
toast.success(`已清理 ${formatBytes(totalFreed)} 缓存`);
|
||||
} catch {
|
||||
toast.error('清理缓存失败');
|
||||
} finally {
|
||||
|
||||
@@ -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<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', {
|
||||
args: {
|
||||
videoPath: result,
|
||||
bgmPath: bgmFullPath,
|
||||
bgmPath: finalBgmPath,
|
||||
outputPath: result,
|
||||
videoVolume: 1.0,
|
||||
bgmVolume: (bgmVolume ?? 0.25),
|
||||
@@ -730,7 +750,7 @@ export default function VideoCompose() {
|
||||
<div
|
||||
className="modal-bgm-info"
|
||||
onClick={() => {
|
||||
setBgmMusic(item.id, item.title, item.url || item.filePath);
|
||||
setBgmMusic(item.id, item.title, item.url);
|
||||
setBgmModalOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user