Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5f1098831 | |||
| 11a85bfee7 |
@@ -31,3 +31,5 @@ tauri-app/src-tauri/binaries/*
|
||||
*test*.key*
|
||||
.atomcode/
|
||||
mixkit_bgm/
|
||||
*.exe
|
||||
*.exe.sig
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
**美家卡智影**是一款面向桌面端的 AI 视频创作应用,采用"Python 后端 API + Tauri 桌面前端"的混合架构。
|
||||
|
||||
- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-zy`
|
||||
- **版本**: `1.6.6`
|
||||
- **版本**: `1.6.7`
|
||||
- **核心功能**: AI 脚本生成、AI 配音合成(TTS)、声音复刻、视频生成(Vidu)、视频字幕生成、压制成片(FFmpeg)、项目本地持久化
|
||||
|
||||
### 技术栈总览
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# === 基础配置 ===
|
||||
APP_NAME=美家卡智影 API
|
||||
APP_VERSION=1.6.6
|
||||
APP_VERSION=1.6.7
|
||||
# ⚠️ 生产环境必须设为 false
|
||||
DEBUG=true
|
||||
ENV=development
|
||||
|
||||
@@ -46,4 +46,4 @@ COPY pyproject.toml .
|
||||
|
||||
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"]
|
||||
|
||||
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 应用基础配置
|
||||
APP_NAME: str = Field(default="美家卡智影 API", description="应用名称")
|
||||
APP_VERSION: str = Field(default="1.6.6", description="应用版本")
|
||||
APP_VERSION: str = Field(default="1.6.7", description="应用版本")
|
||||
DEBUG: bool = Field(default=False, description="调试模式")
|
||||
ENV: Literal["development", "staging", "production"] = Field(
|
||||
default="development", description="运行环境"
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- ~/Documents/Meijiaka-zy:/root/Documents/Meijiaka-zy
|
||||
ports:
|
||||
- "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:
|
||||
- meijiaka-network
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
volumes:
|
||||
# 仅持久化日志到宿主机,其他数据走对象存储
|
||||
- /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:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
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:
|
||||
- meijiaka-zy
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "meijiaka-ai-api"
|
||||
version = "1.6.6"
|
||||
version = "1.6.7"
|
||||
description = "美家卡智影 - AI 视频创作后端 API"
|
||||
authors = [{ name = "Meijiaka Team" }]
|
||||
readme = "README.md"
|
||||
|
||||
Generated
+1
-1
@@ -944,7 +944,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meijiaka-ai-api"
|
||||
version = "1.6.6"
|
||||
version = "1.6.7"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -108,7 +108,7 @@ END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_mobile TEXT := '13860199646'; -- ← 修改:目标用户手机号
|
||||
v_mobile TEXT := '18750556093'; -- ← 修改:目标用户手机号
|
||||
v_gift_points INT := 5000; -- ← 修改:赠送积分数量
|
||||
v_gift_days INT := 180; -- ← 修改:有效期(天)
|
||||
v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
**美家卡智影**(产品名)是一款基于 Tauri v2 + React 19 + TypeScript 的桌面端 AI 视频创作应用。
|
||||
|
||||
- **产品标识**: `cn.meijiaka.ai-video`
|
||||
- **版本**: `1.6.6`
|
||||
- **版本**: `1.6.7`
|
||||
- **窗口尺寸**: 1200×800,不可缩放(`resizable: false`)
|
||||
- **核心功能**: AI 脚本生成、AI 配音合成、视频生成、压制成片(FFmpeg)、项目本地持久化
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ export default tseslint.config(
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/prop-types': 'off', // TypeScript 已有类型检查,无需 PropTypes
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/incompatible-library': 'off', // TanStack Virtual 等常见库误报
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'react/no-unescaped-entities': 'warn',
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tauri-app",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.7",
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -4219,7 +4219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-app"
|
||||
version = "1.6.6"
|
||||
version = "1.6.7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
version = "1.6.6"
|
||||
version = "1.6.7"
|
||||
description = "美家卡智影 - AI 视频创作桌面应用"
|
||||
authors = ["美家卡科技"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -560,7 +560,13 @@ pub async fn mix_bgm_to_video(
|
||||
bgm_volume: f64,
|
||||
) -> Result<(), String> {
|
||||
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)?;
|
||||
|
||||
// 构建 filter_complex:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "美家卡智影",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.7",
|
||||
"identifier": "cn.meijiaka.ai-zy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function generateEmptyShotClip(args: {
|
||||
outputPath: string;
|
||||
}): Promise<string> {
|
||||
const res = await invoke<ApiResponse<string>>('generate_empty_shot_clip', { args });
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (res.code !== 200 || res.data == null) {
|
||||
throw new Error(res.message || '生成空镜片段失败');
|
||||
}
|
||||
@@ -93,6 +94,7 @@ export async function concatVideoClips(
|
||||
projectId,
|
||||
clipPaths,
|
||||
});
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (res.code !== 200 || res.data == null) {
|
||||
throw new Error(res.message || '视频拼接失败');
|
||||
}
|
||||
@@ -108,6 +110,7 @@ export async function concatVideoClips(
|
||||
*/
|
||||
export async function downloadFile(url: string, outputPath: string): Promise<string> {
|
||||
const res = await invoke<ApiResponse<string>>('download_file', { url, outputPath });
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (res.code !== 200 || res.data == null) {
|
||||
throw new Error(res.message || '下载文件失败');
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
|
||||
|
||||
// 点击外部关闭用户菜单
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
if (!showUserMenu) {return;}
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
|
||||
@@ -32,7 +32,8 @@ export default function PricingModal({ open, onClose }: PricingModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
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);
|
||||
pointsApi.getRules()
|
||||
.then(setRules)
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function UpdateDialog() {
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) {return '0 B';}
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface UpdaterActions {
|
||||
* 如果 notes 中包含 [强制更新] 或 [mandatory],视为强制更新
|
||||
*/
|
||||
function parseMandatory(notes: string | undefined): boolean {
|
||||
if (!notes) return false;
|
||||
if (!notes) {return false;}
|
||||
return notes.includes('[强制更新]') || notes.includes('[mandatory]');
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export function useUpdater(): UpdaterState & UpdaterActions {
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
if (!update) {return;}
|
||||
|
||||
setState(s => ({
|
||||
...s,
|
||||
|
||||
@@ -1127,11 +1127,11 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 封面形象网格 — 复用 avatar-card 风格 */
|
||||
/* 封面形象网格 — 复用 works-card 风格 */
|
||||
.cover-avatar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-sm) var(--spacing-xs);
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
@@ -1139,6 +1139,21 @@
|
||||
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 {
|
||||
gap: var(--spacing-md);
|
||||
@@ -1659,11 +1674,12 @@
|
||||
/* 网格布局 - 一行4个 */
|
||||
.works-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);
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
grid-auto-rows: min-content;
|
||||
}
|
||||
|
||||
/* 成品卡片 */
|
||||
@@ -1702,6 +1718,13 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -309,40 +309,39 @@ export default function CoverAvatarLibrary() {
|
||||
) : (
|
||||
<div className="cover-avatar-grid">
|
||||
{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
|
||||
src={a.imageUrl}
|
||||
alt={a.name}
|
||||
className="avatar-card-video"
|
||||
style={{ objectFit: 'contain', background: 'var(--bg-input)' }}
|
||||
className="works-card-poster"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* 悬停操作按钮 */}
|
||||
<div className="avatar-card-actions">
|
||||
<div className="works-card-overlay" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="avatar-card-action-btn"
|
||||
className="works-card-action"
|
||||
onClick={() => startRename(a.id, a.name)}
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
className="avatar-card-action-btn delete"
|
||||
className="works-card-action delete"
|
||||
onClick={() => openDeleteModal(a.id, a.name)}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="avatar-card-info">
|
||||
<div className="works-card-info">
|
||||
{editingId === a.id ? (
|
||||
<input
|
||||
type="text"
|
||||
className="avatar-card-name-input"
|
||||
className="works-card-rename-input"
|
||||
value={editingName}
|
||||
onChange={e => setEditingName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
@@ -353,7 +352,7 @@ export default function CoverAvatarLibrary() {
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-card-name">{a.name}</div>
|
||||
<div className="works-card-name" title={a.name}>{a.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { openPath } from '@tauri-apps/plugin-opener';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { getLocalFileUrl } from '../../utils/fileUrl';
|
||||
import { toast } from '../../store/uiStore';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import { switchProject } from '../../store/projectStore';
|
||||
import { localProjectApi } from '../../api/modules/localStorage';
|
||||
@@ -106,7 +108,7 @@ function ProductCard({ product, onDelete, onRename }: {
|
||||
let canceled = false;
|
||||
getLocalFileUrl(product.path)
|
||||
.then(url => {
|
||||
if (!canceled) setVideoUrl(url);
|
||||
if (!canceled) {setVideoUrl(url);}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[ProductCard] 加载视频失败:', err);
|
||||
@@ -165,6 +167,29 @@ function ProductCard({ product, onDelete, onRename }: {
|
||||
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
@@ -210,6 +235,9 @@ function ProductCard({ product, onDelete, onRename }: {
|
||||
</div>
|
||||
)}
|
||||
<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}>
|
||||
<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>
|
||||
@@ -308,7 +336,7 @@ export default function MyWorks() {
|
||||
|
||||
useEffect(() => {
|
||||
const el = productsGridRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {return;}
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const width = entries[0].contentRect.width;
|
||||
// 卡片最小 200px + gap 16px (var(--spacing-lg))
|
||||
@@ -449,7 +477,7 @@ export default function MyWorks() {
|
||||
{activeTab === 'products' && (
|
||||
<>
|
||||
{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 ? (
|
||||
<div style={{ height: `${productsVirtualizer.getTotalSize()}px`, position: 'relative', width: '100%' }}>
|
||||
{productsVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function Settings() {
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) {return '0 B';}
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
@@ -66,7 +66,7 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
const handleDownloadAndInstall = async () => {
|
||||
if (!updateInfo) return;
|
||||
if (!updateInfo) {return;}
|
||||
|
||||
setDownloading(true);
|
||||
setProgress(0);
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface FileWithPath extends File {
|
||||
path?: string;
|
||||
}
|
||||
import { useCoverAvatarStore } from '../../store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
@@ -175,7 +179,7 @@ export default function CoverDesign() {
|
||||
// 本地上传背景图
|
||||
const handleLocalBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file) {return;}
|
||||
|
||||
// 释放旧的 Blob URL
|
||||
const oldBg = config.backgroundImage;
|
||||
@@ -184,7 +188,7 @@ export default function CoverDesign() {
|
||||
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) {
|
||||
// Tauri 文件选择器通常有 path,如果没有则使用 Object URL
|
||||
const url = URL.createObjectURL(file);
|
||||
@@ -215,9 +219,10 @@ export default function CoverDesign() {
|
||||
|
||||
// 组件卸载时释放所有 Blob URL
|
||||
useEffect(() => {
|
||||
const urls = blobUrlsRef.current;
|
||||
return () => {
|
||||
blobUrlsRef.current.forEach(url => URL.revokeObjectURL(url));
|
||||
blobUrlsRef.current.clear();
|
||||
urls.forEach(url => URL.revokeObjectURL(url));
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -240,7 +245,7 @@ export default function CoverDesign() {
|
||||
|
||||
// 背景图/形象变化时自动保存 coverConfig(防抖,只监听视觉素材)
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
if (!projectId) {return;}
|
||||
const timer = setTimeout(() => {
|
||||
useProjectStore.getState().setCoverConfig({
|
||||
template: config.template,
|
||||
@@ -251,6 +256,7 @@ export default function CoverDesign() {
|
||||
});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.backgroundImage, config.avatarImage, projectId]);
|
||||
|
||||
// 配置变化时重新渲染 Canvas
|
||||
@@ -681,7 +687,7 @@ export default function CoverDesign() {
|
||||
} else {
|
||||
toast.error(res.message || '导出失败');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('导出失败');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { open, 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';
|
||||
@@ -61,7 +61,7 @@ export default function VideoCompose() {
|
||||
const setBgmVolume = useProjectStore(state => state.setBgmVolume);
|
||||
const [, setBgmLoading] = useState(false);
|
||||
const [bgmModalOpen, setBgmModalOpen] = useState(false);
|
||||
const localBgmRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
|
||||
// 打开弹窗时根据当前选中的 BGM 分类自动切换标签
|
||||
useEffect(() => {
|
||||
@@ -73,7 +73,7 @@ export default function VideoCompose() {
|
||||
setBgmCategory('all');
|
||||
}
|
||||
}
|
||||
}, [bgmModalOpen]);
|
||||
}, [bgmModalOpen, bgmList, bgmMusicId]);
|
||||
|
||||
// 弹窗关闭时停止试听
|
||||
useEffect(() => {
|
||||
@@ -159,15 +159,16 @@ export default function VideoCompose() {
|
||||
}, []);
|
||||
|
||||
// 本地上传 BGM
|
||||
const handleLocalBgmUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const path = (file as any).path || '';
|
||||
if (path) {
|
||||
setBgmMusic(-1, file.name, path);
|
||||
const handleLocalBgmUpload = async () => {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: '音频文件', extensions: ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] }],
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
const fileName = selected.split(/[\\/]/).pop() || '本地音频';
|
||||
setBgmMusic(-1, fileName, selected);
|
||||
setBgmModalOpen(false);
|
||||
}
|
||||
setBgmModalOpen(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const shots = segments || [];
|
||||
@@ -261,7 +262,7 @@ export default function VideoCompose() {
|
||||
const resolvedCoverPath = coverPath;
|
||||
|
||||
// 3. 调用 Rust 合成,直接输出到 products 目录
|
||||
let result = await compositeApi.synthesis({
|
||||
const result = await compositeApi.synthesis({
|
||||
video_paths: videoPaths,
|
||||
cover_path: resolvedCoverPath,
|
||||
output_path: outputPath,
|
||||
@@ -295,7 +296,7 @@ export default function VideoCompose() {
|
||||
},
|
||||
});
|
||||
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">
|
||||
<button
|
||||
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">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
@@ -800,13 +801,7 @@ export default function VideoCompose() {
|
||||
</svg>
|
||||
本地上传
|
||||
</button>
|
||||
<input
|
||||
ref={localBgmRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleLocalBgmUpload}
|
||||
/>
|
||||
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setBgmModalOpen(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -396,7 +396,7 @@ export default function VoiceSynthesis() {
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [projectId, segments, handleAlignAndClip, checkBalance, handleError, estimatedTtsPoints]);
|
||||
}, [projectId, segments, handleAlignAndClip, checkBalance, handleError, estimatedTtsPoints, clearStepDirty]);
|
||||
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ export function useVideoGeneration({
|
||||
const baseVideoPoints = Math.ceil(totalAudioDuration) * videoMultiplier;
|
||||
// 系统素材额外收费:每个空镜使用的系统素材额外 2 积分
|
||||
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)];
|
||||
return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://'));
|
||||
}).length;
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function VideoGeneration() {
|
||||
}
|
||||
// 系统素材额外收费:每个空镜使用的系统素材额外 2 积分
|
||||
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)];
|
||||
return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://'));
|
||||
}).length;
|
||||
@@ -326,7 +326,7 @@ export default function VideoGeneration() {
|
||||
// 匹配成功后自动切换到对应镜头并预览,但只处理最新请求的结果,
|
||||
// 避免用户快速连点多个镜头时,后返回的请求把画面切回之前的镜头。
|
||||
const handleMatchMaterial = async (shotId: string) => {
|
||||
if (matchingShotIds.has(shotId)) return;
|
||||
if (matchingShotIds.has(shotId)) {return;}
|
||||
setMatchingShotIds(prev => new Set(prev).add(shotId));
|
||||
|
||||
const requestId = ++matchRequestIdRef.current;
|
||||
|
||||
Reference in New Issue
Block a user