fix: 修复 BGM 本地上传、封面形象样式、ESLint 清零、access log 关闭

- BGM 本地上传改用 Tauri open 对话框,修复 path 为空导致混音失效
- Rust 端放宽 BGM 路径验证(系统文件选择器选取的文件),加路径遍历防护
- BGM 混音失败时 toast 提示,不再静默忽略
- 我的作品页增加导出功能
- 封面形象卡片样式统一为 works-card 体系
- 关闭 uvicorn access log(Dockerfile + 3 个 compose)
- ESLint 全绿:关掉 prop-types/incompatible-library,修复 curly/exhaustive-deps/any/unused-vars
- .gitignore 排除 *.exe 构建产物
This commit is contained in:
小鱼开发
2026-05-27 18:37:33 +08:00
parent 603650cfb3
commit 11a85bfee7
22 changed files with 127 additions and 62 deletions
+2
View File
@@ -31,3 +31,5 @@ tauri-app/src-tauri/binaries/*
*test*.key* *test*.key*
.atomcode/ .atomcode/
mixkit_bgm/ mixkit_bgm/
*.exe
*.exe.sig
+1 -1
View File
@@ -46,4 +46,4 @@ COPY pyproject.toml .
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
+1 -1
View File
@@ -31,7 +31,7 @@ services:
- ~/Documents/Meijiaka-zy:/root/Documents/Meijiaka-zy - ~/Documents/Meijiaka-zy:/root/Documents/Meijiaka-zy
ports: ports:
- "8081:8000" - "8081:8000"
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --no-access-log
networks: networks:
- meijiaka-network - meijiaka-network
+1 -1
View File
@@ -34,7 +34,7 @@ services:
volumes: volumes:
# 仅持久化日志到宿主机,其他数据走对象存储 # 仅持久化日志到宿主机,其他数据走对象存储
- /opt/meijiaka-zy/logs:/root/Documents/Meijiaka-zy/logs - /opt/meijiaka-zy/logs:/root/Documents/Meijiaka-zy/logs
command: alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 command: alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log
ports: ports:
- "8000:8000" - "8000:8000"
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -68,7 +68,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
command: > command: >
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"
networks: networks:
- meijiaka-zy - meijiaka-zy
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -108,7 +108,7 @@ END $$;
DO $$ DO $$
DECLARE DECLARE
v_mobile TEXT := '13860199646'; -- ← 修改:目标用户手机号 v_mobile TEXT := '18750556093'; -- ← 修改:目标用户手机号
v_gift_points INT := 5000; -- ← 修改:赠送积分数量 v_gift_points INT := 5000; -- ← 修改:赠送积分数量
v_gift_days INT := 180; -- ← 修改:有效期(天) v_gift_days INT := 180; -- ← 修改:有效期(天)
v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述) v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述)
+2
View File
@@ -36,7 +36,9 @@ export default tseslint.config(
...react.configs.recommended.rules, ...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules, ...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'react/prop-types': 'off', // TypeScript 已有类型检查,无需 PropTypes
'react-hooks/set-state-in-effect': 'warn', 'react-hooks/set-state-in-effect': 'warn',
'react-hooks/incompatible-library': 'off', // TanStack Virtual 等常见库误报
'react-refresh/only-export-components': 'warn', 'react-refresh/only-export-components': 'warn',
'react/no-unescaped-entities': 'warn', 'react/no-unescaped-entities': 'warn',
+7 -1
View File
@@ -560,7 +560,13 @@ 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)?;
let safe_bgm = validate_safe_path(bgm_path)?; // BGM 可以是用户通过系统文件选择器选取的任意本地文件,
// 验证文件存在且不含路径遍历字符
let safe_bgm = if !bgm_path.contains("..") && std::path::Path::new(bgm_path).exists() {
bgm_path.to_string()
} else {
return Err(format!("BGM 文件不存在或路径非法: {}", bgm_path));
};
let safe_output = sanitize_output_path(output_path)?; let safe_output = sanitize_output_path(output_path)?;
// 构建 filter_complex: // 构建 filter_complex:
@@ -72,6 +72,7 @@ export async function generateEmptyShotClip(args: {
outputPath: string; outputPath: string;
}): Promise<string> { }): Promise<string> {
const res = await invoke<ApiResponse<string>>('generate_empty_shot_clip', { args }); const res = await invoke<ApiResponse<string>>('generate_empty_shot_clip', { args });
// eslint-disable-next-line eqeqeq
if (res.code !== 200 || res.data == null) { if (res.code !== 200 || res.data == null) {
throw new Error(res.message || '生成空镜片段失败'); throw new Error(res.message || '生成空镜片段失败');
} }
@@ -93,6 +94,7 @@ export async function concatVideoClips(
projectId, projectId,
clipPaths, clipPaths,
}); });
// eslint-disable-next-line eqeqeq
if (res.code !== 200 || res.data == null) { if (res.code !== 200 || res.data == null) {
throw new Error(res.message || '视频拼接失败'); throw new Error(res.message || '视频拼接失败');
} }
@@ -108,6 +110,7 @@ export async function concatVideoClips(
*/ */
export async function downloadFile(url: string, outputPath: string): Promise<string> { export async function downloadFile(url: string, outputPath: string): Promise<string> {
const res = await invoke<ApiResponse<string>>('download_file', { url, outputPath }); const res = await invoke<ApiResponse<string>>('download_file', { url, outputPath });
// eslint-disable-next-line eqeqeq
if (res.code !== 200 || res.data == null) { if (res.code !== 200 || res.data == null) {
throw new Error(res.message || '下载文件失败'); throw new Error(res.message || '下载文件失败');
} }
+1 -1
View File
@@ -57,7 +57,7 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
// 点击外部关闭用户菜单 // 点击外部关闭用户菜单
useEffect(() => { useEffect(() => {
if (!showUserMenu) return; if (!showUserMenu) {return;}
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowUserMenu(false); setShowUserMenu(false);
@@ -32,7 +32,8 @@ export default function PricingModal({ open, onClose }: PricingModalProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!open || rules.length > 0) return; if (!open || rules.length > 0) {return;}
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true); setLoading(true);
pointsApi.getRules() pointsApi.getRules()
.then(setRules) .then(setRules)
@@ -46,7 +46,7 @@ export default function UpdateDialog() {
} }
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) {return '0 B';}
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB']; const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
+2 -2
View File
@@ -48,7 +48,7 @@ export interface UpdaterActions {
* 如果 notes 中包含 [强制更新] 或 [mandatory],视为强制更新 * 如果 notes 中包含 [强制更新] 或 [mandatory],视为强制更新
*/ */
function parseMandatory(notes: string | undefined): boolean { function parseMandatory(notes: string | undefined): boolean {
if (!notes) return false; if (!notes) {return false;}
return notes.includes('[强制更新]') || notes.includes('[mandatory]'); return notes.includes('[强制更新]') || notes.includes('[mandatory]');
} }
@@ -107,7 +107,7 @@ export function useUpdater(): UpdaterState & UpdaterActions {
const downloadAndInstall = useCallback(async () => { const downloadAndInstall = useCallback(async () => {
const update = updateRef.current; const update = updateRef.current;
if (!update) return; if (!update) {return;}
setState(s => ({ setState(s => ({
...s, ...s,
@@ -1127,11 +1127,11 @@
flex: 1; flex: 1;
} }
/* 封面形象网格 — 复用 avatar-card 风格 */ /* 封面形象网格 — 复用 works-card 风格 */
.cover-avatar-grid { .cover-avatar-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-xl); gap: var(--spacing-lg);
padding: var(--spacing-sm) var(--spacing-xs); padding: var(--spacing-sm) var(--spacing-xs);
flex: 1; flex: 1;
align-content: start; align-content: start;
@@ -1139,6 +1139,21 @@
overflow-y: auto; overflow-y: auto;
} }
/* 封面形象:封面区域使用 3:4 比例(照片尺寸) */
.cover-avatar-grid .works-card-cover {
aspect-ratio: 3 / 4;
}
.cover-avatar-grid .works-card-cover::before {
padding-top: 133.33%; /* 4/3 * 100 = 133.33% for 3:4 ratio */
}
/* 封面形象:图片使用 contain 以适应透明背景 */
.cover-avatar-grid .works-card-poster {
object-fit: contain;
background: var(--bg-input);
}
/* Content Page Compact variant */ /* Content Page Compact variant */
.content-page-compact { .content-page-compact {
gap: var(--spacing-md); gap: var(--spacing-md);
@@ -1659,11 +1674,12 @@
/* 网格布局 - 一行4个 */ /* 网格布局 - 一行4个 */
.works-grid { .works-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-lg); gap: var(--spacing-lg);
flex: 1; flex: 1;
align-content: start; align-content: start;
align-items: start; align-items: start;
grid-auto-rows: min-content;
} }
/* 成品卡片 */ /* 成品卡片 */
@@ -1702,6 +1718,13 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* 确保封面高度始终正确(兼容 aspect-ratio 支持不佳的环境) */
.works-card-cover::before {
content: '';
display: block;
padding-top: 177.78%; /* 16/9 * 100 = 177.78% for 9:16 ratio */
}
.works-card-poster { .works-card-poster {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -309,40 +309,39 @@ export default function CoverAvatarLibrary() {
) : ( ) : (
<div className="cover-avatar-grid"> <div className="cover-avatar-grid">
{coverAvatars.map(a => ( {coverAvatars.map(a => (
<div key={a.id} className="avatar-card"> <div key={a.id} className="works-card">
{/* 图片预览 */} {/* 图片预览 */}
<div className="avatar-card-thumb-container"> <div className="works-card-cover">
<img <img
src={a.imageUrl} src={a.imageUrl}
alt={a.name} alt={a.name}
className="avatar-card-video" className="works-card-poster"
style={{ objectFit: 'contain', background: 'var(--bg-input)' }}
loading="lazy" loading="lazy"
/> />
{/* 悬停操作按钮 */} {/* 悬停操作按钮 */}
<div className="avatar-card-actions"> <div className="works-card-overlay" onClick={(e) => e.stopPropagation()}>
<button <button
className="avatar-card-action-btn" className="works-card-action"
onClick={() => startRename(a.id, a.name)} onClick={() => startRename(a.id, a.name)}
title="重命名" title="重命名"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg>
</button> </button>
<button <button
className="avatar-card-action-btn delete" className="works-card-action delete"
onClick={() => openDeleteModal(a.id, a.name)} onClick={() => openDeleteModal(a.id, a.name)}
title="删除" title="删除"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /></svg>
</button> </button>
</div> </div>
</div> </div>
{/* 名称 */} {/* 名称 */}
<div className="avatar-card-info"> <div className="works-card-info">
{editingId === a.id ? ( {editingId === a.id ? (
<input <input
type="text" type="text"
className="avatar-card-name-input" className="works-card-rename-input"
value={editingName} value={editingName}
onChange={e => setEditingName(e.target.value)} onChange={e => setEditingName(e.target.value)}
onKeyDown={e => { onKeyDown={e => {
@@ -353,7 +352,7 @@ export default function CoverAvatarLibrary() {
autoFocus autoFocus
/> />
) : ( ) : (
<div className="avatar-card-name">{a.name}</div> <div className="works-card-name" title={a.name}>{a.name}</div>
)} )}
</div> </div>
</div> </div>
@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { openPath } from '@tauri-apps/plugin-opener'; import { openPath } from '@tauri-apps/plugin-opener';
import { save } from '@tauri-apps/plugin-dialog';
import { getLocalFileUrl } from '../../utils/fileUrl'; import { getLocalFileUrl } from '../../utils/fileUrl';
import { toast } from '../../store/uiStore';
import { useNavigation } from '../../contexts/NavigationContext'; import { useNavigation } from '../../contexts/NavigationContext';
import { switchProject } from '../../store/projectStore'; import { switchProject } from '../../store/projectStore';
import { localProjectApi } from '../../api/modules/localStorage'; import { localProjectApi } from '../../api/modules/localStorage';
@@ -106,7 +108,7 @@ function ProductCard({ product, onDelete, onRename }: {
let canceled = false; let canceled = false;
getLocalFileUrl(product.path) getLocalFileUrl(product.path)
.then(url => { .then(url => {
if (!canceled) setVideoUrl(url); if (!canceled) {setVideoUrl(url);}
}) })
.catch(err => { .catch(err => {
console.error('[ProductCard] 加载视频失败:', err); console.error('[ProductCard] 加载视频失败:', err);
@@ -165,6 +167,29 @@ function ProductCard({ product, onDelete, onRename }: {
const handleOpen = () => openPath(product.path); const handleOpen = () => openPath(product.path);
const handleExport = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const filename = product.path.split(/[\\/]/).pop() || 'video.mp4';
const targetPath = await save({
defaultPath: filename,
filters: [{ name: '视频', extensions: ['mp4'] }],
});
if (!targetPath) {return;}
const res = await invoke<{ code: number; data?: string; message: string }>('export_product', {
sourcePath: product.path,
targetPath,
});
if (res.code === 200) {
toast.success('导出成功');
} else {
toast.error(res.message || '导出失败');
}
} catch {
toast.error('导出失败');
}
};
const startRename = (e: React.MouseEvent) => { const startRename = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setIsRenaming(true); setIsRenaming(true);
@@ -210,6 +235,9 @@ function ProductCard({ product, onDelete, onRename }: {
</div> </div>
)} )}
<div className="works-card-overlay" onClick={(e) => e.stopPropagation()}> <div className="works-card-overlay" onClick={(e) => e.stopPropagation()}>
<button className="works-card-action" title="导出" onClick={handleExport}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
</button>
<button className="works-card-action" title="重命名" onClick={startRename}> <button className="works-card-action" title="重命名" onClick={startRename}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg>
</button> </button>
@@ -308,7 +336,7 @@ export default function MyWorks() {
useEffect(() => { useEffect(() => {
const el = productsGridRef.current; const el = productsGridRef.current;
if (!el) return; if (!el) {return;}
const ro = new ResizeObserver(entries => { const ro = new ResizeObserver(entries => {
const width = entries[0].contentRect.width; const width = entries[0].contentRect.width;
// 卡片最小 200px + gap 16px (var(--spacing-lg)) // 卡片最小 200px + gap 16px (var(--spacing-lg))
@@ -449,7 +477,7 @@ export default function MyWorks() {
{activeTab === 'products' && ( {activeTab === 'products' && (
<> <>
{products.length > 0 ? ( {products.length > 0 ? (
<div ref={productsGridRef} className="works-grid" style={{ overflow: 'auto', flex: 1, minHeight: 0, display: enableProductVirtualization ? 'block' : undefined }}> <div ref={productsGridRef} className="works-grid" style={{ overflow: 'auto', flex: 1, minHeight: 0, display: enableProductVirtualization ? 'block' : 'grid', gridTemplateColumns: enableProductVirtualization ? undefined : `repeat(${productColumns}, 1fr)` }}>
{enableProductVirtualization ? ( {enableProductVirtualization ? (
<div style={{ height: `${productsVirtualizer.getTotalSize()}px`, position: 'relative', width: '100%' }}> <div style={{ height: `${productsVirtualizer.getTotalSize()}px`, position: 'relative', width: '100%' }}>
{productsVirtualizer.getVirtualItems().map(virtualRow => { {productsVirtualizer.getVirtualItems().map(virtualRow => {
+2 -2
View File
@@ -35,7 +35,7 @@ export default function Settings() {
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) {return '0 B';}
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB']; const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
@@ -66,7 +66,7 @@ export default function Settings() {
}; };
const handleDownloadAndInstall = async () => { const handleDownloadAndInstall = async () => {
if (!updateInfo) return; if (!updateInfo) {return;}
setDownloading(true); setDownloading(true);
setProgress(0); setProgress(0);
@@ -10,6 +10,10 @@
*/ */
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
interface FileWithPath extends File {
path?: string;
}
import { useCoverAvatarStore } from '../../store'; import { useCoverAvatarStore } from '../../store';
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';
@@ -175,7 +179,7 @@ export default function CoverDesign() {
// 本地上传背景图 // 本地上传背景图
const handleLocalBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLocalBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) {return;}
// 释放旧的 Blob URL // 释放旧的 Blob URL
const oldBg = config.backgroundImage; const oldBg = config.backgroundImage;
@@ -184,7 +188,7 @@ export default function CoverDesign() {
blobUrlsRef.current.delete(oldBg); blobUrlsRef.current.delete(oldBg);
} }
const path = (file as any).path || (file as any).webkitRelativePath || ''; const path = (file as FileWithPath).path || (file as FileWithPath).webkitRelativePath || '';
if (!path) { if (!path) {
// Tauri 文件选择器通常有 path,如果没有则使用 Object URL // Tauri 文件选择器通常有 path,如果没有则使用 Object URL
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
@@ -215,9 +219,10 @@ export default function CoverDesign() {
// 组件卸载时释放所有 Blob URL // 组件卸载时释放所有 Blob URL
useEffect(() => { useEffect(() => {
const urls = blobUrlsRef.current;
return () => { return () => {
blobUrlsRef.current.forEach(url => URL.revokeObjectURL(url)); urls.forEach(url => URL.revokeObjectURL(url));
blobUrlsRef.current.clear(); urls.clear();
}; };
}, []); }, []);
@@ -240,7 +245,7 @@ export default function CoverDesign() {
// 背景图/形象变化时自动保存 coverConfig(防抖,只监听视觉素材) // 背景图/形象变化时自动保存 coverConfig(防抖,只监听视觉素材)
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) {return;}
const timer = setTimeout(() => { const timer = setTimeout(() => {
useProjectStore.getState().setCoverConfig({ useProjectStore.getState().setCoverConfig({
template: config.template, template: config.template,
@@ -251,6 +256,7 @@ export default function CoverDesign() {
}); });
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.backgroundImage, config.avatarImage, projectId]); }, [config.backgroundImage, config.avatarImage, projectId]);
// 配置变化时重新渲染 Canvas // 配置变化时重新渲染 Canvas
@@ -681,7 +687,7 @@ export default function CoverDesign() {
} else { } else {
toast.error(res.message || '导出失败'); toast.error(res.message || '导出失败');
} }
} catch (e) { } catch {
toast.error('导出失败'); toast.error('导出失败');
} }
}} }}
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'; 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 { open, save } from '@tauri-apps/plugin-dialog';
import { exists } from '@tauri-apps/plugin-fs'; 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 { downloadFile } from '../../api/modules/videoCompose';
@@ -61,7 +61,7 @@ export default function VideoCompose() {
const setBgmVolume = useProjectStore(state => state.setBgmVolume); const setBgmVolume = useProjectStore(state => state.setBgmVolume);
const [, setBgmLoading] = useState(false); const [, setBgmLoading] = useState(false);
const [bgmModalOpen, setBgmModalOpen] = useState(false); const [bgmModalOpen, setBgmModalOpen] = useState(false);
const localBgmRef = useRef<HTMLInputElement | null>(null);
// 打开弹窗时根据当前选中的 BGM 分类自动切换标签 // 打开弹窗时根据当前选中的 BGM 分类自动切换标签
useEffect(() => { useEffect(() => {
@@ -73,7 +73,7 @@ export default function VideoCompose() {
setBgmCategory('all'); setBgmCategory('all');
} }
} }
}, [bgmModalOpen]); }, [bgmModalOpen, bgmList, bgmMusicId]);
// 弹窗关闭时停止试听 // 弹窗关闭时停止试听
useEffect(() => { useEffect(() => {
@@ -159,15 +159,16 @@ export default function VideoCompose() {
}, []); }, []);
// 本地上传 BGM // 本地上传 BGM
const handleLocalBgmUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLocalBgmUpload = async () => {
const file = e.target.files?.[0]; const selected = await open({
if (!file) return; multiple: false,
const path = (file as any).path || ''; filters: [{ name: '音频文件', extensions: ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] }],
if (path) { });
setBgmMusic(-1, file.name, path); if (selected && typeof selected === 'string') {
const fileName = selected.split(/[\\/]/).pop() || '本地音频';
setBgmMusic(-1, fileName, selected);
setBgmModalOpen(false);
} }
setBgmModalOpen(false);
e.target.value = '';
}; };
const shots = segments || []; const shots = segments || [];
@@ -261,7 +262,7 @@ export default function VideoCompose() {
const resolvedCoverPath = coverPath; const resolvedCoverPath = coverPath;
// 3. 调用 Rust 合成,直接输出到 products 目录 // 3. 调用 Rust 合成,直接输出到 products 目录
let result = await compositeApi.synthesis({ const result = await compositeApi.synthesis({
video_paths: videoPaths, video_paths: videoPaths,
cover_path: resolvedCoverPath, cover_path: resolvedCoverPath,
output_path: outputPath, output_path: outputPath,
@@ -295,7 +296,7 @@ export default function VideoCompose() {
}, },
}); });
if (mixRes.code !== 200) { if (mixRes.code !== 200) {
console.warn('BGM 混合失败,使用无 BGM 版本:', mixRes.message); toast.warning('BGM 混合失败,使用无 BGM 版本: ' + mixRes.message);
} }
} }
@@ -791,7 +792,7 @@ export default function VideoCompose() {
<div className="modal-bgm-footer"> <div className="modal-bgm-footer">
<button <button
className="modal-bgm-upload-btn" className="modal-bgm-upload-btn"
onClick={() => localBgmRef.current?.click()} onClick={() => { void handleLocalBgmUpload(); }}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
@@ -800,13 +801,7 @@ export default function VideoCompose() {
</svg> </svg>
</button> </button>
<input
ref={localBgmRef}
type="file"
accept="audio/*"
style={{ display: 'none' }}
onChange={handleLocalBgmUpload}
/>
<button className="btn btn-secondary btn-sm" onClick={() => setBgmModalOpen(false)}></button> <button className="btn btn-secondary btn-sm" onClick={() => setBgmModalOpen(false)}></button>
</div> </div>
</div> </div>
@@ -396,7 +396,7 @@ export default function VoiceSynthesis() {
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
}, [projectId, segments, handleAlignAndClip, checkBalance, handleError, estimatedTtsPoints]); }, [projectId, segments, handleAlignAndClip, checkBalance, handleError, estimatedTtsPoints, clearStepDirty]);
const { navigate } = useNavigation(); const { navigate } = useNavigation();
@@ -381,7 +381,7 @@ export function useVideoGeneration({
const baseVideoPoints = Math.ceil(totalAudioDuration) * videoMultiplier; const baseVideoPoints = Math.ceil(totalAudioDuration) * videoMultiplier;
// 系统素材额外收费:每个空镜使用的系统素材额外 2 积分 // 系统素材额外收费:每个空镜使用的系统素材额外 2 积分
const systemMaterialCount = shots.filter((s: ScriptShot) => { const systemMaterialCount = shots.filter((s: ScriptShot) => {
if (s.type !== 'empty_shot') return false; if (s.type !== 'empty_shot') {return false;}
const matched = materialMatchMap[String(s.id)]; const matched = materialMatchMap[String(s.id)];
return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://')); return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://'));
}).length; }).length;
@@ -163,7 +163,7 @@ export default function VideoGeneration() {
} }
// 系统素材额外收费:每个空镜使用的系统素材额外 2 积分 // 系统素材额外收费:每个空镜使用的系统素材额外 2 积分
const systemMaterialCount = shots.filter((s) => { const systemMaterialCount = shots.filter((s) => {
if (s.type !== 'empty_shot') return false; if (s.type !== 'empty_shot') {return false;}
const matched = materialMatchMap[String(s.id)]; const matched = materialMatchMap[String(s.id)];
return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://')); return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://'));
}).length; }).length;
@@ -326,7 +326,7 @@ export default function VideoGeneration() {
// 匹配成功后自动切换到对应镜头并预览,但只处理最新请求的结果, // 匹配成功后自动切换到对应镜头并预览,但只处理最新请求的结果,
// 避免用户快速连点多个镜头时,后返回的请求把画面切回之前的镜头。 // 避免用户快速连点多个镜头时,后返回的请求把画面切回之前的镜头。
const handleMatchMaterial = async (shotId: string) => { const handleMatchMaterial = async (shotId: string) => {
if (matchingShotIds.has(shotId)) return; if (matchingShotIds.has(shotId)) {return;}
setMatchingShotIds(prev => new Set(prev).add(shotId)); setMatchingShotIds(prev => new Set(prev).add(shotId));
const requestId = ++matchRequestIdRef.current; const requestId = ++matchRequestIdRef.current;