Compare commits
33 Commits
v1.5.18
...
v1.5.18-debug
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d7a45618a | |||
| 0abc032682 | |||
| 2d7e1473a9 | |||
| 8794901bfa | |||
| 68b7954e0d | |||
| bb6cd37282 | |||
| 5aeb1d9e3c | |||
| 966cdfc08a | |||
| 331e9ccc23 | |||
| 4cbbb8d2b3 | |||
| 7e5c7ee349 | |||
| 32d86061e7 | |||
| 9ddcb2347d | |||
| 66db8a0788 | |||
| 53476d3e4a | |||
| f36e8d3742 | |||
| c3c5ff442d | |||
| ce754f7004 | |||
| 00409fd9a8 | |||
| 0292a7e1de | |||
| e6bbf0308a | |||
| dd3864db1f | |||
| 09ea37bae1 | |||
| c6fd452e87 | |||
| a1636e6b5d | |||
| 09aa1ca45a | |||
| fc92370993 | |||
| 6431666e7d | |||
| 92359e98f8 | |||
| 88f913b511 | |||
| e100494c6a | |||
| 236055b75f | |||
| fe778b66e3 |
@@ -10,18 +10,20 @@ on:
|
||||
description: '版本号 (例如 1.5.16)'
|
||||
required: true
|
||||
type: string
|
||||
environment:
|
||||
description: '构建环境'
|
||||
platform:
|
||||
description: '构建平台'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- test
|
||||
- prod
|
||||
default: test
|
||||
- all
|
||||
- macos
|
||||
- windows
|
||||
default: all
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
name: Build macOS (Universal)
|
||||
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'macos' }}
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -37,10 +39,25 @@ jobs:
|
||||
with:
|
||||
targets: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
tauri-app/src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('tauri-app/src-tauri/Cargo.lock') }}
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: tauri-app/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('tauri-app/package-lock.json') }}
|
||||
|
||||
- name: Download sidecar binaries
|
||||
run: |
|
||||
mkdir -p tauri-app/src-tauri/binaries
|
||||
gh release download v0.0.0-sidecar --repo fun0/meijiaka-zy --pattern "sidecar-binaries.tar.gz" --dir /tmp
|
||||
gh release download v0.0.0-sidecar --repo ${{ github.repository }} --pattern "sidecar-binaries.tar.gz" --dir /tmp
|
||||
tar xzf /tmp/sidecar-binaries.tar.gz -C tauri-app/src-tauri/binaries/
|
||||
chmod +x tauri-app/src-tauri/binaries/ffmpeg-* tauri-app/src-tauri/binaries/ffprobe-*
|
||||
# Create universal binary for macOS universal-apple-darwin target
|
||||
@@ -62,13 +79,23 @@ jobs:
|
||||
working-directory: tauri-app
|
||||
run: npm ci
|
||||
|
||||
- name: Update version
|
||||
run: |
|
||||
if [ -n "${{ inputs.version }}" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
perl -pi -e "s/\"version\"\s*:\s*\"[^\"]*\"/\"version\": \"$VERSION\"/" tauri-app/src-tauri/tauri.conf.json
|
||||
perl -pi -e "s/^version = \"[^\"]*\"/version = \"$VERSION\"/" tauri-app/src-tauri/Cargo.toml
|
||||
echo "Version updated to: $VERSION"
|
||||
|
||||
- name: Build macOS Universal
|
||||
working-directory: tauri-app
|
||||
run: npm run tauri -- build --target universal-apple-darwin
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.environment == 'prod' && 'https://tapi.meijiaka.cn/api/v1' || 'https://dev.tapi.meijiaka.cn/api/v1' }}
|
||||
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -76,10 +103,11 @@ jobs:
|
||||
name: macos-universal
|
||||
path: |
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg.sig
|
||||
|
||||
build-windows:
|
||||
name: Build Windows (x64)
|
||||
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'windows' }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -95,11 +123,26 @@ jobs:
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
tauri-app/src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('tauri-app/src-tauri/Cargo.lock') }}
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: tauri-app/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('tauri-app/package-lock.json') }}
|
||||
|
||||
- name: Download sidecar binaries
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path tauri-app/src-tauri/binaries
|
||||
gh release download v0.0.0-sidecar --repo fun0/meijiaka-zy --pattern "sidecar-binaries.tar.gz" --dir $env:TEMP
|
||||
gh release download v0.0.0-sidecar --repo ${{ github.repository }} --pattern "sidecar-binaries.tar.gz" --dir $env:TEMP
|
||||
tar xzf "$env:TEMP\sidecar-binaries.tar.gz" -C tauri-app/src-tauri/binaries/
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -108,14 +151,25 @@ jobs:
|
||||
working-directory: tauri-app
|
||||
run: npm ci
|
||||
|
||||
- name: Update version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = if ("${{ inputs.version }}") { "${{ inputs.version }}" } else { $env:GITHUB_REF_NAME -replace '^v', '' }
|
||||
$content = Get-Content tauri-app/src-tauri/tauri.conf.json -Raw
|
||||
$content = $content -replace '"version"\s*:\s*"[^"]*"', "`"version`": `"$version`""
|
||||
Set-Content tauri-app/src-tauri/tauri.conf.json -Value $content
|
||||
$cargo = Get-Content tauri-app/src-tauri/Cargo.toml -Raw
|
||||
$cargo = $cargo -replace '^version = "[^"]*"', "version = `"$version`""
|
||||
Set-Content tauri-app/src-tauri/Cargo.toml -Value $cargo
|
||||
Write-Host "Version updated to: $version"
|
||||
|
||||
- name: Build Windows x64
|
||||
working-directory: tauri-app
|
||||
shell: pwsh
|
||||
run: npm run tauri -- build --target x86_64-pc-windows-msvc
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.environment == 'prod' && 'https://tapi.meijiaka.cn/api/v1' || 'https://dev.tapi.meijiaka.cn/api/v1' }}
|
||||
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -123,3 +177,5 @@ jobs:
|
||||
name: windows-x64
|
||||
path: |
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ __pycache__/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -22,5 +24,6 @@ __pycache__/
|
||||
test_kick.sh
|
||||
.playwright-mcp/
|
||||
*.seed_materials_cache.json
|
||||
.qiniu_pythonsdk_hostscache.json
|
||||
tauri-app/src-tauri/binaries/*
|
||||
.tauri-signing-key
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
**美家卡智影** - AI 视频创作桌面应用,采用 Python FastAPI 后端 + Tauri (Rust + React) 前端的混合架构。
|
||||
|
||||
**核心功能**: AI 脚本生成、TTS 语音合成、声音克隆、对口型 (Vidu)、字幕生成、视频合成、项目本地持久化
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── python-api/ # Python 后端 API (FastAPI)
|
||||
├── tauri-app/ # Tauri 桌面前端 (Rust + React 19)
|
||||
├── docs/ # 架构文档
|
||||
└── AGENTS.md # AI Agent 专用详细文档
|
||||
```
|
||||
|
||||
### 后端 (python-api/)
|
||||
|
||||
```
|
||||
python-api/
|
||||
├── app/
|
||||
│ ├── api/v1/ # API 路由 (按领域拆分: auth, script, voice, vidu, caption, tasks, upload, materials, system)
|
||||
│ ├── core/ # 核心工具 (配置、异常、Redis、健康检查)
|
||||
│ ├── db/ # 数据库配置 (SQLAlchemy 2.0 async)
|
||||
│ ├── models/ # ORM 模型 (BaseModel: UUID 主键 + 时间戳)
|
||||
│ ├── schemas/ # Pydantic Schema (请求/响应校验)
|
||||
│ ├── services/ # 业务逻辑层
|
||||
│ ├── scheduler/ # Async Engine 异步任务调度
|
||||
│ ├── ai/ # AI 模型封装 (providers, adapters, gateways)
|
||||
│ └── platform_gateway.py # 第三方平台统一调用网关
|
||||
├── config/ # 运行时配置 (platform-config.yaml, materials.json)
|
||||
├── alembic/ # 数据库迁移
|
||||
└── Makefile # 开发命令
|
||||
```
|
||||
|
||||
### 前端 (tauri-app/)
|
||||
|
||||
```
|
||||
tauri-app/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ ├── client.ts # 智能路由客户端 (HTTP/IPC 自动选择, camelCase ↔ snake_case)
|
||||
│ │ ├── modules/ # 按领域拆分的 API 模块
|
||||
│ │ └── generated/ # OpenAPI 生成类型
|
||||
│ ├── components/ # 可复用组件 (PascalCase 文件夹)
|
||||
│ ├── pages/ # 页面组件 (PascalCase 文件夹)
|
||||
│ ├── store/ # Zustand 状态管理
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ └── styles/ # CSS 变量与全局样式
|
||||
├── src-tauri/src/
|
||||
│ ├── commands/ # Tauri IPC 命令 (按领域拆分)
|
||||
│ ├── storage/ # 本地存储引擎
|
||||
│ ├── ffmpeg_cmd.rs # FFmpeg 命令封装
|
||||
│ └── lib.rs # Tauri Builder、Command 定义
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 混合通信架构
|
||||
|
||||
1. **纯数据 API** (脚本、TTS、字幕、视频生成、任务查询等) → 前端通过 **HTTP 直连 Python 后端**
|
||||
- 使用 `tauri-app/src/api/client.ts` 的 `client.get/post/put/delete`
|
||||
- 异步任务统一走 `POST /tasks/{task_type}` + 轮询 `/tasks/{task_id}`
|
||||
|
||||
2. **本地系统能力** (FFmpeg 合成、文件系统、项目持久化) → 走 **Tauri IPC → Rust 层**
|
||||
- 使用 `invoke()` 调用 `src-tauri/src/commands/` 中的 `#[tauri::command]`
|
||||
- 命令参数用 `snake_case`,前端用 `camelCase`,通过 `#[serde(rename_all = "camelCase")]` 转换
|
||||
|
||||
**新增纯数据 API 时**: 只需在 `tauri-app/src/api/modules/` 使用 `client` 调用,无需修改 Rust 代码。
|
||||
|
||||
**新增本地能力时**: 在 `src-tauri/src/commands/` 添加 `#[tauri::command]`,并在 `lib.rs` 的 `invoke_handler` 中注册。
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 后端 (cd python-api)
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
make dev
|
||||
|
||||
# 启动服务 (需两个终端)
|
||||
make run # FastAPI 开发服务器 (8000)
|
||||
make scheduler # Async Engine 调度器
|
||||
|
||||
# 代码质量
|
||||
make lint # ruff + mypy
|
||||
make format # black + ruff --fix
|
||||
make lint-semantic # 语义层禁词检查 (防止供应商术语泄漏)
|
||||
make ci # format-check + lint + lint-semantic + test + security
|
||||
|
||||
# 测试
|
||||
make test # pytest
|
||||
make test-cov # 覆盖率报告
|
||||
|
||||
# 安全扫描
|
||||
make security # bandit + pip-audit
|
||||
|
||||
# Docker
|
||||
make docker-run # 启动 api + scheduler (共享外部 db/redis)
|
||||
make docker-rebuild # 强制重建
|
||||
make docker-logs # 查看日志
|
||||
|
||||
# 数据库迁移
|
||||
alembic revision --autogenerate -m "描述"
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### 前端 (cd tauri-app)
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
npm run dev # Vite 前端服务器 (1420)
|
||||
npm run tauri dev # Tauri 完整开发模式
|
||||
|
||||
# 构建
|
||||
npm run build # tsc + vite build
|
||||
npm run tauri build # Tauri 打包
|
||||
|
||||
# 测试
|
||||
npm run test # Vitest
|
||||
npm run test:ui # Vitest UI
|
||||
npm run test:coverage # 覆盖率
|
||||
|
||||
# 代码质量
|
||||
npm run lint # ESLint
|
||||
npm run lint:fix # ESLint --fix
|
||||
npm run format # Prettier
|
||||
npm run stylelint # Stylelint
|
||||
|
||||
# API 类型生成
|
||||
npm run gen:api # 从 openapi.json 生成 TypeScript 类型
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
### 命名约定
|
||||
- **组件/页面文件夹**: PascalCase (`VideoCreation/`, `ErrorBoundary/`)
|
||||
- **Store/Hooks/API 文件**: camelCase (`authStore.ts`, `useProjectData.ts`)
|
||||
- **Python 模块/函数**: snake_case (`script_handler.py`, `get_settings()`)
|
||||
- **Python 类**: PascalCase (`AsyncEngine`, `BaseModel`)
|
||||
- **常量**: UPPER_SNAKE_CASE (`PYTHON_API_BASE_URL`)
|
||||
|
||||
### 注释语言
|
||||
- **统一使用中文注释**
|
||||
- 关键架构决策需用多行注释说明
|
||||
|
||||
### Python 代码质量
|
||||
- **格式化**: black (line-length: 100)
|
||||
- **检查**: ruff (E, F, I, N, W, UP, B, C4, SIM)
|
||||
- **类型检查**: mypy (新代码 `app.schemas.*`, `app.crud.*`, `app.scheduler.handlers.*` 严格模式)
|
||||
- **依赖管理**: uv (`requirements.lock` 必须与 `pyproject.toml` 同步)
|
||||
- **安全**: bandit + pip-audit
|
||||
|
||||
### TypeScript 配置
|
||||
- `strict: true` 已开启
|
||||
- `jsx: "react-jsx"`,无需手动引入 React
|
||||
- 路径别名 `@/` → `./src`
|
||||
|
||||
### 语义层防护 (Makefile `lint-semantic`)
|
||||
- API 层禁止 `element_id` 作为字段名 (应使用 `provider_element_id`)
|
||||
- Scheduler 层统一使用 `task` 命名 (`TaskRegistry`, `task_id`, `task:` Redis key 前缀),禁止混用 `job`
|
||||
|
||||
## 关键架构组件
|
||||
|
||||
### Async Engine (异步任务调度)
|
||||
- **位置**: `python-api/app/scheduler/`
|
||||
- **核心组件**:
|
||||
- `AsyncEngine`: 主调度器,驱动 Tick 循环
|
||||
- `TaskRegistry`: Redis 任务注册表 (running/pending/finished)
|
||||
- `SlotManager`: 并发槽位管理 (按 task_type 限制并发数)
|
||||
- `handlers/`: 各领域处理器 (`script`, `tts`, `subtitle`, `video`)
|
||||
- **启动**: `make scheduler` 或 `python -m app.scheduler.main`
|
||||
- **Tick 间隔**: 5 秒 (可配置)
|
||||
|
||||
### Platform Gateway (第三方平台统一调用)
|
||||
- **位置**: `python-api/app/platform_gateway.py`
|
||||
- **功能**: 统一调用所有第三方平台 (Vidu、火山方舟、火山字幕)
|
||||
- **方法**:
|
||||
- `call_sync()`: 同步调用
|
||||
- `submit_task()`: 异步任务提交
|
||||
- `query_task()`: 任务状态查询
|
||||
- `handle_webhook()`: 回调处理 (含签名验证 + nonce 防重放)
|
||||
|
||||
### Model Router (AI 模型路由)
|
||||
- **位置**: `python-api/app/ai/model_router.py`
|
||||
- **功能**: 从 YAML 配置文件加载平台/模型配置,支持模型自动选择
|
||||
- **配置文件**: `python-api/config/platform-config.yaml`
|
||||
|
||||
### 状态管理 (前端)
|
||||
- **Zustand + Immer**: 不可变更新
|
||||
- **projectStore**: 自定义 `persist` 存储,通过 Tauri IPC 持久化到本地文件系统 (`app_config_dir/current_project.json`)
|
||||
- **其他 Store**: 使用 `localStorage` 持久化
|
||||
|
||||
## 环境变量 (后端)
|
||||
|
||||
关键配置见 `python-api/.env.example`:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DATABASE_URL` | PostgreSQL 连接字符串 |
|
||||
| `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` | Redis 连接信息 |
|
||||
| `SECRET_KEY` | JWT 签名密钥 (生产环境必须设置) |
|
||||
| `CORS_ORIGINS` | 允许的跨域来源 |
|
||||
| `VOLCENGINE_API_KEY` | 火山方舟 API Key |
|
||||
| `VIDU_API_KEY` | Vidu API Key |
|
||||
| `QINIU_ACCESS_KEY` / `QINIU_SECRET_KEY` | 七牛云存储密钥 |
|
||||
| `APP_BASE_URL` | 应用公网地址 (第三方回调用) |
|
||||
|
||||
## 部署
|
||||
|
||||
### 测试环境 (GitLab CI)
|
||||
- 触发条件: master 分支
|
||||
- 流程: 拉取代码 → 构建 api + scheduler → 启动服务 → 健康检查 (`/health`)
|
||||
- 清理: 删除 7 天前的旧镜像
|
||||
|
||||
### 生产环境
|
||||
```bash
|
||||
cd python-api
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
- **ORM**: SQLAlchemy 2.0 (asyncpg 驱动)
|
||||
- **迁移**: Alembic
|
||||
- **基础模型**: `app.models.base.BaseModel` (UUID 主键、`created_at`、`updated_at`)
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
- JWT `SECRET_KEY` 生产环境必须设置强随机字符串
|
||||
- 依赖安全: `aiohttp>=3.13.4` 和 `orjson>=3.11.0` 为强制最低版本
|
||||
- 输入验证: 所有 API 入参通过 Pydantic Schema 校验
|
||||
- 数据库: 使用参数化查询 (SQLAlchemy ORM)
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
1. 修改 `pyproject.toml` 后必须运行 `make update-lock` 更新 `requirements.lock`
|
||||
2. 新增组件遵循 PascalCase 文件夹约定
|
||||
3. 语义层禁词检查 (`make lint-semantic`) 必须通过
|
||||
4. Tauri 配置变更后需重新 `tauri dev`
|
||||
5. `projectStore` 的 `partialize` 字段决定哪些状态被保存到本地文件
|
||||
6. 前端测试在 `src/__tests__/setup.ts` 中已全局 mock localStorage 和 Tauri API
|
||||
@@ -1,181 +0,0 @@
|
||||
# Alembic 迁移诊断报告
|
||||
|
||||
## 迁移历史概览
|
||||
|
||||
```
|
||||
<base>
|
||||
↓
|
||||
509aa8b53d81 initial_schema(创建 6 张初始表)
|
||||
↓
|
||||
ccf61ff6f4bb remove_frozen_and_refunded(删除 3 个废弃字段)
|
||||
↓
|
||||
95eb1a1c0af9 add_duration_to_point_transaction(duration 字段)
|
||||
↓
|
||||
8aa48b89a07d add_category_to_point_transaction(category 字段)
|
||||
↓
|
||||
69274ce979a5 add_broll_material_tables(⚠️ 问题脚本,593 行)
|
||||
↓
|
||||
e02c96e264d9 add_cover_backgrounds_table(封面背景表)
|
||||
↓
|
||||
d0a7c5a375c6 add_app_update_tables(更新系统表)
|
||||
↓
|
||||
7a412121e69a rename_mjk_to_mjk_broll(broll 表重命名) ← head
|
||||
```
|
||||
|
||||
依赖链:**线性,无 branch/merge**,head 为 `7a412121e69a`。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 严重问题
|
||||
|
||||
### 1. 69274ce979a5 脚本职责混乱、极其臃肿
|
||||
|
||||
**脚本名**:`add_broll_material_tables`
|
||||
|
||||
**实际内容**:
|
||||
- 创建 3 张新表(`mjk_categories`/`mjk_tags`/`mjk_materials`):约 60 行
|
||||
- 给已有 6 张表的所有字段加 `comment`:约 530 行
|
||||
|
||||
**问题**:
|
||||
- 脚本体积膨胀到 **593 行**,90% 内容和脚本名称无关
|
||||
- 新环境执行迁移时,会触发几十个 `ALTER COLUMN` 操作,耗时显著增加
|
||||
- 脚本难以维护,任何已有表字段的 comment 调整都需要修改这个脚本
|
||||
- "加 comment" 和 "创建新表" 是完全独立的操作,不应该混在一起
|
||||
|
||||
**正确做法**( hindsight ):
|
||||
- 脚本 A:创建 broll 表(~60 行)
|
||||
- 脚本 B:给已有字段补 comment(~530 行)
|
||||
- 或在 `initial_schema` 中直接带上 comment,避免后续回头补
|
||||
|
||||
---
|
||||
|
||||
### 2. "创建 → 加 comment → 重命名" 链式低效
|
||||
|
||||
| 步骤 | 迁移脚本 | 操作 |
|
||||
|------|---------|------|
|
||||
| 1 | 509aa8b53d81 | 创建 `mjk_categories`(无 comment) |
|
||||
| 2 | 69274ce979a5 | 给 `mjk_categories` 字段加 comment |
|
||||
| 3 | 7a412121e69a | 重命名 `mjk_categories` → `mjk_broll_categories` |
|
||||
|
||||
**问题**:同一张表经历了"创建 → 补 comment → 重命名"三次操作。正确的做法是在创建时就用最终表名并带上 comment。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 中等问题
|
||||
|
||||
### 3. downgrade 顺序错误
|
||||
|
||||
**d0a7c5a375c6**(更新系统表):
|
||||
|
||||
```python
|
||||
# 当前(逻辑顺序错误)
|
||||
op.drop_table("release_packages") # 1. 先删子表
|
||||
op.drop_index("ix_app_releases_version", table_name="app_releases") # 2. 再删索引
|
||||
op.drop_table("app_releases") # 3. 最后删父表
|
||||
```
|
||||
|
||||
虽然 `drop_table` 会级联删除索引不会报错,但正确的逻辑顺序应该是:
|
||||
```python
|
||||
op.drop_table("release_packages")
|
||||
op.drop_index("ix_app_releases_version", table_name="app_releases")
|
||||
op.drop_table("app_releases")
|
||||
```
|
||||
|
||||
> 注:当前顺序实际不会导致错误,只是不够规范。
|
||||
|
||||
### 4. 表名前缀不一致
|
||||
|
||||
| 表 | 前缀 | 一致性 |
|
||||
|----|------|--------|
|
||||
| `mjk_users` | `mjk_` | ✅ |
|
||||
| `mjk_point_transactions` | `mjk_` | ✅ |
|
||||
| `mjk_broll_categories` | `mjk_` | ✅ |
|
||||
| `mjk_cover_backgrounds` | `mjk_` | ✅ |
|
||||
| `app_releases` | `app_` | ❌ 缺少 `mjk_` 前缀 |
|
||||
| `release_packages` | 无前缀 | ❌ 缺少 `mjk_` 前缀 |
|
||||
|
||||
### 5. 时间戳与顺序不一致
|
||||
|
||||
- `d0a7c5a375c6` Create Date:`2026-05-11 09:30:00`
|
||||
- `e02c96e264d9` Create Date:`2026-05-11 20:00:00`
|
||||
|
||||
依赖链:`e02c96e264d9 → d0a7c5a375c6`,但时间戳显示 `e02c96e264d9` 比 `d0a7c5a375c6` 晚(20:00 vs 09:30)。
|
||||
|
||||
不影响功能,只是记录不规范。
|
||||
|
||||
---
|
||||
|
||||
## 🟢 正常部分
|
||||
|
||||
### 模型与迁移一致性 ✅
|
||||
|
||||
| 模型 | 表名 | 字段 | 状态 |
|
||||
|------|------|------|------|
|
||||
| `UserPoint` | `mjk_user_points` | 无 `frozen`/`total_refunded` | ✅ 与 `ccf61ff6f4bb` 一致 |
|
||||
| `PointBatch` | `mjk_point_batches` | 无 `frozen` | ✅ 与 `ccf61ff6f4bb` 一致 |
|
||||
| `PointTransaction` | `mjk_point_transactions` | 有 `duration` + `category` | ✅ 与 `95eb1a1c0af9`/`8aa48b89a07d` 一致 |
|
||||
| `BrollCategory` | `mjk_broll_categories` | - | ✅ 与 `7a412121e69a` 重命名后一致 |
|
||||
| `BrollMaterial` | `mjk_broll_materials` | - | ✅ 与 `7a412121e69a` 重命名后一致 |
|
||||
| `BrollTag` | `mjk_broll_tags` | - | ✅ 与 `7a412121e69a` 重命名后一致 |
|
||||
| `CoverBackground` | `mjk_cover_backgrounds` | - | ✅ 与 `e02c96e264d9` 一致 |
|
||||
| `AppRelease`/`ReleasePackage` | `app_releases`/`release_packages` | - | ✅ 与 `d0a7c5a375c6` 一致 |
|
||||
|
||||
### env.py 模型导入 ✅
|
||||
|
||||
导入了全部 12 个模型类,无遗漏。
|
||||
|
||||
---
|
||||
|
||||
## 修复建议
|
||||
|
||||
### 方案 A:维持现状(推荐,已投产)
|
||||
|
||||
**理由**:
|
||||
- 生产环境数据库已有数据,不能直接修改或删除已有迁移脚本
|
||||
- 当前迁移链虽然"丑",但功能正确,执行无误
|
||||
|
||||
**行动**:
|
||||
- 保留现有迁移,以后的新迁移保持**单一职责**(一个脚本只做一件事)
|
||||
- 不再在迁移脚本里大量添加 `comment`(初始 schema 没带的就保持原样)
|
||||
|
||||
### 方案 B:Squash 迁移(适合新环境 / 大版本重构时)
|
||||
|
||||
将所有历史迁移合并为一张干净的 `initial_schema`:
|
||||
|
||||
```bash
|
||||
# 1. 备份当前 alembic 目录
|
||||
mv alembic/versions alembic/versions_backup
|
||||
|
||||
# 2. 在空数据库上生成一张完整的初始迁移
|
||||
alembic revision --autogenerate -m "squash: initial schema"
|
||||
|
||||
# 3. 验证新迁移包含所有表和字段
|
||||
# 4. 删除备份
|
||||
rm -rf alembic/versions_backup
|
||||
```
|
||||
|
||||
**风险**:
|
||||
- 生产环境已有 `alembic_version` 记录,不能直接切换
|
||||
- 需要协调所有环境(开发/测试/生产)同时切换
|
||||
|
||||
### 方案 C:拆分 69274ce979a5(仅做记录,不建议执行)
|
||||
|
||||
将 `69274ce979a5` 拆分为两个脚本:
|
||||
1. `create_broll_tables`:创建 `mjk_broll_categories`/`mjk_broll_materials`/`mjk_broll_tags`
|
||||
2. `add_column_comments`:给已有字段加 comment
|
||||
|
||||
**不可行原因**:已有迁移的 revision ID 已写入数据库 `alembic_version` 表,拆分会导致已执行环境的历史记录和脚本不匹配。
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
| 问题 | 严重程度 | 是否可修复 | 建议 |
|
||||
|------|---------|-----------|------|
|
||||
| 69274ce979a5 职责混乱 | 🔴 高 | ❌ 已投产,不动 | 以后新迁移保持单一职责 |
|
||||
| 创建→comment→重命名链 | 🔴 高 | ❌ 已投产,不动 | 以后在创建时直接用最终表名 |
|
||||
| downgrade 顺序 | 🟡 中 | ⚠️ 不建议改 | 当前不会报错,保持现状 |
|
||||
| 表名前缀不一致 | 🟡 中 | ❌ 已投产,不动 | 接受现状 |
|
||||
| 时间戳不一致 | 🟢 低 | ❌ 无影响 | 忽略 |
|
||||
|
||||
**最终建议**:维持现状,不修改已有迁移脚本。以后新迁移遵循"单一职责原则"。
|
||||
@@ -0,0 +1,227 @@
|
||||
# DMG 背景图设计规范
|
||||
|
||||
> 本规范与美家卡智影桌面应用视觉体系保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 一、画布规格
|
||||
|
||||
| 项目 | 规格 |
|
||||
|------|------|
|
||||
| 尺寸 | **660 × 400 px** |
|
||||
| 格式 | PNG(不透明) |
|
||||
| 分辨率 | 72 DPI |
|
||||
| 色彩模式 | sRGB |
|
||||
|
||||
---
|
||||
|
||||
## 二、视觉风格
|
||||
|
||||
### 2.1 设计语言
|
||||
|
||||
与主应用保持一致:
|
||||
- **卡片式布局** — 圆角卡片承载信息
|
||||
- **轻微阴影** — 营造层级感
|
||||
- **绿色主色调** — 品牌识别色 `#36b26a`
|
||||
- **圆角设计** — 大圆角(12px)为主,小圆角(8px)为辅
|
||||
- **毛玻璃质感** — 侧边栏/浮层使用半透明模糊效果
|
||||
|
||||
### 2.2 色彩规范(引用应用 CSS 变量)
|
||||
|
||||
| 用途 | 色值 | CSS 变量 |
|
||||
|------|------|----------|
|
||||
| 背景底色 | `#f9fafb` | `--bg-main` |
|
||||
| 卡片背景 | `#ffffff` | `--bg-card` |
|
||||
| 品牌主色 | `#36b26a` | `--primary` |
|
||||
| 品牌辅色 | `#18a08a` | `--secondary` |
|
||||
| 文字主色 | `#111827` | `--text-primary` |
|
||||
| 文字次色 | `#6b7280` | `--text-secondary` |
|
||||
| 文字三级 | `#9ca3af` | `--text-tertiary` |
|
||||
| 边框颜色 | `#e5e7eb` | `--border-color` |
|
||||
| 成功/提示 | `#10b981` | `--success` |
|
||||
| 警告 | `#f59e0b` | `--warning` |
|
||||
| 错误 | `#ef4444` | `--error` |
|
||||
| 信息 | `#3b82f6` | `--info` |
|
||||
|
||||
### 2.3 字体规范
|
||||
|
||||
- **字体家族**:`'Inter', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif`
|
||||
- **字重**:标题 600(SemiBold),正文 400(Regular)
|
||||
|
||||
| 层级 | 字号 | 字重 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 品牌标题 | 28 px | 600 | 顶部 "美家卡智影" |
|
||||
| 引导文字 | 15 px | 500 | 拖拽指引 |
|
||||
| 提示标题 | 14 px | 600 | 卡片内小标题 |
|
||||
| 提示正文 | 13 px | 400 | 卡片内说明文字 |
|
||||
| 版本号 | 11 px | 400 | 底部版本信息 |
|
||||
|
||||
### 2.4 阴影规范
|
||||
|
||||
| 用途 | 阴影值 |
|
||||
|------|--------|
|
||||
| 卡片阴影 | `0 1px 3px rgb(0 0 0 / 5%)` |
|
||||
| 浮层阴影 | `0 4px 12px rgb(0 0 0 / 6%)` |
|
||||
| 强调阴影 | `0 4px 12px rgb(54 178 106 / 30%)` |
|
||||
|
||||
### 2.5 圆角规范
|
||||
|
||||
| 用途 | 圆角 |
|
||||
|------|------|
|
||||
| 大卡片 | 12 px (`--radius-lg`) |
|
||||
| 小元素 | 8 px (`--radius-md`) |
|
||||
| 按钮/标签 | 6 px (`--radius-sm`) |
|
||||
|
||||
---
|
||||
|
||||
## 三、布局规范
|
||||
|
||||
### 3.1 安全区域
|
||||
|
||||
Tauri 会在背景图上**自动叠加**以下元素,背景图需为其预留空间:
|
||||
|
||||
| 元素 | 尺寸(约) | 位置(660×400 画布) |
|
||||
|------|-----------|---------------------|
|
||||
| `.app` 图标 | 100 × 100 px | 左侧中心 (180, 170) |
|
||||
| `Applications` 文件夹 | 100 × 100 px | 右侧中心 (480, 170) |
|
||||
|
||||
> ⚠️ 左右两侧 120~140 px 宽度区域避免放置重要信息,留给系统图标。
|
||||
|
||||
### 3.2 信息层级(从上至下)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [品牌 Logo + 应用名称] │ ← 顶部居中
|
||||
│ │
|
||||
│ │
|
||||
│ [.app] [Applications] │ ← 系统图标区域
|
||||
│ 图标 文件夹 │
|
||||
│ │
|
||||
│ ← 拖拽箭头 / 视觉引导线 → │ ← 中部
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [提示图标] 首次安装提示 │ │ ← 提示卡片
|
||||
│ │ 说明文字... │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ v1.5.18 │ ← 底部版本号
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、内容规范
|
||||
|
||||
### 4.1 顶部品牌区
|
||||
|
||||
**内容:**
|
||||
- 应用 Logo(绿色 M 图标,约 48×48 px)
|
||||
- 应用名称:"美家卡智影"
|
||||
|
||||
**样式:**
|
||||
- Logo 与文字水平排列,间距 12 px
|
||||
- 文字颜色:`#111827` (`--text-primary`)
|
||||
- 文字字号:28 px,字重 600
|
||||
- 整体居中于顶部,距上边距约 40 px
|
||||
|
||||
### 4.2 中部拖拽区
|
||||
|
||||
**内容:**
|
||||
- 拖拽箭头或虚线引导线:从 `.app` 图标指向 `Applications` 文件夹
|
||||
- 引导文字:"将应用拖拽到 Applications 文件夹"
|
||||
|
||||
**样式:**
|
||||
- 箭头颜色:`#36b26a` (`--primary`) 或 `#9ca3af` (`--text-tertiary`)
|
||||
- 引导文字颜色:`#6b7280` (`--text-secondary`)
|
||||
- 引导文字字号:15 px,字重 500
|
||||
|
||||
### 4.3 底部提示卡片(核心区域)
|
||||
|
||||
由于应用未注册 Apple 开发者账号,macOS Gatekeeper 会拦截首次打开。必须通过醒目的提示卡片告知用户解决方法。
|
||||
|
||||
**卡片样式:**
|
||||
- 背景:`#ffffff` (`--bg-card`)
|
||||
- 边框:1 px solid `#e5e7eb` (`--border-color`)
|
||||
- 圆角:12 px (`--radius-lg`)
|
||||
- 阴影:`0 1px 3px rgb(0 0 0 / 5%)` (`--shadow-card`)
|
||||
- 内边距:左右 24 px,上下 16 px
|
||||
- 最大宽度:约 440 px,水平居中
|
||||
|
||||
**卡片内容:**
|
||||
|
||||
```
|
||||
[绿色圆点图标] 首次安装提示
|
||||
─────────────────────────────────
|
||||
由于未注册 Apple 开发者,首次打开时请:
|
||||
|
||||
方法 1:右键点击应用图标 → 选择「打开」
|
||||
方法 2:系统设置 → 隐私与安全性 → 仍要打开
|
||||
```
|
||||
|
||||
**文字样式:**
|
||||
- 提示标题:14 px,600,颜色 `#36b26a` (`--primary`)
|
||||
- 说明正文:13 px,400,颜色 `#6b7280` (`--text-secondary`)
|
||||
- 行高:1.6
|
||||
|
||||
**提示图标:**
|
||||
- 使用绿色圆点(8 px)或 Info 图标(16 px)
|
||||
- 颜色:`#36b26a` (`--primary`)
|
||||
|
||||
### 4.4 底部版本号(可选)
|
||||
|
||||
**内容:** "v1.5.18"
|
||||
|
||||
**样式:**
|
||||
- 字号:11 px (`--font-xs`)
|
||||
- 颜色:`#9ca3af` (`--text-tertiary`)
|
||||
- 位置:底部居中,距下边距约 16 px
|
||||
|
||||
---
|
||||
|
||||
## 五、设计禁忌
|
||||
|
||||
| ❌ 禁止 | ✅ 推荐 |
|
||||
|---------|---------|
|
||||
| 使用鲜艳刺眼的背景色(大红、亮黄) | 使用浅灰 `#f9fafb` 或纯白 `#fff` |
|
||||
| 文字过小(< 11 px)导致可读性差 | 最小字号 11 px,正文 13 px |
|
||||
| 左右两侧放置重要信息(被系统图标遮挡) | 左右两侧 120 px 留白 |
|
||||
| 使用纯黑 `#000` 文字 | 使用深灰 `#111827` |
|
||||
| 阴影过重(如 0 10px 30px) | 使用轻微阴影 `0 4px 12px rgb(0 0 0 / 6%)` |
|
||||
| 圆角过小(2-4 px)或直角 | 使用 8-12 px 大圆角 |
|
||||
| 使用多种字体混排 | 统一使用 Inter / PingFang SC |
|
||||
|
||||
---
|
||||
|
||||
## 六、交付物
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dmg-background.png` | 660 × 400 px,72 DPI,sRGB,PNG 格式 |
|
||||
| `dmg-background@2x.png` | 1320 × 800 px(Retina 屏高清版本,可选) |
|
||||
| 源文件 | PSD / Sketch / Figma 源文件 |
|
||||
|
||||
**放置路径:** `tauri-app/src-tauri/dmg-background.png`
|
||||
|
||||
---
|
||||
|
||||
## 七、参考案例
|
||||
|
||||
### 7.1 飞书 (Lark)
|
||||
- 浅灰背景 `#f5f5f7`
|
||||
- 中央大 Logo
|
||||
- 两侧图标 + 拖拽箭头
|
||||
- 底部小字提示
|
||||
|
||||
### 7.2 微信
|
||||
- 纯白背景
|
||||
- 简洁的拖拽指引
|
||||
- 无 Gatekeeper 提示(已签名)
|
||||
|
||||
### 7.3 推荐风格
|
||||
参考 **Apple 官方设计风格** + **应用自身绿色品牌色**:
|
||||
- 背景:`#f9fafb`
|
||||
- 卡片:纯白 + 浅灰边框 + 轻微阴影
|
||||
- 强调:绿色 `#36b26a`
|
||||
- 整体简洁、专业、无多余装饰
|
||||
@@ -1,260 +0,0 @@
|
||||
# 项目名词统一梳理报告
|
||||
|
||||
> 本文档梳理了美家卡智影项目中所有核心功能在用户界面和开发代码中的命名现状,识别出不一致问题,并提出统一标准方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、核心功能总览(10个业务域)
|
||||
|
||||
| # | source_type | 用户界面名称 | 开发层关键命名 | 计费模式 |
|
||||
|---|-------------|-------------|---------------|---------|
|
||||
| 1 | `script` | 脚本生成 | `ScriptCreation.tsx`, `scriptApi` | 固定 5 |
|
||||
| 2 | `polish` | 文案润色 | `scriptApi.polish`, `polish_content` | 固定 1 |
|
||||
| 3 | `title` | 标题生成 | `scriptApi.generateTitle`, `generate_title` | 固定 1 |
|
||||
| 4 | `tts` | 音频合成 / 配音 | `VoiceDubbing.tsx`, `synthesizeTTS`, `voice.ts` | 按秒计费 |
|
||||
| 5 | `voice_clone` | 声音复刻 / 声音克隆 | `VoiceMaterialLibrary.tsx`, `voice.ts` 声音克隆 API | 固定 200 |
|
||||
| 6 | `video` | 视频生成 / 对口型 | `VideoGeneration.tsx`, `vidu.ts`, `lipSync` | 按秒计费 |
|
||||
| 7 | `caption` | 字幕生成 | `createTask('subtitle', ...)` | 免费 |
|
||||
| 8 | `subtitle_burn` | 字幕烧录 | `SubtitleBurning.tsx`, `subtitle_burn` | 固定 2 |
|
||||
| 9 | `cover_design` | 封面设计 / 封面制作 / 封面生成 | `CoverDesign.tsx`, `cover_design` | 固定 2 |
|
||||
| 10 | `compose` | 压制成片 / 视频合成 | `VideoComposite.tsx`, `videoComposite.ts`, `videoCompose.ts`, `video_processing.rs`, `video_compose.rs` | 固定 5 |
|
||||
|
||||
---
|
||||
|
||||
## 二、问题分类
|
||||
|
||||
### A类:后端分类映射错误(数据层污染)
|
||||
|
||||
文件:`python-api/app/services/point_service.py` 中的 `_CATEGORY_MAP`
|
||||
|
||||
| source_type | 当前映射 | 正确映射 | 影响 |
|
||||
|-------------|---------|---------|------|
|
||||
| `polish` | "脚本生成" ❌ | "文案润色" | 用户流水显示错误分类 |
|
||||
| `title` | "脚本生成" ❌ | "标题生成" | 用户流水显示错误分类 |
|
||||
| `voice_clone` | "音频合成" ❌ | "声音复刻" | 用户流水显示错误分类 |
|
||||
|
||||
> **严重程度:高**。这会导致积分明细中用户无法区分自己到底消费了什么服务。
|
||||
|
||||
---
|
||||
|
||||
### B类:同一功能多词混用(用户层混乱)
|
||||
|
||||
#### B1. TTS / 音频合成 / 配音
|
||||
|
||||
| 位置 | 用词 |
|
||||
|------|------|
|
||||
| 步骤标签(Step 2) | **音频合成** |
|
||||
| 页面文件名 | `VoiceDubbing.tsx` |
|
||||
| 页面注释 | "语音**配音**页面" |
|
||||
| 进度弹窗 | "生成**配音**"、"**配音**已就绪" |
|
||||
| 按钮文案 | "生成**配音**" |
|
||||
| 字段标签 | "**配音**文案" |
|
||||
| 错误提示 | "请返回第二步重新生成**配音**" |
|
||||
| TermsModal | "AI **配音**(TTS)" |
|
||||
| API 模块注释 | "TTS 合成" |
|
||||
| 积分明细(UsageDetail)筛选 | "**音频合成**" |
|
||||
|
||||
**问题**:步骤标签用"音频合成",但页面内全部用"配音"。用户从步骤导航点进来,看到的内容全是"配音",会产生"这是同一个功能吗?"的困惑。
|
||||
|
||||
#### B2. 封面设计 / 封面制作 / 封面生成
|
||||
|
||||
| 位置 | 用词 |
|
||||
|------|------|
|
||||
| 步骤标签(Step 5) | **封面制作** |
|
||||
| 页面文件名 | `CoverDesign.tsx` |
|
||||
| 页面注释 | "**封面制作**页面" |
|
||||
| 进度弹窗 | "**封面生成**"、"**封面生成**完成" |
|
||||
| 积分 description | "**封面设计**" |
|
||||
| 按钮文案 | "立即生成封面图" |
|
||||
| 预览区标题 | "**封面**预览" |
|
||||
|
||||
**问题**:"制作"、"设计"、"生成"三个词混用。
|
||||
|
||||
#### B3. 声音复刻 / 声音克隆
|
||||
|
||||
| 位置 | 用词 |
|
||||
|------|------|
|
||||
| 侧边栏 Sidebar | **声音克隆** |
|
||||
| 积分明细 UsageDetail | **声音复刻** |
|
||||
| API 模块注释(`voice.ts`) | "**声音克隆** API" |
|
||||
| 页面标题(`VoiceMaterialLibrary.tsx`) | "**声音克隆**" |
|
||||
|
||||
**问题**:同一功能两个不同的中文名称。
|
||||
|
||||
#### B4. 视频生成 / 对口型
|
||||
|
||||
| 位置 | 用词 |
|
||||
|------|------|
|
||||
| 步骤标签(Step 3) | **视频生成** |
|
||||
| 积分明细 | **视频生成** |
|
||||
| 技术实现注释/日志 | "**对口型**任务"、"**对口型**视频"、"**对口型**处理中..." |
|
||||
| 错误提示 | "**对口型**任务失败"、"**对口型**任务超时" |
|
||||
| 字段名 | `lipSyncTaskId`, `lipSyncVideoPath` |
|
||||
|
||||
**问题**:技术实现术语"对口型"泄露到用户可见文案中。
|
||||
|
||||
---
|
||||
|
||||
### C类:开发命名与业务命名不匹配
|
||||
|
||||
| 业务名称 | 开发命名现状 | 问题 |
|
||||
|---------|-------------|------|
|
||||
| 压制成片 | `compose` (source_type) / `composite` (API 模块名) / `videoComposite.ts` / `videoCompose.ts` / `video_processing.rs` / `video_compose.rs` / `VideoComposite.tsx` | **同一业务 5 种不同命名**,开发人员无法一眼看出这些代码对应同一个功能 |
|
||||
| 音频合成 | `tts` (source_type) / `VoiceDubbing.tsx` (页面) / `synthesizeTTS` (函数) / `voice.ts` (API 模块) | 页面文件名 `VoiceDubbing` 与步骤名"音频合成"语义不匹配 |
|
||||
| 文案润色 | `polish` (source_type/API) / `polish_content` (service) | 基本对应,但"polish"在代码中同时指"画面描述润色"和"配音文案润色" |
|
||||
|
||||
---
|
||||
|
||||
### D类:术语在注释/文案中的不统一
|
||||
|
||||
- `ScriptCreation.tsx` 中:`voiceover` 字段的注释混用"配音文案"、"画外音"
|
||||
- `VideoGeneration.tsx` 中:用户错误提示混用"返回第二步重新生成配音"和"回到第2步重新生成音频"
|
||||
- `voice.ts` 中:API 模块标题为"TTS 合成、批量合成、声音克隆",但对应的功能页面叫"音频合成"
|
||||
|
||||
---
|
||||
|
||||
## 三、统一标准方案
|
||||
|
||||
### 3.1 用户层统一名称(用户可见的所有文案)
|
||||
|
||||
| source_type | 统一名称 | 子类型/说明 |
|
||||
|-------------|---------|------------|
|
||||
| `script` | **脚本生成** | — |
|
||||
| `polish` | **文案润色** | 含画面描述润色、配音文案润色 |
|
||||
| `title` | **标题生成** | 封面主/副标题 |
|
||||
| `tts` | **配音合成** | 步骤标签、页面标题、按钮、进度统一用此 |
|
||||
| `voice_clone` | **声音复刻** | 统一用"复刻",不用"克隆" |
|
||||
| `video` | **视频生成** | 技术实现是对口型,但用户界面禁止出现"对口型" |
|
||||
| `caption` | **字幕生成** | 从视频提取字幕文本 |
|
||||
| `subtitle_burn` | **字幕烧录** | 将字幕文件烧录到视频画面中 |
|
||||
| `cover_design` | **封面设计** | 统一用"设计",不用"制作/生成" |
|
||||
| `compose` | **压制成片** | FFmpeg 拼接输出最终成品视频 |
|
||||
|
||||
**说明**:
|
||||
- **配音合成**:选择这个词是因为它比"音频合成"更贴近用户理解("我给视频配个音"),又比单独的"配音"更像一个功能名称。步骤标签从"音频合成"改为"配音合成"。
|
||||
- **声音复刻**:"复刻"比"克隆"更符合国内 AI 产品用语习惯(如剪映用"声音克隆",但通义/讯飞多用"声音复刻")。考虑到积分明细已用"复刻",统一到此。
|
||||
- **封面设计**:文件名已经是 `CoverDesign`,积分 description 也是"封面设计",步骤标签从"封面制作"改为此,形成统一。
|
||||
- **视频生成**:技术实现是 Vidu 对口型,但所有用户文案(含错误提示、进度提示)统一用"视频生成"。
|
||||
|
||||
### 3.2 开发层统一命名
|
||||
|
||||
#### source_type(数据库/API 层,已较规范,保持不变)
|
||||
|
||||
```
|
||||
script / polish / title / tts / voice_clone / video / caption / subtitle_burn / cover_design / compose
|
||||
```
|
||||
|
||||
#### 前端文件/模块命名
|
||||
|
||||
| 业务 | 当前文件名 | 建议文件名 | 理由 |
|
||||
|------|----------|----------|------|
|
||||
| 脚本生成 | `ScriptCreation.tsx` | ✅ 保持不变 | 语义清晰 |
|
||||
| 文案润色 | 无独立页面(在 ScriptCreation 内) | — | — |
|
||||
| 标题生成 | 无独立页面(在 CoverDesign/SubtitleBurning 内调用) | — | — |
|
||||
| 配音合成 | `VoiceDubbing.tsx` | `VoiceSynthesis.tsx` | `VoiceDubbing` 侧重"配音"动作,不够功能化;`Synthesis` 与 `synthesizeTTS` 对应 |
|
||||
| 声音复刻 | `VoiceMaterialLibrary.tsx` | ✅ 保持不变 | 页面本身是素材库,其中包含声音复刻功能,可以接受 |
|
||||
| 视频生成 | `VideoGeneration.tsx` | ✅ 保持不变 | 语义清晰 |
|
||||
| 字幕生成 | 无独立页面(在 VoiceDubbing 内调用) | — | — |
|
||||
| 字幕烧录 | `SubtitleBurning.tsx` | ✅ 保持不变 | 语义清晰 |
|
||||
| 封面设计 | `CoverDesign.tsx` | ✅ 保持不变 | 语义清晰 |
|
||||
| 压制成片 | `VideoComposite.tsx` | `VideoCompose.tsx` | 与 source_type `compose` 一致。注意当前已有 `videoCompose.ts`(上传模块),需先厘清两者边界 |
|
||||
|
||||
> ⚠️ **关于 `videoCompose.ts` vs `videoComposite.ts`**:
|
||||
> - `videoCompose.ts`:提供 `uploadVideoFile`(上传本地视频到后端→七牛云),文件注释写"压制成片 IPC 模块",实际做的是上传,命名混乱。
|
||||
> - `videoComposite.ts`:提供 `compositeApi.synthesis`(调用 Rust 压制成片),命名与业务对应。
|
||||
> **建议**:`videoCompose.ts` 改名为 `videoUpload.ts`(或合并到七牛上传模块),`VideoComposite.tsx` 改名为 `VideoCompose.tsx`。
|
||||
|
||||
#### 后端文件/模块命名
|
||||
|
||||
| 业务 | 当前命名 | 建议 | 理由 |
|
||||
|------|---------|------|------|
|
||||
| 压制成片 | `video_compose.rs` + `video_processing.rs` | ✅ 保持两个文件,但统一对外 command 名 | `video_processing.rs` 是业务逻辑层,`video_compose.rs` 是 command 层,分层合理。只需统一 Rust command 名和响应消息 |
|
||||
|
||||
---
|
||||
|
||||
## 四、具体修改清单
|
||||
|
||||
### 必改(数据层错误)
|
||||
|
||||
1. **`python-api/app/services/point_service.py`**
|
||||
- `_CATEGORY_MAP["polish"]` → "文案润色"
|
||||
- `_CATEGORY_MAP["title"]` → "标题生成"
|
||||
- `_CATEGORY_MAP["voice_clone"]` → "声音复刻"
|
||||
|
||||
2. **`python-api/app/api/v1/script.py`**(如果 description 硬编码了错误分类)
|
||||
- `description="【文案润色】"` ✅ 已正确
|
||||
- `title` 端点消费记录需要确认 description 格式
|
||||
|
||||
### 用户文案统一
|
||||
|
||||
3. **Step 标签(`tauri-app/src/pages/VideoCreation/index.tsx`)**
|
||||
- Step 2: "音频合成" → "配音合成"
|
||||
- Step 5: "封面制作" → "封面设计"
|
||||
|
||||
4. **配音合成页面(`tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx`)**
|
||||
- 页面注释:"语音配音页面" → "配音合成页面"
|
||||
- 进度:`show('生成配音')` → `show('配音合成')`
|
||||
- 进度:`update('正在生成配音...')` → `update('正在合成配音...')`
|
||||
- 成功:`success('配音已就绪')` → `success('配音合成完成')`
|
||||
- 按钮:`生成配音` → `合成配音`
|
||||
- 右侧标题:`配音文案` → `配音文本`
|
||||
- 字段标签:`配音` → `配音文本`
|
||||
- 错误提示中所有"配音"保持不动("重新生成配音"是动作描述,不需要改)
|
||||
|
||||
5. **封面设计页面(`tauri-app/src/pages/VideoCreation/CoverDesign.tsx`)**
|
||||
- 进度:`show('封面生成')` → `show('封面设计')`
|
||||
- 成功:`success('封面生成完成')` → `success('封面设计完成')`
|
||||
- 错误:`封面生成失败` → `封面设计失败`
|
||||
- 按钮:`立即生成封面图` → `立即设计封面`
|
||||
- 积分 description:`封面设计` ✅ 已正确
|
||||
|
||||
6. **声音复刻(`tauri-app/src/components/Layout/Sidebar.tsx` + `tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx` + `tauri-app/src/api/modules/voice.ts`)**
|
||||
- Sidebar: "声音克隆" → "声音复刻"
|
||||
- `voice.ts` 注释:"声音克隆" → "声音复刻"
|
||||
- `VoiceMaterialLibrary.tsx` 标题和文案
|
||||
|
||||
7. **视频生成页面(`tauri-app/src/pages/VideoCreation/VideoGeneration.tsx`)**
|
||||
- 所有用户可见的"对口型"改为"视频生成":
|
||||
- 进度:`show('视频生成')` ✅ 已正确
|
||||
- 进度:`update('正在提交对口型任务...')` → `update('正在提交视频生成任务...')`
|
||||
- 进度:`update('正在等待对口型处理...')` → `update('正在等待视频处理...')`
|
||||
- 进度:`update('对口型处理中...')` → `update('视频处理中...')`
|
||||
- 进度:`update('正在下载对口型视频...')` → `update('正在下载生成视频...')`
|
||||
- 错误:`对口型任务失败` → `视频生成失败`
|
||||
- 错误:`对口型任务超时` → `视频生成超时`
|
||||
- 日志/注释中的"对口型"可以保留(开发层)
|
||||
|
||||
8. **压制成片页面(`tauri-app/src/pages/VideoCreation/VideoComposite.tsx`)**
|
||||
- 镜头列表中的`配音`标签 → `配音文本`
|
||||
|
||||
9. **TermsModal(`tauri-app/src/components/Modal/TermsModal.tsx`)**
|
||||
- "AI 配音(TTS)" → "AI 配音合成(TTS)"
|
||||
- "声音复刻" ✅ 已正确
|
||||
|
||||
10. **积分明细(`tauri-app/src/pages/Profile/UsageDetail.tsx`)**
|
||||
- `voice_clone` 筛选标签:"声音复刻" ✅ 已正确
|
||||
- `tts` 筛选标签:"音频合成" → "配音合成"
|
||||
- `cover_design` 筛选标签:"封面设计" ✅ 已正确
|
||||
|
||||
11. **我的作品(`tauri-app/src/pages/ContentManagement/MyWorks.tsx`)**
|
||||
- 空状态文案 ✅ 已改为"压制成片"
|
||||
|
||||
### 开发层整理(建议项,不影响用户)
|
||||
|
||||
12. **`tauri-app/src/api/modules/videoCompose.ts`**
|
||||
- 文件注释写"压制成片 IPC 模块",实际做的是上传视频。建议改名或修正注释。
|
||||
|
||||
13. **`tauri-app/src/pages/VideoCreation/VideoComposite.tsx`**
|
||||
- 建议未来改名为 `VideoCompose.tsx`,与 source_type `compose` 一致。
|
||||
|
||||
---
|
||||
|
||||
## 五、确认清单
|
||||
|
||||
请确认以下决策:
|
||||
|
||||
1. **Step 2 标签**:"音频合成" → "配音合成"(还是保持"音频合成"?)
|
||||
2. **Step 5 标签**:"封面制作" → "封面设计"(是否接受?)
|
||||
3. **声音功能**:统一为"声音复刻"(放弃"声音克隆")?
|
||||
4. **视频生成**:所有用户文案中的"对口型"全部替换为"视频生成"?
|
||||
5. **文件改名**:`VoiceDubbing.tsx` → `VoiceSynthesis.tsx`、`VideoComposite.tsx` → `VideoCompose.tsx` 是否执行?
|
||||
@@ -36,7 +36,7 @@ ALGORITHM=HS256
|
||||
# === CORS 配置 ===
|
||||
# 本地开发: 允许 localhost
|
||||
# 测试/生产服: 填写实际域名,如 https://app.yourdomain.com
|
||||
CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080
|
||||
CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080,tauri://localhost,http://tauri.localhost,https://tauri.localhost
|
||||
|
||||
# === AI 平台配置 ===
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
@@ -289,5 +289,5 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
|
||||
return TaskStatus(
|
||||
state=self.normalize_state(state),
|
||||
result={"video_url": video_url, "creations": creations, "task_id": task_id} if video_url else {"task_id": task_id},
|
||||
error_message=data.get("err_code") or data.get("message") if state == "failed" else None,
|
||||
error_message=(data.get("err_code") or data.get("message")) if state == "failed" else None,
|
||||
)
|
||||
|
||||
@@ -128,7 +128,7 @@ class ViduProvider:
|
||||
|
||||
logger.info(f"[Vidu TTS] 请求参数: text_length={len(text)}")
|
||||
|
||||
logger.info(f"[Vidu LipSync] 提交请求: url={url}, body={body}")
|
||||
logger.info(f"[Vidu TTS] 提交请求: url={url}, body={body}")
|
||||
|
||||
try:
|
||||
resp = await self.client.post(url, json=body)
|
||||
|
||||
@@ -88,7 +88,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGINS: str = Field(
|
||||
default="http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080,http://127.0.0.1:8080,tauri://localhost",
|
||||
default="http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080,http://127.0.0.1:8080,tauri://localhost,http://tauri.localhost,https://tauri.localhost",
|
||||
description="允许的跨域来源(逗号分隔)",
|
||||
)
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ def _estimate_max_cost(source_type: str, param: dict | None = None) -> int:
|
||||
# ── 余额查询 ──────────────────────────────────────────
|
||||
|
||||
async def get_user_balance(db: AsyncSession, user_id: UUID | str) -> dict:
|
||||
"""获取用户积分余额快照"""
|
||||
"""获取用户积分余额快照(实时计算,排除已过期批次)。"""
|
||||
result = await db.execute(
|
||||
select(UserPoint).where(UserPoint.user_id == user_id)
|
||||
)
|
||||
@@ -178,8 +178,21 @@ async def get_user_balance(db: AsyncSession, user_id: UUID | str) -> dict:
|
||||
"total_expired": 0,
|
||||
}
|
||||
|
||||
# 实时计算可用余额(排除已过期批次),避免 expire_batches 延迟时数据不一致
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
available_result = await db.execute(
|
||||
select(_func.coalesce(_func.sum(PointBatch.remaining), 0))
|
||||
.where(
|
||||
PointBatch.user_id == user_id,
|
||||
PointBatch.remaining > 0,
|
||||
PointBatch.expired_at > _now(),
|
||||
)
|
||||
)
|
||||
available_balance = available_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"balance": up.balance,
|
||||
"balance": available_balance,
|
||||
"total_recharged": up.total_recharged,
|
||||
"total_consumed": up.total_consumed,
|
||||
"total_expired": up.total_expired,
|
||||
@@ -387,9 +400,10 @@ async def consume(
|
||||
db.add(up)
|
||||
await db.flush()
|
||||
|
||||
# 3. 余额检查(在同一事务内,避免竞态)
|
||||
if not allow_negative and up.balance < points:
|
||||
raise ValueError(f"积分不足,当前余额 {up.balance},需要 {points} 积分")
|
||||
# 3. 余额检查:用实时可用余额(未过期批次 remaining 总和),避免 expire_batches 延迟导致超扣
|
||||
available = sum(b.remaining for b in batches)
|
||||
if not allow_negative and available < points:
|
||||
raise ValueError(f"积分不足,当前可用余额 {available},需要 {points} 积分")
|
||||
|
||||
remaining_to_deduct = points
|
||||
for batch in batches:
|
||||
|
||||
@@ -15,7 +15,7 @@ DECLARE
|
||||
v_nickname TEXT := '新用户昵称'; -- ← 修改:昵称(可为空)
|
||||
v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion
|
||||
v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL)
|
||||
v_gift_points INT := 100; -- ← 修改:赠送初始积分(0 表示不赠送)
|
||||
v_gift_points INT := 2000; -- ← 修改:赠送初始积分(0 表示不赠送)
|
||||
v_gift_days INT := 365; -- ← 修改:赠送积分有效期(天)
|
||||
v_user_id UUID;
|
||||
v_batch_id BIGINT;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成 macOS Big Sur 风格圆角矩形图标"""
|
||||
"""生成圆角图标:原图(白底+内容)作为整体,缩放居中,圆角外透明"""
|
||||
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ICONS_DIR = "/Users/0fun/work/meijiaka-zy/tauri-app/src-tauri/icons"
|
||||
SOURCE_PNG = "/tmp/original-source-icon.png"
|
||||
@@ -10,7 +10,9 @@ SOURCE_PNG = "/tmp/original-source-icon.png"
|
||||
# macOS Big Sur 圆角比例 ≈ 22.6%
|
||||
CORNER_RATIO = 0.226
|
||||
|
||||
# 输出尺寸列表 (文件名, 尺寸)
|
||||
# 内容占画布比例(参考腾讯视频 ≈ 80.5%)
|
||||
CONTENT_RATIO = 0.805
|
||||
|
||||
PNG_SIZES = [
|
||||
("icon.png", 512),
|
||||
("128x128@2x.png", 256),
|
||||
@@ -19,7 +21,6 @@ PNG_SIZES = [
|
||||
("64x64.png", 64),
|
||||
]
|
||||
|
||||
# Windows Store/Square 尺寸
|
||||
SQUARE_SIZES = [
|
||||
("Square310x310Logo.png", 310),
|
||||
("Square284x284Logo.png", 284),
|
||||
@@ -35,136 +36,83 @@ SQUARE_SIZES = [
|
||||
|
||||
|
||||
def create_rounded_rect_mask(size: int, radius: int) -> Image.Image:
|
||||
"""创建圆角矩形蒙版"""
|
||||
"""创建圆角矩形蒙版(硬边缘)"""
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255)
|
||||
return mask
|
||||
|
||||
|
||||
def create_big_sur_background(size: int) -> Image.Image:
|
||||
"""创建 macOS Big Sur 风格圆角矩形底板(微妙渐变)"""
|
||||
radius = int(size * CORNER_RATIO)
|
||||
def compose_icon(size: int, source: Image.Image, rounded: bool = True) -> Image.Image:
|
||||
"""原图作为整体,缩放至画布 CONTENT_RATIO,居中"""
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
|
||||
# 顶部稍亮、底部稍暗的微妙渐变
|
||||
bg = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(bg)
|
||||
for y in range(size):
|
||||
val = int(250 - (y / size) * 10)
|
||||
draw.line([(0, y), (size, y)], fill=(val, val, val, 255))
|
||||
|
||||
# 圆角裁剪
|
||||
mask = create_rounded_rect_mask(size, radius)
|
||||
masked = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||
masked.paste(bg, mask=mask)
|
||||
|
||||
return masked
|
||||
|
||||
|
||||
def create_shadow_layer(size: int, radius: int) -> Image.Image:
|
||||
"""创建底板外部阴影(用于大尺寸)"""
|
||||
pad = int(size * 0.06)
|
||||
canvas_size = size + pad * 2
|
||||
shadow = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(shadow)
|
||||
draw.rounded_rectangle(
|
||||
(pad, pad, pad + size, pad + size),
|
||||
radius=radius,
|
||||
fill=(0, 0, 0, 40),
|
||||
)
|
||||
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=pad // 2))
|
||||
return shadow, pad
|
||||
|
||||
|
||||
def clean_source_icon(source: Image.Image) -> Image.Image:
|
||||
"""清理原始图标中的幽灵半透明像素(如内层圆角矩形轮廓)"""
|
||||
rgba = source.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
width, height = rgba.size
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
if a < 250:
|
||||
pixels[x, y] = (0, 0, 0, 0)
|
||||
else:
|
||||
pixels[x, y] = (r, g, b, 255)
|
||||
return rgba
|
||||
|
||||
|
||||
def _prepare_m_icon(source: Image.Image, plate_size: int) -> Image.Image:
|
||||
"""裁剪 M 的透明边距,宽度充满底板,保持完整形状(不裁剪)"""
|
||||
bbox = source.getbbox()
|
||||
if bbox:
|
||||
m_cropped = source.crop(bbox)
|
||||
else:
|
||||
m_cropped = source
|
||||
|
||||
# 保持宽高比,宽度充满底板,高度自然留白
|
||||
m_cropped.thumbnail((plate_size, plate_size), Image.LANCZOS)
|
||||
return m_cropped
|
||||
|
||||
|
||||
def compose_icon(size: int, source: Image.Image, add_shadow: bool = False) -> Image.Image:
|
||||
"""将 M 图标合成到圆角矩形底板上(底板占画布80%,四周留透明边距)"""
|
||||
# macOS 图标底板占画布约 80%,四周留透明呼吸边距
|
||||
plate_size = int(size * 0.80)
|
||||
plate_size = int(size * CONTENT_RATIO)
|
||||
plate_offset = (size - plate_size) // 2
|
||||
|
||||
if add_shadow and size >= 128:
|
||||
shadow, pad = create_shadow_layer(plate_size, int(plate_size * CORNER_RATIO))
|
||||
canvas = Image.new("RGBA", (size + pad * 2, size + pad * 2), (0, 0, 0, 0))
|
||||
canvas.paste(shadow, (0, 0), shadow)
|
||||
# 原图等比缩放,短边充满 plate_size
|
||||
src_w, src_h = source.size
|
||||
ratio = max(plate_size / src_w, plate_size / src_h)
|
||||
new_w = int(src_w * ratio)
|
||||
new_h = int(src_h * ratio)
|
||||
resized = source.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
bg = create_big_sur_background(plate_size)
|
||||
canvas.paste(bg, (pad + plate_offset, pad + plate_offset), bg)
|
||||
# 居中裁剪到 plate_size
|
||||
left = (new_w - plate_size) // 2
|
||||
top = (new_h - plate_size) // 2
|
||||
img = resized.crop((left, top, left + plate_size, top + plate_size))
|
||||
|
||||
m_img = _prepare_m_icon(source, plate_size)
|
||||
offset_x = (plate_size - m_img.width) // 2
|
||||
offset_y = (plate_size - m_img.height) // 2
|
||||
canvas.paste(m_img, (pad + plate_offset + offset_x, pad + plate_offset + offset_y), m_img)
|
||||
|
||||
return canvas
|
||||
if rounded:
|
||||
# 圆角蒙版裁剪(macOS / Linux)
|
||||
radius = int(plate_size * CORNER_RATIO)
|
||||
mask = create_rounded_rect_mask(plate_size, radius)
|
||||
canvas.paste(img, (plate_offset, plate_offset), mask)
|
||||
else:
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
# 正方形填满(Windows 专用)
|
||||
canvas.paste(img, (plate_offset, plate_offset))
|
||||
return canvas
|
||||
|
||||
bg = create_big_sur_background(plate_size)
|
||||
canvas.paste(bg, (plate_offset, plate_offset), bg)
|
||||
|
||||
m_img = _prepare_m_icon(source, plate_size)
|
||||
offset_x = (plate_size - m_img.width) // 2
|
||||
offset_y = (plate_size - m_img.height) // 2
|
||||
canvas.paste(m_img, (plate_offset + offset_x, plate_offset + offset_y), m_img)
|
||||
def compose_icon_windows(size: int, source: Image.Image) -> Image.Image:
|
||||
"""Windows 专用:原图填满整个画布 100%,无圆角,无透明边距"""
|
||||
# 原图等比缩放,短边充满画布
|
||||
src_w, src_h = source.size
|
||||
ratio = max(size / src_w, size / src_h)
|
||||
new_w = int(src_w * ratio)
|
||||
new_h = int(src_h * ratio)
|
||||
resized = source.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
return canvas
|
||||
# 居中裁剪到画布尺寸
|
||||
left = (new_w - size) // 2
|
||||
top = (new_h - size) // 2
|
||||
return resized.crop((left, top, left + size, top + size))
|
||||
|
||||
|
||||
def generate_icns(source: Image.Image, output_path: str):
|
||||
"""生成 macOS .icns 文件"""
|
||||
import tempfile
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||
iconset_dir = tempfile.mkdtemp(suffix=".iconset")
|
||||
|
||||
for sz in sizes:
|
||||
img = compose_icon(sz, source)
|
||||
img = compose_icon(sz, source, rounded=True)
|
||||
img.save(os.path.join(iconset_dir, f"icon_{sz}x{sz}.png"))
|
||||
if sz <= 512:
|
||||
img2x = compose_icon(sz * 2, source)
|
||||
img2x = compose_icon(sz * 2, source, rounded=True)
|
||||
img2x.save(os.path.join(iconset_dir, f"icon_{sz}x{sz}@2x.png"))
|
||||
|
||||
subprocess.run(
|
||||
["iconutil", "-c", "icns", iconset_dir, "-o", output_path],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# 清理
|
||||
import shutil
|
||||
shutil.rmtree(iconset_dir)
|
||||
|
||||
|
||||
def generate_ico(source: Image.Image, output_path: str):
|
||||
"""生成 Windows .ico 文件(手动组装,支持多分辨率 PNG 嵌入)"""
|
||||
"""生成 Windows .ico 文件"""
|
||||
import struct
|
||||
import io
|
||||
|
||||
@@ -173,37 +121,26 @@ def generate_ico(source: Image.Image, output_path: str):
|
||||
entries = []
|
||||
|
||||
for sz in sizes:
|
||||
img = compose_icon(sz, source)
|
||||
# Windows .ico 填满画布,无圆角,无透明边距
|
||||
img = compose_icon_windows(sz, source)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
data = buf.getvalue()
|
||||
png_datas.append(data)
|
||||
entries.append((sz, len(data)))
|
||||
|
||||
# ICO 文件头: Reserved(2) + Type(2) + Count(2)
|
||||
ico = struct.pack("<HHH", 0, 1, len(sizes))
|
||||
|
||||
# 计算数据偏移量: 文件头(6) + 目录项(16 * count)
|
||||
data_offset = 6 + 16 * len(sizes)
|
||||
|
||||
# ICONDIRENTRY
|
||||
for sz, size_bytes in entries:
|
||||
width = sz if sz < 256 else 0
|
||||
height = sz if sz < 256 else 0
|
||||
ico += struct.pack(
|
||||
"<BBBBHHII",
|
||||
width, # Width
|
||||
height, # Height
|
||||
0, # Colors (0 for >256)
|
||||
0, # Reserved
|
||||
1, # Color planes
|
||||
32, # Bits per pixel
|
||||
size_bytes, # Size in bytes
|
||||
data_offset, # Offset
|
||||
width, height, 0, 0, 1, 32, size_bytes, data_offset,
|
||||
)
|
||||
data_offset += size_bytes
|
||||
|
||||
# 追加图像数据
|
||||
for data in png_datas:
|
||||
ico += data
|
||||
|
||||
@@ -211,89 +148,26 @@ def generate_ico(source: Image.Image, output_path: str):
|
||||
f.write(ico)
|
||||
|
||||
|
||||
def generate_android(source: Image.Image, android_dir: str):
|
||||
"""生成 Android 图标"""
|
||||
android_sizes = {
|
||||
"mipmap-hdpi": 72,
|
||||
"mipmap-mdpi": 48,
|
||||
"mipmap-xhdpi": 96,
|
||||
"mipmap-xxhdpi": 144,
|
||||
"mipmap-xxxhdpi": 192,
|
||||
}
|
||||
for folder, sz in android_sizes.items():
|
||||
folder_path = os.path.join(android_dir, folder)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
img = compose_icon(sz, source)
|
||||
img.save(os.path.join(folder_path, "ic_launcher.png"))
|
||||
# foreground / background / round / monochrome 等可能不需要更新
|
||||
|
||||
|
||||
def generate_ios(source: Image.Image, ios_dir: str):
|
||||
"""生成 iOS 图标"""
|
||||
ios_sizes = [
|
||||
("AppIcon-20x20@1x.png", 20),
|
||||
("AppIcon-20x20@2x.png", 40),
|
||||
("AppIcon-20x20@3x.png", 60),
|
||||
("AppIcon-29x29@1x.png", 29),
|
||||
("AppIcon-29x29@2x.png", 58),
|
||||
("AppIcon-29x29@3x.png", 87),
|
||||
("AppIcon-40x40@1x.png", 40),
|
||||
("AppIcon-40x40@2x.png", 80),
|
||||
("AppIcon-40x40@3x.png", 120),
|
||||
("AppIcon-512@2x~ipad.png", 1024),
|
||||
("AppIcon-512x512@1x.png", 512),
|
||||
("AppIcon-512x512@2x.png", 1024),
|
||||
("AppIcon-60x60@2x.png", 120),
|
||||
("AppIcon-60x60@3x.png", 180),
|
||||
("AppIcon-76x76@1x.png", 76),
|
||||
("AppIcon-76x76@2x.png", 152),
|
||||
("AppIcon-83.5x83.5@2x.png", 167),
|
||||
("AppIcon-Notification@3x.png", 60),
|
||||
]
|
||||
for filename, sz in ios_sizes:
|
||||
img = compose_icon(sz, source)
|
||||
img.save(os.path.join(ios_dir, filename))
|
||||
|
||||
|
||||
def main():
|
||||
source_raw = Image.open(SOURCE_PNG).convert("RGBA")
|
||||
source = clean_source_icon(source_raw)
|
||||
|
||||
source = Image.open(SOURCE_PNG).convert("RGBA")
|
||||
print(f"源图标尺寸: {source.size}")
|
||||
|
||||
# 生成基础 PNG
|
||||
for filename, size in PNG_SIZES:
|
||||
path = os.path.join(ICONS_DIR, filename)
|
||||
img = compose_icon(size, source)
|
||||
img.save(path)
|
||||
compose_icon(size, source).save(path)
|
||||
print(f"已生成: {filename} ({size}x{size})")
|
||||
|
||||
# 生成 Square Logo(Windows)
|
||||
# Windows Square Logo 填满画布,无圆角,无透明边距
|
||||
for filename, size in SQUARE_SIZES:
|
||||
path = os.path.join(ICONS_DIR, filename)
|
||||
img = compose_icon(size, source)
|
||||
img.save(path)
|
||||
compose_icon_windows(size, source).save(path)
|
||||
print(f"已生成: {filename} ({size}x{size})")
|
||||
|
||||
# 生成 .icns
|
||||
icns_path = os.path.join(ICONS_DIR, "icon.icns")
|
||||
generate_icns(source, icns_path)
|
||||
print(f"已生成: icon.icns")
|
||||
generate_icns(source, os.path.join(ICONS_DIR, "icon.icns"))
|
||||
print("已生成: icon.icns")
|
||||
|
||||
# 生成 .ico
|
||||
ico_path = os.path.join(ICONS_DIR, "icon.ico")
|
||||
generate_ico(source, ico_path)
|
||||
print(f"已生成: icon.ico")
|
||||
|
||||
# 生成 Android
|
||||
android_dir = os.path.join(ICONS_DIR, "android")
|
||||
generate_android(source, android_dir)
|
||||
print(f"已生成: Android 图标")
|
||||
|
||||
# 生成 iOS
|
||||
ios_dir = os.path.join(ICONS_DIR, "ios")
|
||||
generate_ios(source, ios_dir)
|
||||
print(f"已生成: iOS 图标")
|
||||
generate_ico(source, os.path.join(ICONS_DIR, "icon.ico"))
|
||||
print("已生成: icon.ico")
|
||||
|
||||
print("\n全部完成!")
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri = { version = "2", features = ["protocol-asset", "devtools"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>icon</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
生成 DMG 背景图 — 美家卡智影
|
||||
画布: 660 x 400 px
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
W, H = 660, 400
|
||||
|
||||
BG = (249, 250, 251)
|
||||
TEXT_PRIMARY = (17, 24, 39)
|
||||
TEXT_SECONDARY = (107, 114, 128)
|
||||
TEXT_TERTIARY = (156, 163, 175)
|
||||
|
||||
|
||||
def get_font(size, bold=False):
|
||||
result = subprocess.run(
|
||||
["fc-match", "-f", "%{file}", "PingFang SC"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
path = result.stdout.strip()
|
||||
if path and Path(path).exists():
|
||||
idx = 2 if bold else 0
|
||||
return ImageFont.truetype(path, size, index=idx)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
font_title = get_font(22, bold=True)
|
||||
font_body = get_font(13)
|
||||
font_caption = get_font(11)
|
||||
|
||||
bg = Image.new("RGBA", (W, H), (*BG, 255))
|
||||
draw = ImageDraw.Draw(bg)
|
||||
|
||||
# ── 顶部标题 ──
|
||||
draw.text((W // 2, 55), "拖拽到 Applications 文件夹",
|
||||
fill=TEXT_PRIMARY, font=font_title, anchor="mm")
|
||||
|
||||
# ── 中部留白:系统图标区域 y≈140~300 ──
|
||||
|
||||
# ── 底部提示文字 ──
|
||||
tip_lines = [
|
||||
'首次打开如遇拦截,请右键应用图标选择"打开"',
|
||||
'或前往 系统设置 → 隐私与安全性 → 点击"仍要打开"',
|
||||
]
|
||||
for i, line in enumerate(tip_lines):
|
||||
draw.text((W // 2, 325 + i * 22), line,
|
||||
fill=TEXT_SECONDARY, font=font_body, anchor="mm")
|
||||
|
||||
# ── 版本号 ──
|
||||
draw.text((W // 2, H - 14), "v1.5.18",
|
||||
fill=TEXT_TERTIARY, font=font_caption, anchor="mm")
|
||||
|
||||
output = bg.convert("RGB")
|
||||
output.save("dmg-background.png", "PNG")
|
||||
print("✅ Generated: dmg-background.png (660x400)")
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1004 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成 macOS 风格圆角图标(Big Sur 22% 圆角半径)"""
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
# 配置
|
||||
LOGO_PATH = "../../public/assets/logo.png"
|
||||
OUTPUT_DIR = "."
|
||||
BG_COLOR = (255, 255, 255, 255)
|
||||
# macOS Big Sur 风格圆角:约 22% 半径
|
||||
CORNER_RADIUS_RATIO = 0.22
|
||||
# Logo 缩放比例
|
||||
LOGO_SCALE = 0.65
|
||||
|
||||
|
||||
def create_rounded_rect(size, radius, fill):
|
||||
"""创建圆角矩形遮罩"""
|
||||
img = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=fill)
|
||||
return img
|
||||
|
||||
|
||||
def generate_icon(size):
|
||||
"""生成指定尺寸的图标"""
|
||||
radius = int(size[0] * CORNER_RADIUS_RATIO)
|
||||
|
||||
# 白色圆角背景
|
||||
bg = create_rounded_rect(size, radius, BG_COLOR)
|
||||
|
||||
# 加载并缩放 logo
|
||||
logo = Image.open(LOGO_PATH).convert("RGBA")
|
||||
logo_size = int(min(size) * LOGO_SCALE)
|
||||
logo.thumbnail((logo_size, logo_size), Image.LANCZOS)
|
||||
|
||||
# 居中绘制 logo
|
||||
x = (size[0] - logo.width) // 2
|
||||
y = (size[1] - logo.height) // 2
|
||||
bg.paste(logo, (x, y), logo)
|
||||
|
||||
return bg
|
||||
|
||||
|
||||
def main():
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 生成各尺寸 PNG
|
||||
sizes = {
|
||||
"icon_16x16.png": (16, 16),
|
||||
"icon_32x32.png": (32, 32),
|
||||
"icon_128x128.png": (128, 128),
|
||||
"icon_256x256.png": (256, 256),
|
||||
"icon_512x512.png": (512, 512),
|
||||
"icon_512x512@2x.png": (1024, 1024),
|
||||
}
|
||||
|
||||
# 用于 ICNS 的临时目录
|
||||
icns_dir = "/tmp/icon.iconset"
|
||||
os.makedirs(icns_dir, exist_ok=True)
|
||||
|
||||
for filename, size in sizes.items():
|
||||
img = generate_icon(size)
|
||||
img.save(f"{icns_dir}/{filename}", "PNG")
|
||||
print(f"Generated {filename} ({size[0]}x{size[1]})")
|
||||
|
||||
# 复制到输出目录
|
||||
for src, dst in [
|
||||
("icon_32x32.png", "32x32.png"),
|
||||
("icon_128x128.png", "128x128.png"),
|
||||
("icon_256x256.png", "128x128@2x.png"),
|
||||
("icon_512x512.png", "icon.png"),
|
||||
]:
|
||||
subprocess.run(["cp", f"{icns_dir}/{src}", f"{OUTPUT_DIR}/{dst}"])
|
||||
print(f"Copied to {dst}")
|
||||
|
||||
# 生成 ICNS
|
||||
subprocess.run(
|
||||
["iconutil", "-c", "icns", "-o", f"{OUTPUT_DIR}/icon.icns", icns_dir],
|
||||
check=True,
|
||||
)
|
||||
print("Generated icon.icns")
|
||||
|
||||
# 生成 ICO(Windows 需要多分辨率)
|
||||
ico_sizes = [16, 32, 48, 64, 128, 256]
|
||||
ico_images = []
|
||||
for s in ico_sizes:
|
||||
img = generate_icon((s, s))
|
||||
# ICO 需要 RGB 模式
|
||||
if img.mode == "RGBA":
|
||||
rgb = Image.new("RGB", img.size, (255, 255, 255))
|
||||
rgb.paste(img, mask=img.split()[3])
|
||||
ico_images.append(rgb)
|
||||
else:
|
||||
ico_images.append(img)
|
||||
|
||||
ico_images[0].save(
|
||||
f"{OUTPUT_DIR}/icon.ico",
|
||||
format="ICO",
|
||||
sizes=[(s, s) for s in ico_sizes],
|
||||
)
|
||||
print("Generated icon.ico")
|
||||
|
||||
# 清理临时目录
|
||||
subprocess.run(["rm", "-rf", icns_dir])
|
||||
|
||||
print("\n✅ 所有图标已生成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 379 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -67,114 +67,18 @@ pub fn sanitize_output_path(path: &str) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 FFmpeg sidecar 路径
|
||||
*
|
||||
* 优先检查当前可执行文件目录(打包后场景),
|
||||
* 然后检查开发模式路径(项目根目录 / src-tauri / binaries)。
|
||||
*/
|
||||
fn find_ffmpeg_sidecar() -> Result<std::path::PathBuf, String> {
|
||||
let arch = std::env::consts::ARCH;
|
||||
#[cfg(target_os = "macos")]
|
||||
let os_suffix = "apple-darwin";
|
||||
#[cfg(target_os = "windows")]
|
||||
let os_suffix = "pc-windows-msvc";
|
||||
#[cfg(target_os = "linux")]
|
||||
let os_suffix = "unknown-linux-gnu";
|
||||
|
||||
let sidecar_name = format!(
|
||||
"ffmpeg-{}-{}{}",
|
||||
arch,
|
||||
os_suffix,
|
||||
std::env::consts::EXE_SUFFIX
|
||||
);
|
||||
|
||||
// 打包后:和当前可执行文件同级
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let path = dir.join(&sidecar_name);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开发模式
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
for subdir in ["src-tauri/binaries", "binaries"] {
|
||||
let path = cwd.join(subdir).join(&sidecar_name);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("找不到 FFmpeg sidecar: {}", sidecar_name))
|
||||
}
|
||||
|
||||
/// 检测 Mach-O 二进制是否为 x86_64 架构
|
||||
///
|
||||
/// 用于判断 Apple Silicon 上是否需要通过 Rosetta 运行 sidecar。
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_x86_64_binary(path: &std::path::Path) -> bool {
|
||||
use std::io::Read;
|
||||
let mut file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut header = [0u8; 8];
|
||||
if file.read_exact(&mut header).is_err() {
|
||||
return false;
|
||||
}
|
||||
// Mach-O 64-bit little-endian magic number: 0xfeedfacf
|
||||
if header[..4] != [0xcf, 0xfa, 0xed, 0xfe] {
|
||||
return false;
|
||||
}
|
||||
// CPU type at offset 4 (little-endian u32)
|
||||
let cpu_type = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
|
||||
cpu_type == 0x01000007 // CPU_TYPE_X86_64
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn is_x86_64_binary(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 FFmpeg Sidecar 调用
|
||||
*
|
||||
* 注意:当前 aarch64-macOS sidecar 使用 evermeet x86_64 静态编译版本
|
||||
*(支持 HTTPS,但需要 Rosetta 2 在 Apple Silicon 上运行)。
|
||||
* 当检测到 sidecar 为 x86_64 二进制且当前运行在 aarch64 上时,
|
||||
* 自动通过 `/usr/bin/arch -x86_64` 调用。
|
||||
* 使用 Tauri 官方 sidecar API,自动处理开发/生产环境的 sidecar 查找。
|
||||
*/
|
||||
pub async fn run_ffmpeg(app: &AppHandle, args: Vec<String>) -> Result<String, String> {
|
||||
let sidecar_path = find_ffmpeg_sidecar()
|
||||
.map_err(|e| format!("Failed to find ffmpeg sidecar: {}", e))?;
|
||||
|
||||
// 如果 sidecar 是 x86_64 二进制,在 Apple Silicon 上需要通过 Rosetta 运行
|
||||
let needs_rosetta = cfg!(target_os = "macos")
|
||||
&& std::env::consts::ARCH == "aarch64"
|
||||
&& is_x86_64_binary(&sidecar_path);
|
||||
|
||||
let (mut rx, child) = if needs_rosetta {
|
||||
let sidecar_str = sidecar_path
|
||||
.to_str()
|
||||
.ok_or_else(|| "Sidecar 路径包含无效字符".to_string())?;
|
||||
app.shell()
|
||||
.command("arch")
|
||||
.args(&["-x86_64", sidecar_str])
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ffmpeg via Rosetta: {}", e))?
|
||||
} else {
|
||||
app.shell()
|
||||
.sidecar("ffmpeg")
|
||||
.map_err(|e| format!("Failed to find ffmpeg sidecar: {}", e))?
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?
|
||||
};
|
||||
let (mut rx, child) = app.shell()
|
||||
.sidecar("ffmpeg")
|
||||
.map_err(|e| format!("Failed to find ffmpeg sidecar: {e}"))?
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;
|
||||
|
||||
let mut output = String::new();
|
||||
let mut stderr_output = String::new();
|
||||
|
||||
@@ -56,6 +56,12 @@ pub fn run() {
|
||||
crate::storage::init_app_data_dir(app_data_dir);
|
||||
}
|
||||
|
||||
// Release 构建也打开 DevTools(临时:排查 Windows 网络问题)
|
||||
// 排查完成后可移除或改为快捷键触发
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.open_devtools();
|
||||
}
|
||||
|
||||
// macOS 自定义菜单栏(中文本地化)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"visible": true
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -41,7 +41,7 @@
|
||||
"plugins": {
|
||||
"opener": {},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQxMzg5NDcwQTVDMUY3MEIKUldRTDk4R2xjSlE0MFZPUzQwaDd4RzMwRHdsMWQzVy8wZGJndDVoYngwRlN6cU5wODgva215blcK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwRTdFOUI5NkZERkNFRDMKUldUVHp0OXZ1ZW5ua0pVN2M0ZGoyakxneFc5am5pR21UNFdCaThDN0p5S0JubUxUVUhLNnlkMWkK",
|
||||
"endpoints": [
|
||||
"https://dev.tapi.meijiaka.cn/api/v1/update/check?version={{current_version}}&target={{target}}&arch={{arch}}"
|
||||
]
|
||||
@@ -49,6 +49,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": [
|
||||
"nsis",
|
||||
"dmg",
|
||||
@@ -62,22 +63,39 @@
|
||||
"fonts/*": "fonts/"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/32x32.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "13.0",
|
||||
"infoPlist": "Info.plist"
|
||||
"infoPlist": "Info.plist",
|
||||
"dmg": {
|
||||
"background": "dmg-background.png",
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
"height": 400
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 170
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 480,
|
||||
"y": 170
|
||||
}
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"languages": [
|
||||
"SimpChinese"
|
||||
],
|
||||
"displayLanguageSelector": false
|
||||
"displayLanguageSelector": false,
|
||||
"installMode": "perMachine"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
.app-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.app-loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e8e8e8;
|
||||
border-top-color: #1a9e8a;
|
||||
border-radius: 50%;
|
||||
animation: app-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes app-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.app-loading-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a9e8a;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
|
||||
@@ -69,16 +69,22 @@ function App() {
|
||||
const [currentPage, setCurrentPage] = useState<PageType>('video-creation');
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
const [appEnvironment, setAppEnvironment] = useState<string>('production');
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// 应用启动时加载配置和认证状态
|
||||
useEffect(() => {
|
||||
loadAppConfig()
|
||||
.then(config => {
|
||||
setApiBaseUrl(config.apiBaseUrl);
|
||||
setAppEnvironment(config.environment);
|
||||
})
|
||||
.catch(console.error);
|
||||
loadFromStorage().catch(console.error);
|
||||
Promise.all([
|
||||
loadAppConfig()
|
||||
.then(config => {
|
||||
setApiBaseUrl(config.apiBaseUrl);
|
||||
setAppEnvironment(config.environment);
|
||||
})
|
||||
.catch(console.error),
|
||||
loadFromStorage().catch(console.error),
|
||||
]).finally(() => {
|
||||
// 确保至少显示 300ms 加载态,避免闪屏
|
||||
setTimeout(() => setIsReady(true), 300);
|
||||
});
|
||||
}, [loadFromStorage]);
|
||||
|
||||
// 固定浅色模式
|
||||
@@ -109,6 +115,15 @@ function App() {
|
||||
setCurrentPage(page as PageType);
|
||||
};
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<div className="app-loading-spinner" />
|
||||
<div className="app-loading-text">美家卡智影</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast 全局挂载 - 不受登录状态影响 */}
|
||||
|
||||
@@ -247,7 +247,22 @@ async function httpRequest<T>(
|
||||
fetchOptions.body = body as BodyInit;
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
// 发送请求,失败时输出详细诊断日志(跨平台网络问题排查用)
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, fetchOptions);
|
||||
} catch (networkErr) {
|
||||
const err = networkErr instanceof Error ? networkErr : new Error(String(networkErr));
|
||||
console.error('[client] 网络请求失败:', {
|
||||
method,
|
||||
url,
|
||||
platform: navigator.userAgent,
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
// 在错误消息中附加 URL,方便生产环境排查
|
||||
throw new Error(`${err.message} (URL: ${url})`);
|
||||
}
|
||||
|
||||
// ========== 被动刷新:401 时尝试刷新并重试 ==========
|
||||
if (response.status === 401 && !options?._retry && cachedAuth?.refreshToken) {
|
||||
|
||||
@@ -16,10 +16,30 @@ document.addEventListener('contextmenu', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// 前端渲染完成后,通知 Tauri 显示主窗口
|
||||
// 使用 requestIdleCallback 确保首帧已绘制
|
||||
const showWindow = () => {
|
||||
import('@tauri-apps/api/webviewWindow')
|
||||
.then(({ getCurrentWebviewWindow }) => {
|
||||
const win = getCurrentWebviewWindow();
|
||||
win.show();
|
||||
win.setFocus();
|
||||
})
|
||||
.catch(() => {
|
||||
// 非 Tauri 环境(如浏览器开发)忽略
|
||||
});
|
||||
};
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(showWindow, { timeout: 500 });
|
||||
} else {
|
||||
setTimeout(showWindow, 100);
|
||||
}
|
||||
|
||||
@@ -320,6 +320,14 @@ export default function UsageDetail() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
// 页码变化时自动查询(分页按钮触发)
|
||||
useEffect(() => {
|
||||
if (startDate && endDate && initialLoadTriggered.current) {
|
||||
load();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 根据后端配置过滤选项(只展示有扣费的业务)
|
||||
const filteredOptions = SOURCE_TYPE_OPTIONS.filter((opt) =>
|
||||
opt.value === '' || chargeableTypes.includes(opt.value),
|
||||
|
||||
@@ -49,11 +49,8 @@ export default function ScriptCreation() {
|
||||
.catch((err: Error) => {
|
||||
console.error('[分类加载失败]', err);
|
||||
if (!cached) {
|
||||
// 生产环境显示友好固定文案,开发环境显示详细错误
|
||||
const msg = import.meta.env.PROD
|
||||
? '加载分类列表失败,请检查网络连接'
|
||||
: `加载分类列表失败: ${err.message}`;
|
||||
toast.error(msg);
|
||||
// 始终显示详细错误,方便跨平台诊断(Windows WebView2 网络问题需要具体信息)
|
||||
toast.error(`加载分类列表失败: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -211,16 +211,30 @@
|
||||
|
||||
/* 空状态 */
|
||||
.voice-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-sm);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.voice-empty small {
|
||||
.voice-empty-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.voice-empty-desc {
|
||||
font-size: var(--font-xs);
|
||||
opacity: 0.7;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.voice-empty-action {
|
||||
margin-top: 4px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
/* 语速 */
|
||||
|
||||
@@ -15,6 +15,7 @@ import { toast } from '../../store/uiStore';
|
||||
import type { AlignmentResult } from '../../api/types';
|
||||
import { useProgressStore } from '../../store/progressStore';
|
||||
import { usePointsCheck } from '../../hooks/usePointsCheck';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import { getFriendlyErrorMessage } from '../../utils/errorMessage';
|
||||
import { createTask, getTaskStatus } from '../../api/modules/task';
|
||||
import { matchSegmentsToUtterances } from '../../utils/audioAlign';
|
||||
@@ -251,6 +252,7 @@ export default function VoiceSynthesis() {
|
||||
}
|
||||
|
||||
// 7. 保存字幕打轴结果到 meta,供 Step 4 直接复用
|
||||
// 同时用打轴的真实时长覆盖 dubbingAudioDuration(浏览器 audio.duration 读取不可靠)
|
||||
const subtitleAlignment: AlignmentResult = {
|
||||
status: 'completed',
|
||||
utterances: alignResult.utterances.map(u => ({
|
||||
@@ -260,8 +262,14 @@ export default function VoiceSynthesis() {
|
||||
})),
|
||||
duration: alignResult.duration,
|
||||
};
|
||||
useProjectStore.setState({ subtitleAlignment });
|
||||
await saveMetaToLocalFile({ subtitleAlignment });
|
||||
useProjectStore.setState({
|
||||
subtitleAlignment,
|
||||
dubbingAudioDuration: alignResult.duration,
|
||||
});
|
||||
await saveMetaToLocalFile({
|
||||
subtitleAlignment,
|
||||
dubbingAudioDuration: alignResult.duration,
|
||||
});
|
||||
|
||||
// 注意:不在这里调用 progress.success,最终成功态由调用方 handleGenerate 统一设置
|
||||
} catch (err) {
|
||||
@@ -385,6 +393,12 @@ export default function VoiceSynthesis() {
|
||||
}
|
||||
}, [projectId, segments, handleAlignAndClip, checkBalance, handleError, estimatedTtsPoints]);
|
||||
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
const handleGoToVoiceClone = useCallback(() => {
|
||||
navigate('voice-material');
|
||||
}, [navigate]);
|
||||
|
||||
const handleToggleGeneratedAudio = useCallback(() => {
|
||||
if (!dubbingAudioUrl) {return;}
|
||||
|
||||
@@ -452,7 +466,13 @@ export default function VoiceSynthesis() {
|
||||
{activeVoiceTab === 'clone' && (
|
||||
<div className="voice-list">
|
||||
{voiceMaterials.filter(m => m.status === 'ready').length === 0 ? (
|
||||
<div className="voice-empty">暂无私有音色<br /><small>去素材库上传音频并克隆音色</small></div>
|
||||
<div className="voice-empty">
|
||||
<div className="voice-empty-title">还没有私有音色</div>
|
||||
<div className="voice-empty-desc">上传一段音频,即可克隆你的专属音色</div>
|
||||
<button className="link-btn voice-empty-action" onClick={handleGoToVoiceClone}>
|
||||
去声音复刻 →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
voiceMaterials.filter(m => m.status === 'ready').map(m => (
|
||||
<div key={m.voiceId} className={`voice-row ${m.voiceId === selectedVoiceId ? 'selected' : ''}`} onClick={() => { setSelectedVoiceId(m.voiceId); saveMetaToLocalFile({ selectedVoiceId: m.voiceId }); }}>
|
||||
|
||||
@@ -61,6 +61,7 @@ function VideoCreationContent() {
|
||||
if (meta.selectedVoiceId !== undefined) {updates.selectedVoiceId = meta.selectedVoiceId;}
|
||||
if (meta.dubbingAudioUrl !== undefined) {updates.dubbingAudioUrl = meta.dubbingAudioUrl;}
|
||||
if (meta.dubbingAudioPath !== undefined) {updates.dubbingAudioPath = meta.dubbingAudioPath;}
|
||||
if (meta.dubbingAudioDuration !== undefined) {updates.dubbingAudioDuration = meta.dubbingAudioDuration;}
|
||||
if (meta.voiceSpeed !== undefined) {updates.voiceSpeed = meta.voiceSpeed;}
|
||||
if (meta.voiceVolume !== undefined) {updates.voiceVolume = meta.voiceVolume;}
|
||||
if (meta.voicePitch !== undefined) {updates.voicePitch = meta.voicePitch;}
|
||||
|
||||
@@ -441,7 +441,6 @@ export function useVideoGeneration({
|
||||
progress.hide();
|
||||
} else {
|
||||
progress.error(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
} finally {
|
||||
setIsComposing(false);
|
||||
|
||||