feat: 封面人物形象 + 素材匹配优化 + Windows 预览修复
- 新增 cover_avatar 积分类型和弹窗支持 - Modal 组件支持 maxHeight 属性 - 素材匹配增加 loading 状态(匹配中...) - 修复 Windows 视频预览:统一 handlePreview、preload=metadata、修复 Rust UNC 路径 - 修复进度条倒退问题 - 更新运营脚本 - 新增 Windows 11 开发环境搭建文档
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
# Windows 11 开发环境搭建指南
|
||||
|
||||
> 适用场景:全新重装系统后的 Windows 11,国内网络环境。
|
||||
|
||||
---
|
||||
|
||||
## 前置说明
|
||||
|
||||
- **WebView2**:Windows 11 自带,无需安装。
|
||||
- **WSL2**:Windows 11 默认支持,Docker Desktop 会自动启用。
|
||||
- **全程使用 cmd + 官网 .exe 安装包**,不依赖 PowerShell 脚本。
|
||||
|
||||
---
|
||||
|
||||
## 一、基础工具安装(图形界面,双击下一步)
|
||||
|
||||
按顺序安装,装完一个再装下一个。
|
||||
|
||||
### 1. Git
|
||||
|
||||
- 下载:https://git-scm.com/download/win
|
||||
- 安装:全默认,一路 Next。
|
||||
|
||||
### 2. Node.js 22 LTS
|
||||
|
||||
- 下载:https://nodejs.org/
|
||||
- 安装:勾选 **"Automatically install necessary tools"**(会自动装 Python 2.7 等构建工具)。
|
||||
|
||||
### 3. Visual Studio Build Tools 2022
|
||||
|
||||
- 下载:https://aka.ms/vs/17/release/vs_BuildTools.exe
|
||||
- 安装:只勾选 **"使用 C++ 的桌面开发"**(约 8GB),其他全取消。
|
||||
|
||||
### 4. Rust
|
||||
|
||||
- 下载:https://rustup.rs/ → 点击 `rustup-init.exe (64-bit)`
|
||||
- 安装:选 **1) Proceed with default installation**(默认 MSVC 工具链)。
|
||||
|
||||
---
|
||||
|
||||
## 二、国内镜像配置(cmd 执行)
|
||||
|
||||
打开 **cmd(Win+R → cmd)**,逐行执行:
|
||||
|
||||
```cmd
|
||||
:: ========== npm 镜像 ==========
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
:: ========== Rust 镜像 ==========
|
||||
mkdir "%USERPROFILE%\.cargo" 2>nul
|
||||
|
||||
echo [source.crates-io] > "%USERPROFILE%\.cargo\config.toml"
|
||||
echo replace-with = 'ustc' >> "%USERPROFILE%\.cargo\config.toml"
|
||||
echo [source.ustc] >> "%USERPROFILE%\.cargo\config.toml"
|
||||
echo registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/" >> "%USERPROFILE%\.cargo\config.toml"
|
||||
|
||||
setx RUSTUP_UPDATE_ROOT https://mirrors.ustc.edu.cn/rust-static/rustup
|
||||
setx RUSTUP_DIST_SERVER https://mirrors.ustc.edu.cn/rust-static
|
||||
|
||||
:: ========== Python 镜像(预留,方案 B 用到) ==========
|
||||
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
echo 镜像配置完成,请关闭并重新打开 cmd
|
||||
```
|
||||
|
||||
**执行完后,关闭 cmd,重新打开**,再执行验证:
|
||||
|
||||
```cmd
|
||||
npm config get registry
|
||||
cargo --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、方案 A:只跑前端(连测试环境后端)
|
||||
|
||||
### 1. 拉代码
|
||||
|
||||
```cmd
|
||||
git clone <你的仓库地址>
|
||||
cd meijiaka-zy\tauri-app
|
||||
```
|
||||
|
||||
### 2. 装依赖
|
||||
|
||||
```cmd
|
||||
npm ci
|
||||
```
|
||||
|
||||
### 3. 启动
|
||||
|
||||
```cmd
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
前端默认连接 `https://dev.tapi.meijiaka.cn/api/v1`,无需本地后端。
|
||||
|
||||
---
|
||||
|
||||
## 四、方案 B:前后端都本地跑
|
||||
|
||||
在方案 A 基础上继续。
|
||||
|
||||
### 1. Python 3.13
|
||||
|
||||
- 下载:https://www.python.org/ftp/python/3.13.0/python-3.13.0-amd64.exe
|
||||
- 安装:**务必勾选 "Add python.exe to PATH"**,然后 Install Now。
|
||||
|
||||
### 2. 安装 uv
|
||||
|
||||
```cmd
|
||||
pip install uv
|
||||
```
|
||||
|
||||
### 3. Docker Desktop
|
||||
|
||||
- 下载:https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe
|
||||
- 安装:默认,装完**重启电脑**。
|
||||
- 重启后打开 Docker Desktop,等左下角状态变绿。
|
||||
|
||||
### 4. 后端启动
|
||||
|
||||
```cmd
|
||||
cd meijiaka-zy\python-api
|
||||
|
||||
:: 安装依赖
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
:: 复制环境变量
|
||||
copy .env.example .env
|
||||
|
||||
:: 启动数据库
|
||||
docker compose -f docker-compose.test.yml up -d db redis
|
||||
|
||||
:: 数据库迁移
|
||||
alembic upgrade head
|
||||
|
||||
:: 启动 API(终端 1)
|
||||
make run
|
||||
:: 或:uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
如果需要异步调度器(脚本/TTS/字幕生成等),另开终端:
|
||||
|
||||
```cmd
|
||||
cd meijiaka-zy\python-api
|
||||
make scheduler
|
||||
:: 或:python -m app.scheduler.main
|
||||
```
|
||||
|
||||
### 5. 前端启动(连本地后端)
|
||||
|
||||
```cmd
|
||||
cd meijiaka-zy\tauri-app
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
前端 Vite 开发服务器会代理 API 请求到 `localhost:8000`。如果代理异常,检查 `tauri-app/src/api/client.ts` 中的 `PYTHON_API_BASE_URL`。
|
||||
|
||||
---
|
||||
|
||||
## 五、验证清单
|
||||
|
||||
全部装完后,在 cmd 里执行:
|
||||
|
||||
```cmd
|
||||
git --version
|
||||
node -v
|
||||
npm -v
|
||||
rustc --version
|
||||
cargo --version
|
||||
python --version
|
||||
uv --version
|
||||
docker --version
|
||||
```
|
||||
|
||||
每个都要有版本号输出。
|
||||
|
||||
---
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| `npm ci` 卡住 | 镜像没配好 | 检查 `npm config get registry` 是否为 `registry.npmmirror.com` |
|
||||
| `cargo build` 卡住 | Rust 镜像没生效 | 关闭 cmd 重新打开,或检查 `%USERPROFILE%\.cargo\config.toml` |
|
||||
| Tauri 编译报错 `link.exe not found` | VS Build Tools 没装 C++ 桌面开发 | 重装,确保勾选了该工作负载 |
|
||||
| `tauri dev` 白屏 | 前端代理地址错误 | 检查 `client.ts` 里的 base URL |
|
||||
| Docker 启动失败 | WSL2 未启用 | 控制面板 → 程序和功能 → 启用 Windows 功能 → 勾选 **适用于 Linux 的 Windows 子系统** |
|
||||
| `python` 命令找不到 | 安装时没勾选 Add to PATH | 重装 Python,务必勾选 |
|
||||
| `alembic` 命令找不到 | 没在虚拟环境里 | 确保在 `python-api` 目录下执行,`uv pip install` 已经装了 |
|
||||
@@ -108,8 +108,8 @@ END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_mobile TEXT := '13800138000'; -- ← 修改:目标用户手机号
|
||||
v_gift_points INT := 500; -- ← 修改:赠送积分数量
|
||||
v_mobile TEXT := '13860199646'; -- ← 修改:目标用户手机号
|
||||
v_gift_points INT := 5000; -- ← 修改:赠送积分数量
|
||||
v_gift_days INT := 180; -- ← 修改:有效期(天)
|
||||
v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述)
|
||||
v_user_id UUID;
|
||||
|
||||
Generated
+1
-1
@@ -4219,7 +4219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-app"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -29,6 +29,16 @@ pub fn escape_ffmpeg_path(path: &str) -> String {
|
||||
path.replace("'", "'\\''")
|
||||
}
|
||||
|
||||
/// 去掉 Windows UNC 前缀(\\?\),使 convertFileSrc 能正确生成 asset:// URL
|
||||
fn normalize_path(path: &std::path::Path) -> String {
|
||||
let s = path.to_string_lossy().to_string();
|
||||
if s.starts_with(r"\\?\") {
|
||||
std::path::PathBuf::from(&s[4..]).to_string_lossy().to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证路径在允许的目录内,防止路径遍历攻击
|
||||
/// 允许的目录:应用数据目录(app_local_data_dir)
|
||||
fn validate_safe_path(path: &str) -> Result<String, String> {
|
||||
@@ -910,7 +920,7 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
|
||||
&& resolution_ok;
|
||||
|
||||
if can_skip {
|
||||
return Ok(path_str);
|
||||
return Ok(normalize_path(&std::path::Path::new(&path_str)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
width?: string;
|
||||
centerTitle?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
@@ -17,6 +18,7 @@ export default function Modal({
|
||||
children,
|
||||
width = '560px',
|
||||
centerTitle = false,
|
||||
maxHeight,
|
||||
}: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -45,7 +47,15 @@ export default function Modal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`modal-container animate-slideUp ${!title ? 'modal-container--no-title' : ''}`} style={{ width: '90%', maxWidth: width, position: 'relative' }}>
|
||||
<div
|
||||
className={`modal-container animate-slideUp ${!title ? 'modal-container--no-title' : ''}`}
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: width,
|
||||
position: 'relative',
|
||||
...(maxHeight ? { maxHeight } : {}),
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<div
|
||||
className="modal-header"
|
||||
|
||||
@@ -12,6 +12,7 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
compose: '压制成片',
|
||||
subtitle_burn: '字幕烧录',
|
||||
cover_design: '封面设计',
|
||||
cover_avatar: '封面人物形象',
|
||||
caption: '字幕生成',
|
||||
};
|
||||
|
||||
@@ -40,7 +41,7 @@ export default function PricingModal({ open, onClose }: PricingModalProps) {
|
||||
}, [open, rules.length]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} width="600px">
|
||||
<Modal open={open} onClose={onClose} width="600px" maxHeight="none">
|
||||
{loading ? (
|
||||
<div className="profile-pricing-loading">
|
||||
加载中...
|
||||
|
||||
@@ -307,6 +307,12 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recharge-modal-footer .btn.btn-ghost {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 订单信息 */
|
||||
.recharge-order-info {
|
||||
display: flex;
|
||||
|
||||
@@ -208,6 +208,7 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
compose: '压制成片',
|
||||
subtitle_burn: '字幕烧录',
|
||||
cover_design: '封面设计',
|
||||
cover_avatar: '封面人物形象',
|
||||
caption: '字幕生成',
|
||||
};
|
||||
|
||||
@@ -217,6 +218,7 @@ const SOURCE_TYPE_OPTIONS = [
|
||||
{ value: 'polish', label: '文案润色' },
|
||||
{ value: 'title', label: '标题生成' },
|
||||
{ value: 'tts', label: '配音合成' },
|
||||
{ value: 'cover_avatar', label: '封面人物形象' },
|
||||
{ value: 'voice_clone', label: '声音复刻' },
|
||||
{ value: 'video', label: '视频生成' },
|
||||
{ value: 'compose', label: '压制成片' },
|
||||
|
||||
@@ -635,7 +635,7 @@ export default function SubtitleBurning() {
|
||||
onClick={() => setPreviewMode('result')}
|
||||
disabled={!burnedVideoUrl}
|
||||
>
|
||||
{previewMode === 'result' ? '视频预览中...' : '视频预览'}
|
||||
{previewMode === 'result' ? '视频预览中' : '视频预览'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -40,7 +40,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
|
||||
style={{ flex: 1, marginTop: 0, flexShrink: 0 }}
|
||||
onClick={onPreview}
|
||||
>
|
||||
{isPreviewing ? '视频预览中...' : '视频预览'}
|
||||
{isPreviewing ? '视频预览中' : '视频预览'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ShotTimelineProps {
|
||||
userUploadedMaterials: Record<string, { path: string; duration: number }>;
|
||||
selectedAvatarMaterial: AvatarMaterial | null;
|
||||
isComposedPreview: boolean;
|
||||
matchingShotIds?: Set<string>;
|
||||
onSceneClick: (id: number, shot: ScriptShot) => void;
|
||||
onSwitchMaterial: (shotId: string) => void;
|
||||
onUploadMaterial: (shotId: string) => void;
|
||||
@@ -33,6 +34,7 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
|
||||
userUploadedMaterials,
|
||||
selectedAvatarMaterial,
|
||||
isComposedPreview,
|
||||
matchingShotIds,
|
||||
onSceneClick,
|
||||
onSwitchMaterial,
|
||||
onUploadMaterial,
|
||||
@@ -123,6 +125,7 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: '11px', padding: '2px 6px', color: 'var(--primary)' }}
|
||||
disabled={matchingShotIds?.has(String(shot.id))}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const sid = String(shot.id);
|
||||
@@ -133,7 +136,11 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{materialMatchMap[String(shot.id)] ? '换一个' : '匹配素材'}
|
||||
{matchingShotIds?.has(String(shot.id))
|
||||
? '匹配中...'
|
||||
: materialMatchMap[String(shot.id)]
|
||||
? '换一个'
|
||||
: '匹配素材'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { exists } from '@tauri-apps/plugin-fs';
|
||||
import { getPreviewVideoUrl } from '../../utils/videoPreview';
|
||||
@@ -63,6 +63,15 @@ export default function VideoGeneration() {
|
||||
const dubbingAudioDuration = useProjectStore(state => state.dubbingAudioDuration);
|
||||
|
||||
const [activeScene, setActiveScene] = useState<number>(1);
|
||||
const activeSceneRef = useRef(activeScene);
|
||||
useEffect(() => {
|
||||
activeSceneRef.current = activeScene;
|
||||
}, [activeScene]);
|
||||
|
||||
// 匹配请求进行中 shotId 集合,防止重复点击
|
||||
const [matchingShotIds, setMatchingShotIds] = useState<Set<string>>(new Set());
|
||||
// 匹配请求序号,用于丢弃过期的异步返回结果
|
||||
const matchRequestIdRef = useRef(0);
|
||||
|
||||
// 控制右侧预览区显示完整视频还是镜头素材
|
||||
const [isComposedPreview, setIsComposedPreview] = useState(false);
|
||||
@@ -192,52 +201,10 @@ export default function VideoGeneration() {
|
||||
}
|
||||
}, [previewVideoUrl]);
|
||||
|
||||
// 点击卡片后自动预览对应素材/形象
|
||||
useEffect(() => {
|
||||
const activeShot = shots.find((s) => Number(s.id) === activeScene);
|
||||
if (!activeShot) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewVideoUrl(null);
|
||||
|
||||
if (activeShot.type === 'empty_shot') {
|
||||
const matched = materialMatchMap[String(activeShot.id)];
|
||||
if (matched) {
|
||||
setPreviewVideoUrl(matched.url);
|
||||
}
|
||||
} else if (selectedAvatarMaterial) {
|
||||
setIsPreviewLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const url = await getPreviewVideoUrl(selectedAvatarMaterial.path);
|
||||
setPreviewVideoUrl(url);
|
||||
} catch (e) {
|
||||
console.error('[VideoGeneration] 预览形象视频失败:', e);
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeScene]);
|
||||
|
||||
// 视频生成
|
||||
const { isComposing, handleGenerate } = useVideoGeneration({
|
||||
selectedAvatarMaterial,
|
||||
materialMatchMap,
|
||||
estimatedVideoPoints,
|
||||
videoMultiplier,
|
||||
checkBalance,
|
||||
handleError,
|
||||
onSuccess: () => {
|
||||
setIsComposedPreview(true);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 预览视频(素材或人物形象)
|
||||
*/
|
||||
const handlePreview = async (urlOrPath: string) => {
|
||||
const handlePreview = useCallback(async (urlOrPath: string) => {
|
||||
setPreviewVideoUrl(null);
|
||||
setIsPreviewLoading(true);
|
||||
|
||||
@@ -257,7 +224,52 @@ export default function VideoGeneration() {
|
||||
toast.error('预览失败');
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 点击卡片后自动预览对应素材/形象
|
||||
useEffect(() => {
|
||||
const activeShot = shots.find((s) => Number(s.id) === activeScene);
|
||||
if (!activeShot) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewVideoUrl(null);
|
||||
|
||||
if (activeShot.type === 'empty_shot') {
|
||||
const matched = materialMatchMap[String(activeShot.id)];
|
||||
if (matched) {
|
||||
handlePreview(matched.url);
|
||||
} else {
|
||||
setPreviewVideoUrl(null);
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
} else if (selectedAvatarMaterial) {
|
||||
setIsPreviewLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const url = await getPreviewVideoUrl(selectedAvatarMaterial.path);
|
||||
setPreviewVideoUrl(url);
|
||||
} catch (e) {
|
||||
console.error('[VideoGeneration] 预览形象视频失败:', e);
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeScene, handlePreview]);
|
||||
|
||||
// 视频生成
|
||||
const { isComposing, handleGenerate } = useVideoGeneration({
|
||||
selectedAvatarMaterial,
|
||||
materialMatchMap,
|
||||
estimatedVideoPoints,
|
||||
videoMultiplier,
|
||||
checkBalance,
|
||||
handleError,
|
||||
onSuccess: () => {
|
||||
setIsComposedPreview(true);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 选择本地人物视频
|
||||
@@ -324,18 +336,37 @@ export default function VideoGeneration() {
|
||||
};
|
||||
|
||||
// 处理素材匹配(单个镜头触发匹配)
|
||||
// 匹配成功后自动切换到对应镜头并预览,但只处理最新请求的结果,
|
||||
// 避免用户快速连点多个镜头时,后返回的请求把画面切回之前的镜头。
|
||||
const handleMatchMaterial = async (shotId: string) => {
|
||||
const result = await matchSingleMaterial(shotId);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
if (Number(shotId) === activeScene) {
|
||||
// 当前镜头已激活,直接播放
|
||||
setPreviewVideoUrl(result.url);
|
||||
} else {
|
||||
// 切换到该镜头,useEffect 会自动加载预览
|
||||
setActiveScene(Number(shotId));
|
||||
setIsComposedPreview(false);
|
||||
if (matchingShotIds.has(shotId)) return;
|
||||
setMatchingShotIds(prev => new Set(prev).add(shotId));
|
||||
|
||||
const requestId = ++matchRequestIdRef.current;
|
||||
|
||||
try {
|
||||
const result = await matchSingleMaterial(shotId);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
// 如果已经有更新的请求发出,丢弃当前过期结果
|
||||
if (requestId !== matchRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (Number(shotId) === activeSceneRef.current) {
|
||||
// 当前镜头已激活,直接预览(setActiveScene 值不变时不会触发 useEffect)
|
||||
handlePreview(result.url);
|
||||
} else {
|
||||
// 切换到对应镜头,useEffect 会自动加载预览
|
||||
setActiveScene(Number(shotId));
|
||||
setIsComposedPreview(false);
|
||||
}
|
||||
} finally {
|
||||
setMatchingShotIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(shotId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -368,6 +399,7 @@ export default function VideoGeneration() {
|
||||
userUploadedMaterials={userUploadedMaterials}
|
||||
selectedAvatarMaterial={selectedAvatarMaterial}
|
||||
isComposedPreview={isComposedPreview}
|
||||
matchingShotIds={matchingShotIds}
|
||||
onSceneClick={handleSceneClick}
|
||||
onSwitchMaterial={handleSwitchMaterial}
|
||||
onUploadMaterial={handleUploadMaterial}
|
||||
@@ -418,10 +450,14 @@ export default function VideoGeneration() {
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
preload="none"
|
||||
preload="metadata"
|
||||
onLoadStart={() => setIsPreviewLoading(true)}
|
||||
onLoadedData={() => setIsPreviewLoading(false)}
|
||||
onError={() => setIsPreviewLoading(false)}
|
||||
onError={(e) => {
|
||||
console.error('[VideoGeneration] 视频加载失败:', e);
|
||||
toast.error('视频加载失败');
|
||||
setIsPreviewLoading(false);
|
||||
}}
|
||||
/>
|
||||
{isPreviewLoading && (
|
||||
<div
|
||||
|
||||
@@ -78,7 +78,9 @@ export const useProgressStore = create<ProgressState & ProgressActions>((set) =>
|
||||
},
|
||||
|
||||
setProgress: (progress) => {
|
||||
set({ progress: Math.min(100, Math.max(0, progress)) });
|
||||
set((state) => ({
|
||||
progress: Math.min(100, Math.max(state.progress ?? 0, progress)),
|
||||
}));
|
||||
},
|
||||
|
||||
success: (message = '生成完成', pointsConsumed) => {
|
||||
|
||||
Reference in New Issue
Block a user