diff --git a/python-api/app/api/v1/image.py b/python-api/app/api/v1/image.py new file mode 100644 index 0000000..c17e190 --- /dev/null +++ b/python-api/app/api/v1/image.py @@ -0,0 +1,193 @@ +""" +图片处理 API +============ + +提供图片上传(七牛云)和 AI 抠图(火山引擎 MediaKit)功能。 +""" + +import io +import logging +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile +from pydantic import BaseModel, Field + +from app.api.deps import get_current_user +from app.config import get_settings +from app.models.user import User +from app.schemas.common import ApiResponse, success_response +from app.services.qiniu_service import get_qiniu_service +from app.services.volcengine_mediakit_service import VolcengineMediakitService +from app.utils.file_validation import check_upload_file + +router = APIRouter(tags=["Image"]) + +logger = logging.getLogger(__name__) +settings = get_settings() + + +# ── Dependencies ── + +async def get_mediakit_service(request: Request) -> VolcengineMediakitService: + """FastAPI Depends:从 app.state 获取全局 VolcengineMediakitService 实例。""" + service = getattr(request.app.state, "volcengine_mediakit_service", None) + if service is None: + raise HTTPException( + status_code=503, + detail="MediaKit 服务未初始化,请检查配置", + ) + return service + + +# ── Schemas ── + +class ImageUploadResponse(BaseModel): + """图片上传响应""" + + url: str = Field(..., description="七牛云图片 URL") + key: str = Field(..., description="七牛云文件 key") + size: int = Field(..., description="文件大小(字节)") + + +class RemoveBackgroundResponse(BaseModel): + """抠图响应""" + + url: str = Field(..., description="抠图结果图片 URL") + + +class RemoveBackgroundRequest(BaseModel): + """抠图请求""" + + image_url: str = Field(..., description="原始图片 URL") + scene: str = Field(default="general", description="场景类型:general(通用)或 product(商品)") + + +# ── Endpoints ── + +@router.post("/upload/image", response_model=ApiResponse[ImageUploadResponse]) +async def upload_image( + file: UploadFile = File(..., description="图片文件"), + current_user: User = Depends(get_current_user), +) -> ApiResponse[ImageUploadResponse]: + """ + 上传图片到七牛云 + + 支持格式:jpg, jpeg, png, gif, webp + 返回七牛云永久访问 URL。 + """ + try: + allowed_types = { + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + } + content_type = file.content_type or "" + + # 如果 content_type 为空,尝试从文件名推断 + if not content_type: + ext = Path(file.filename or "").suffix.lower() + ext_to_mime = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + } + content_type = ext_to_mime.get(ext, "") + + if content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail=f"不支持的图片格式: {content_type},请上传 jpg/png/gif/webp 图片", + ) + + # 读取文件内容 + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="文件内容为空") + + # 校验大小和魔数 + check_upload_file( + content, + settings.UPLOAD_MAX_IMAGE_SIZE, + content_type, + "图片", + ) + + # 生成唯一文件名 + ext = Path(file.filename or "image.jpg").suffix or ".jpg" + unique_name = f"{uuid.uuid4().hex[:16]}{ext}" + + # 上传到七牛云 + qiniu = get_qiniu_service() + bucket, domain = qiniu._get_bucket_and_domain("image") + file_key = qiniu.generate_key("image", unique_name) + stream = io.BytesIO(content) + result = await qiniu.upload_stream_async( + stream=stream, + key=file_key, + mime_type=content_type or "image/jpeg", + bucket=bucket, + domain=domain, + ) + + url = result.get("url") + returned_key = result.get("key") + + if not url: + raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL") + + logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}") + + return success_response( + data=ImageUploadResponse( + url=url, + key=returned_key or file_key, + size=len(content), + ) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"[Upload] 图片上传失败: {e}") + raise HTTPException(status_code=500, detail=f"上传失败: {e}") + + +@router.post("/image/remove-background", response_model=ApiResponse[RemoveBackgroundResponse]) +async def remove_background( + req: RemoveBackgroundRequest, + current_user: User = Depends(get_current_user), + mediakit_service: VolcengineMediakitService = Depends(get_mediakit_service), +) -> ApiResponse[RemoveBackgroundResponse]: + """ + AI 抠图(火山引擎 MediaKit) + + 移除图片背景,返回透明背景图片 URL。 + """ + try: + result = await mediakit_service.remove_background( + image_url=req.image_url, + scene=req.scene, + ) + + if not result.image_url: + raise HTTPException(status_code=500, detail="抠图失败:未返回结果图片 URL") + + logger.info(f"[RemoveBackground] 抠图成功: {result.image_url[:80]}...") + + return success_response( + data=RemoveBackgroundResponse(url=result.image_url), + message="抠图成功", + ) + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"[RemoveBackground] 抠图失败: {e}") + raise HTTPException(status_code=500, detail=f"抠图失败: {e}") diff --git a/python-api/app/api/v1/router.py b/python-api/app/api/v1/router.py index 41e9ec0..83a2aa5 100644 --- a/python-api/app/api/v1/router.py +++ b/python-api/app/api/v1/router.py @@ -10,6 +10,7 @@ from app.api.v1 import ( caption, cover_background, events, + image, materials, points, script, @@ -59,5 +60,8 @@ api_router.include_router(cover_background.router, tags=["Cover Background"]) # 积分系统模块 api_router.include_router(points.router, tags=["Points"]) +# 图片处理模块(上传 + 抠图) +api_router.include_router(image.router, tags=["Image"]) + # 应用更新模块 api_router.include_router(update.router, prefix="/update", tags=["Update"]) diff --git a/python-api/app/api/v1/upload.py b/python-api/app/api/v1/upload.py index 2b9c6fa..a8088dc 100644 --- a/python-api/app/api/v1/upload.py +++ b/python-api/app/api/v1/upload.py @@ -18,6 +18,7 @@ from app.config import get_settings from app.models.user import User from app.schemas.common import ApiResponse, success_response from app.services.qiniu_service import get_qiniu_service +from app.utils.file_validation import check_upload_file router = APIRouter(prefix="/upload", tags=["Upload"]) @@ -25,101 +26,6 @@ logger = logging.getLogger(__name__) settings = get_settings() -def _validate_file_magic(content: bytes, expected_content_type: str) -> bool: - """通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。""" - if len(content) < 12: - return False - - # 拒绝常见危险文件头 - dangerous_signatures = [ - (b"MZ", "Windows 可执行文件"), # .exe, .dll - (b"#!", "Shell 脚本"), # bash, python, etc - (b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx - (b" None: - """统一校验文件大小和魔数,失败时直接抛 HTTPException。""" - if len(content) > max_size: - max_mb = max_size // 1024 // 1024 - raise HTTPException( - status_code=413, - detail=f"{type_label}文件大小不能超过 {max_mb}MB", - ) - if not _validate_file_magic(content, content_type): - raise HTTPException( - status_code=400, - detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险", - ) - class UploadResponse(BaseModel): """上传响应""" @@ -173,7 +79,7 @@ async def upload_video( raise HTTPException(status_code=400, detail="文件内容为空") # 校验大小和魔数 - _check_upload_file( + check_upload_file( content, settings.UPLOAD_MAX_VIDEO_SIZE, content_type, @@ -269,7 +175,7 @@ async def upload_audio( raise HTTPException(status_code=400, detail="文件内容为空") # 校验大小和魔数 - _check_upload_file( + check_upload_file( content, settings.UPLOAD_MAX_AUDIO_SIZE, content_type, diff --git a/python-api/app/utils/file_validation.py b/python-api/app/utils/file_validation.py new file mode 100644 index 0000000..f314b09 --- /dev/null +++ b/python-api/app/utils/file_validation.py @@ -0,0 +1,105 @@ +""" +文件校验工具 +========== + +提供文件头魔数校验和上传文件统一校验功能, +防止 MIME 伪造攻击和危险文件上传。 +""" + +from fastapi import HTTPException + + +def validate_file_magic(content: bytes, expected_content_type: str) -> bool: + """通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。""" + if len(content) < 12: + return False + + # 拒绝常见危险文件头 + dangerous_signatures = [ + (b"MZ", "Windows 可执行文件"), # .exe, .dll + (b"#!", "Shell 脚本"), # bash, python, etc + (b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx + (b" None: + """统一校验文件大小和魔数,失败时直接抛 HTTPException。""" + if len(content) > max_size: + max_mb = max_size // 1024 // 1024 + raise HTTPException( + status_code=413, + detail=f"{type_label}文件大小不能超过 {max_mb}MB", + ) + if not validate_file_magic(content, content_type): + raise HTTPException( + status_code=400, + detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险", + ) diff --git a/tauri-app/src-tauri/src/commands/cover_avatar.rs b/tauri-app/src-tauri/src/commands/cover_avatar.rs new file mode 100644 index 0000000..87c73b2 --- /dev/null +++ b/tauri-app/src-tauri/src/commands/cover_avatar.rs @@ -0,0 +1,123 @@ +//! 封面形象管理 IPC 命令 + +use crate::ApiResponse; +use crate::storage::cover_avatar as cover_avatar_storage; + +// --------------------- 封面形象库命令 --------------------- + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverAvatarArgs { + pub id: String, + pub name: String, + pub image_url: String, + pub local_path: Option, + pub created_at: String, +} + +/// 加载封面形象库 +#[tauri::command] +pub async fn load_cover_avatars() -> ApiResponse> { + match cover_avatar_storage::load_cover_avatars() { + Ok(list) => ApiResponse { + code: 200, + message: "封面形象库加载成功".to_string(), + data: Some(list.avatars), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("加载封面形象库失败: {}", e), + data: Some(vec![]), + }, + } +} + +/// 保存封面形象 +#[tauri::command] +pub async fn save_cover_avatar( + args: CoverAvatarArgs, +) -> ApiResponse { + let avatar = cover_avatar_storage::CoverAvatar { + id: args.id, + name: args.name, + image_url: args.image_url, + local_path: args.local_path, + created_at: args.created_at, + }; + match cover_avatar_storage::add_cover_avatar(avatar) { + Ok(_) => ApiResponse { + code: 200, + message: "封面形象保存成功".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("保存封面形象失败: {}", e), + data: Some(false), + }, + } +} + +/// 删除封面形象 +#[tauri::command] +pub async fn delete_cover_avatar_cmd( + id: String, +) -> ApiResponse { + match cover_avatar_storage::delete_cover_avatar(&id) { + Ok(_) => ApiResponse { + code: 200, + message: "封面形象删除成功".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("删除封面形象失败: {}", e), + data: Some(false), + }, + } +} + +// --------------------- 封面形象图片文件命令 --------------------- + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveCoverAvatarImageArgs { + pub avatar_id: String, + pub image_data: String, // base64 编码 + pub ext: String, // 文件扩展名,如 "png" +} + +/// 保存封面形象图片文件(前端传入 base64 编码) +#[tauri::command] +pub async fn save_cover_avatar_image( + args: SaveCoverAvatarImageArgs, +) -> ApiResponse { + let image_bytes = match base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &args.image_data, + ) { + Ok(data) => data, + Err(e) => return ApiResponse { + code: 400, + message: format!("Invalid base64 data: {}", e), + data: None, + }, + }; + + match cover_avatar_storage::save_cover_avatar_image( + &args.avatar_id, + &image_bytes, + &args.ext, + ) { + Ok(path) => ApiResponse { + code: 200, + message: "封面形象图片保存成功".to_string(), + data: Some(path), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("保存封面形象图片失败: {}", e), + data: None, + }, + } +} diff --git a/tauri-app/src-tauri/src/commands/mod.rs b/tauri-app/src-tauri/src/commands/mod.rs index 0601fdd..4f55dbe 100644 --- a/tauri-app/src-tauri/src/commands/mod.rs +++ b/tauri-app/src-tauri/src/commands/mod.rs @@ -11,3 +11,4 @@ pub mod project; pub mod voice; pub mod video_compose; pub mod file; +pub mod cover_avatar; diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 0b86571..379a89d 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -111,6 +111,60 @@ fn clean_video_cache(app_data_dir: &std::path::Path) { } } +/// 手动清理 video_cache 目录,返回释放的字节数 +#[tauri::command] +fn clear_video_cache_cmd() -> Result { + let app_data_dir = crate::storage::paths::get_app_data_dir() + .map_err(|e| e.to_string())?; + let cache_dir = app_data_dir.join("video_cache"); + if !cache_dir.exists() { + return Ok(0); + } + + let mut freed: u64 = 0; + let read_dir = match std::fs::read_dir(&cache_dir) { + Ok(d) => d, + Err(e) => return Err(format!("无法读取缓存目录: {}", e)), + }; + + for entry in read_dir.flatten() { + let Ok(metadata) = entry.metadata() else { continue }; + if !metadata.is_file() { continue; } + let size = metadata.len(); + if std::fs::remove_file(entry.path()).is_ok() { + freed += size; + } + } + + Ok(freed) +} + +/// 获取 video_cache 目录当前占用大小(字节) +#[tauri::command] +fn get_video_cache_size_cmd() -> Result { + let app_data_dir = crate::storage::paths::get_app_data_dir() + .map_err(|e| e.to_string())?; + let cache_dir = app_data_dir.join("video_cache"); + if !cache_dir.exists() { + return Ok(0); + } + + let mut total: u64 = 0; + let read_dir = match std::fs::read_dir(&cache_dir) { + Ok(d) => d, + Err(e) => return Err(format!("无法读取缓存目录: {}", e)), + }; + + for entry in read_dir.flatten() { + let Ok(metadata) = entry.metadata() else { continue }; + if metadata.is_file() { + total += metadata.len(); + } + } + + Ok(total) +} + // ============================================================ // 应用入口 // ============================================================ @@ -257,6 +311,11 @@ pub fn run() { commands::voice::load_voice_materials, commands::voice::save_voice_material, commands::voice::delete_voice_material_cmd, + // 封面形象库 + commands::cover_avatar::load_cover_avatars, + commands::cover_avatar::save_cover_avatar, + commands::cover_avatar::delete_cover_avatar_cmd, + commands::cover_avatar::save_cover_avatar_image, // 压制成片(Phase 2) commands::video_compose::extract_video_segment, commands::video_compose::concat_video_clips, @@ -267,6 +326,9 @@ pub fn run() { get_video_metadata_cmd, // 视频预览转码(统一浏览器兼容格式) transcode_for_preview_cmd, + // 缓存清理 + get_video_cache_size_cmd, + clear_video_cache_cmd, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/tauri-app/src-tauri/src/storage/cover_avatar.rs b/tauri-app/src-tauri/src/storage/cover_avatar.rs new file mode 100644 index 0000000..86c8a90 --- /dev/null +++ b/tauri-app/src-tauri/src/storage/cover_avatar.rs @@ -0,0 +1,101 @@ +//! 封面形象存储模块 +//! +//! 管理用户上传的人物照片(抠图后)的本地存储。 + +use serde::{Deserialize, Serialize}; + +use crate::storage::engine::{atomic_write_bytes, atomic_write_json, ensure_dir, read_json, StorageError}; +use crate::storage::paths::get_cover_avatars_dir; + +/// 封面形象记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverAvatar { + pub id: String, + pub name: String, + /// 抠图后的透明背景图片 URL(七牛云) + pub image_url: String, + /// 本地文件路径(相对于项目目录或绝对路径) + pub local_path: Option, + pub created_at: String, +} + +/// 封面形象列表 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CoverAvatarsList { + pub avatars: Vec, + pub updated_at: String, +} + +/// 加载封面形象库 +pub fn load_cover_avatars() -> Result { + let path = crate::storage::paths::get_cover_avatars_json_path()?; + Ok(read_json(&path)?.unwrap_or_default()) +} + +/// 保存封面形象库 +pub fn save_cover_avatars(list: &CoverAvatarsList) -> Result<(), StorageError> { + let path = crate::storage::paths::get_cover_avatars_json_path()?; + atomic_write_json(&path, list) +} + +/// 添加封面形象 +pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> { + let mut list = load_cover_avatars()?; + // 去重:相同 id 替换 + if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) { + list.avatars[pos] = avatar; + } else { + list.avatars.push(avatar); + } + list.updated_at = chrono_lite_now(); + save_cover_avatars(&list) +} + +/// 删除封面形象 +pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> { + let mut list = load_cover_avatars()?; + let pos = list.avatars.iter().position(|a| a.id == id) + .ok_or_else(|| StorageError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("封面形象 {} 不存在", id), + )))?; + list.avatars.remove(pos); + list.updated_at = chrono_lite_now(); + save_cover_avatars(&list) +} + +/// 保存封面形象图片文件到本地 +/// +/// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。 +pub fn save_cover_avatar_image( + avatar_id: &str, + data: &[u8], + ext: &str, +) -> Result { + let avatars_dir = get_cover_avatars_dir()?; + ensure_dir(&avatars_dir)?; + + // 净化扩展名:只允许字母数字,防止路径遍历 + let safe_ext = ext + .chars() + .filter(|c| c.is_alphanumeric()) + .collect::(); + if safe_ext.is_empty() { + return Err(StorageError::InvalidId("无效的文件扩展名".into())); + } + + let file_name = format!("{}.{}", avatar_id, safe_ext); + let file_path = avatars_dir.join(&file_name); + + atomic_write_bytes(&file_path, data)?; + + Ok(file_path.to_str().unwrap_or_default().to_string()) +} + +// ====================== 工具函数 ====================== + +fn chrono_lite_now() -> String { + chrono::Utc::now().to_rfc3339() +} diff --git a/tauri-app/src-tauri/src/storage/mod.rs b/tauri-app/src-tauri/src/storage/mod.rs index ab1bb00..e687a6a 100644 --- a/tauri-app/src-tauri/src/storage/mod.rs +++ b/tauri-app/src-tauri/src/storage/mod.rs @@ -12,6 +12,7 @@ pub mod project; pub mod auth; pub mod voice; pub mod config; +pub mod cover_avatar; pub use engine::{ atomic_write_json, atomic_write_bytes, @@ -22,5 +23,5 @@ pub use paths::{ init_app_data_dir, get_app_data_dir, get_projects_root_dir, get_project_dir, get_project_dir_path, get_project_assets_dir, get_project_videos_dir, get_project_products_dir, get_voices_json_path, - get_app_config_json_path, + get_app_config_json_path, get_cover_avatars_dir, get_cover_avatars_json_path, }; diff --git a/tauri-app/src-tauri/src/storage/paths.rs b/tauri-app/src-tauri/src/storage/paths.rs index 5b158ae..7b9d0d9 100644 --- a/tauri-app/src-tauri/src/storage/paths.rs +++ b/tauri-app/src-tauri/src/storage/paths.rs @@ -104,3 +104,19 @@ pub fn get_auth_state_path(app: &AppHandle) -> Result { crate::storage::engine::ensure_dir(&path)?; Ok(path.join("auth.json")) } + +/// 获取封面形象图片存储目录 +/// {app_local_data_dir}/cover_avatars/ +pub fn get_cover_avatars_dir() -> Result { + let base = get_app_data_dir()?; + let path = base.join("cover_avatars"); + crate::storage::engine::ensure_dir(&path)?; + Ok(path) +} + +/// 获取封面形象库 JSON 路径 +/// {app_local_data_dir}/cover_avatars.json +pub fn get_cover_avatars_json_path() -> Result { + let base = get_app_data_dir()?; + Ok(base.join("cover_avatars.json")) +} diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 07f0b60..2cfd470 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -9,6 +9,7 @@ import Login from './pages/Login/Login'; import VideoCreation from './pages/VideoCreation'; import MyWorks from './pages/ContentManagement/MyWorks'; import VoiceMaterialLibrary from './pages/ContentManagement/VoiceMaterialLibrary'; +import CoverAvatarLibrary from './pages/ContentManagement/CoverAvatarLibrary'; import Settings from './pages/Settings/Settings'; import Profile from './pages/Profile/Profile'; import UsageDetail from './pages/Profile/UsageDetail'; @@ -29,6 +30,7 @@ import './App.css'; type PageType = | 'video-creation' | 'voice-material' + | 'cover-avatar' | 'my-works' | 'settings' | 'profile' @@ -38,6 +40,7 @@ type PageType = const pages: Record = { 'video-creation': VideoCreation, 'voice-material': VoiceMaterialLibrary, + 'cover-avatar': CoverAvatarLibrary, 'my-works': MyWorks, settings: Settings, profile: Profile, diff --git a/tauri-app/src/api/modules/coverAvatar.ts b/tauri-app/src/api/modules/coverAvatar.ts new file mode 100644 index 0000000..0410dfd --- /dev/null +++ b/tauri-app/src/api/modules/coverAvatar.ts @@ -0,0 +1,104 @@ +/** + * Cover Avatar 封面形象 API 模块 + * ================================ + * + * 提供图片上传、AI 抠图、本地封面形象库管理接口。 + */ + +import { invoke } from '@tauri-apps/api/core'; +import { client } from '../client'; + +// ====================== 类型 ====================== + +export interface CoverAvatar { + id: string; + name: string; + imageUrl: string; + localPath?: string; + createdAt: string; +} + +// ====================== HTTP API(后端直连) ====================== + +/** + * 上传图片到七牛云 + */ +export async function uploadImage(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + const result = await client.postForm<{ url: string; key: string }>('/upload/image', formData); + return result.url; +} + +/** + * AI 抠图(火山引擎 MediaKit) + */ +export async function removeBackground(imageUrl: string, scene = 'general'): Promise { + const result = await client.post<{ url: string }>('/image/remove-background', { + imageUrl, + scene, + }); + return result.url; +} + +// ====================== IPC 命令(本地存储) ====================== + +/** + * 从本地加载封面形象库 + */ +export async function loadCoverAvatars(): Promise { + const result = await invoke<{ code: number; data?: CoverAvatar[]; message: string }>('load_cover_avatars'); + if (result.code !== 200) { + throw new Error(result.message || '加载封面形象库失败'); + } + return result.data || []; +} + +/** + * 保存封面形象到本地 + */ +export async function saveCoverAvatar(avatar: CoverAvatar): Promise { + const result = await invoke<{ code: number; message: string }>('save_cover_avatar', { + args: { + id: avatar.id, + name: avatar.name, + imageUrl: avatar.imageUrl, + localPath: avatar.localPath, + createdAt: avatar.createdAt, + }, + }); + if (result.code !== 200) { + throw new Error(result.message || '保存封面形象失败'); + } +} + +/** + * 删除本地封面形象 + */ +export async function deleteCoverAvatar(id: string): Promise { + const result = await invoke<{ code: number; message: string }>('delete_cover_avatar_cmd', { id }); + if (result.code !== 200) { + throw new Error(result.message || '删除封面形象失败'); + } +} + +/** + * 保存封面形象图片文件到本地(base64 编码) + */ +export async function saveCoverAvatarImage(args: { + avatarId: string; + imageData: string; + ext: string; +}): Promise { + const result = await invoke<{ code: number; data?: string; message: string }>('save_cover_avatar_image', { + args: { + avatarId: args.avatarId, + imageData: args.imageData, + ext: args.ext, + }, + }); + if (result.code !== 200 || !result.data) { + throw new Error(result.message || '保存封面形象图片失败'); + } + return result.data; +} diff --git a/tauri-app/src/api/modules/localStorage.ts b/tauri-app/src/api/modules/localStorage.ts index 9d17fe4..0897140 100644 --- a/tauri-app/src/api/modules/localStorage.ts +++ b/tauri-app/src/api/modules/localStorage.ts @@ -111,6 +111,7 @@ export const localProjectApi = { lipSyncedVideoUrl: meta.lipSyncedVideoUrl, dubbingAudioUrl: meta.dubbingAudioUrl, dubbingAudioPath: meta.dubbingAudioPath, + dubbingAudioDuration: meta.dubbingAudioDuration, voiceSpeed: meta.voiceSpeed, voiceVolume: meta.voiceVolume, voicePitch: meta.voicePitch, diff --git a/tauri-app/src/components/Layout/Sidebar.tsx b/tauri-app/src/components/Layout/Sidebar.tsx index 22ffaf1..5be30d6 100644 --- a/tauri-app/src/components/Layout/Sidebar.tsx +++ b/tauri-app/src/components/Layout/Sidebar.tsx @@ -23,6 +23,7 @@ const navItems: NavItem[] = [ icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', children: [ { id: 'voice-material', label: '声音复刻' }, + { id: 'cover-avatar', label: '封面形象' }, { id: 'my-works', label: '我的作品' }, ], }, diff --git a/tauri-app/src/hooks/useCoverFabric.ts b/tauri-app/src/hooks/useCoverFabric.ts index 7452501..954d8f0 100644 --- a/tauri-app/src/hooks/useCoverFabric.ts +++ b/tauri-app/src/hooks/useCoverFabric.ts @@ -28,6 +28,7 @@ interface CoverDesignConfig { backgroundImage: string | null; mainTitle: string; subtitle: string; + avatarImage?: string | null; } interface TemplateDef { @@ -218,6 +219,49 @@ export function useCoverFabric() { [] ); + // 加载封面形象(叠加在背景之上,文字之下) + const loadAvatarImage = useCallback( + async (canvas: Canvas, imageUrl: string): Promise => { + try { + const img = await new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = (e) => reject(e); + image.src = imageUrl; + }); + + const fabricImg = new FabricImage(img); + // 计算缩放:宽度不超过画布的 70%,高度不超过画布的 40% + const maxWidth = CANVAS_WIDTH * 0.7; + const maxHeight = CANVAS_HEIGHT * 0.4; + const scale = Math.min( + maxWidth / (fabricImg.width || 1), + maxHeight / (fabricImg.height || 1), + 1 // 不放大原图 + ); + fabricImg.scale(scale); + + const scaledWidth = (fabricImg.width || 1) * scale; + + // 居中偏下定位(约 55% 处) + fabricImg.set({ + left: (CANVAS_WIDTH - scaledWidth) / 2, + top: CANVAS_HEIGHT * 0.55, + originX: 'left', + originY: 'top', + selectable: false, + evented: false, + }); + canvas.add(fabricImg); + canvas.renderAll(); + } catch { + // no-op + } + }, + [] + ); + // 渲染封面 const renderCover = useCallback( async (config: CoverDesignConfig) => { @@ -240,9 +284,18 @@ export function useCoverFabric() { } } + // 2. 封面形象(叠加在背景之上) + if (config.avatarImage) { + try { + await loadAvatarImage(canvas, config.avatarImage); + } catch { + // no-op: 封面形象加载失败不影响主流程 + } + } + const template = TEMPLATES[config.template]; - // 2. 主标题(自动换行,最多2行,居中) + // 3. 主标题(自动换行,最多2行,居中) if (config.mainTitle.trim()) { const maxWidth = CANVAS_WIDTH - 120; // 左右留边 const lines = wrapTextByWidth( @@ -272,7 +325,7 @@ export function useCoverFabric() { }); } - // 3. 副标题(底部居中,加粗) + // 4. 副标题(底部居中,加粗) if (config.subtitle.trim()) { const maxWidth = CANVAS_WIDTH - 120; const lines = wrapTextByWidth( diff --git a/tauri-app/src/pages/ContentManagement/ContentManagement.css b/tauri-app/src/pages/ContentManagement/ContentManagement.css index e52422b..0df18c6 100644 --- a/tauri-app/src/pages/ContentManagement/ContentManagement.css +++ b/tauri-app/src/pages/ContentManagement/ContentManagement.css @@ -2057,3 +2057,171 @@ max-width: 280px; line-height: 1.6; } + +/* ============================================================ + 关于我们 (About) - 品牌展示 + 版本更新 + ============================================================ */ + +.about-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-md); +} + +.about-brand { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); +} + +.about-logo { + width: 64px; + height: 64px; + border-radius: var(--radius-lg); + object-fit: contain; +} + +.about-name { + font-size: var(--font-lg); + font-weight: 600; + color: var(--text-primary); +} + +.about-version-row { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 320px; + margin: 0 auto; + padding: var(--spacing-xs) 0; + gap: var(--spacing-sm); +} + +.about-version-text { + font-size: var(--font-sm); + color: var(--text-secondary); + cursor: default; + user-select: none; + flex-shrink: 0; + font-weight: 500; +} + +.about-version-actions { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.about-checking-status { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: var(--font-sm); + color: var(--text-tertiary); +} + +.about-check-result { + font-size: var(--font-sm); + font-weight: 500; + white-space: nowrap; +} + +.about-check-latest { + color: var(--success); +} + +.about-check-available { + color: var(--primary); +} + +.about-check-btn { + flex-shrink: 0; + background: transparent; + color: var(--primary); + border: none; + border-radius: var(--radius-md); + padding: 4px 10px; + font-size: var(--font-xs); + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.about-check-btn:hover:not(:disabled) { + background: rgba(54, 178, 106, 0.08); +} + +.about-check-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.about-update-detail { + width: 100%; + padding: var(--spacing-lg); + border-top: 1px solid var(--border-light); + background: var(--bg-input); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.about-update-body-title { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--spacing-xs); +} + +.about-update-body pre { + font-size: var(--font-sm); + line-height: 1.7; + white-space: pre-wrap; + margin: 0; + color: var(--text-primary); +} + +.about-update-progress { + margin-bottom: var(--spacing-sm); +} + +.about-progress-bar { + height: 6px; + background: var(--border-light); + border-radius: 3px; + overflow: hidden; +} + +.about-progress-fill { + height: 100%; + background: var(--primary); + border-radius: 3px; + transition: width 0.3s; +} + +.about-progress-meta { + display: flex; + justify-content: space-between; + font-size: var(--font-xs); + color: var(--text-tertiary); + margin-top: 4px; +} + +.about-update-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-sm); +} + +.about-update-error { + width: 100%; + padding: var(--spacing-md) var(--spacing-lg); + background: #fef2f2; + color: #dc2626; + font-size: var(--font-sm); + border-radius: var(--radius-md); +} diff --git a/tauri-app/src/pages/ContentManagement/CoverAvatarLibrary.tsx b/tauri-app/src/pages/ContentManagement/CoverAvatarLibrary.tsx new file mode 100644 index 0000000..823cd15 --- /dev/null +++ b/tauri-app/src/pages/ContentManagement/CoverAvatarLibrary.tsx @@ -0,0 +1,361 @@ +/** + * 封面形象库页面 + * ============ + * + * 管理用户上传的人物照片(抠图后)。 + * 上传图片 → 七牛云 → 火山引擎 MediaKit 抠图 → 保存到本地素材库。 + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useCoverAvatarStore } from '../../store'; +import { toast } from '../../store/uiStore'; +import { useProgressStore } from '../../store/progressStore'; +import Modal from '../../components/Modal/Modal'; +import ConfirmModal from '../../components/Modal/ConfirmModal'; +import './ContentManagement.css'; + +export default function CoverAvatarLibrary() { + const [uploadModalOpen, setUploadModalOpen] = useState(false); + const [uploadName, setUploadName] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + + // 重命名状态 + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(''); + + // 删除确认状态 + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); + + const fileInputRef = useRef(null); + + const { + coverAvatars, + isLoading, + loadCoverAvatars, + addCoverAvatar, + renameCoverAvatar, + deleteCoverAvatar, + } = useCoverAvatarStore(); + + // 加载数据 + useEffect(() => { + loadCoverAvatars(); + }, [loadCoverAvatars]); + + // 图片文件验证 + const validateImageFile = (file: File): { valid: boolean; error?: string } => { + const maxSize = 20 * 1024 * 1024; // 20MB + if (file.size > maxSize) { + return { valid: false, error: `文件大小 ${(file.size / 1024 / 1024).toFixed(1)}MB,要求不超过 20MB` }; + } + + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + return { valid: false, error: '仅支持 JPG、PNG、GIF、WebP 格式' }; + } + + return { valid: true }; + }; + + // 文件选择 + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) {return;} + + const validation = validateImageFile(file); + if (!validation.valid) { + toast.error(validation.error || '文件验证失败'); + e.target.value = ''; + return; + } + + setSelectedFile(file); + // 默认使用文件名(不含扩展名)作为名称 + if (!uploadName.trim()) { + const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ''); + setUploadName(nameWithoutExt); + } + }, [uploadName]); + + // 重置弹窗状态 + const resetUploadModal = useCallback(() => { + setUploadModalOpen(false); + setUploadName(''); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, []); + + // 上传处理 + const handleUpload = useCallback(async () => { + if (!uploadName.trim() || !selectedFile) {return;} + + const progress = useProgressStore.getState(); + setUploadModalOpen(false); + + progress.show('上传封面形象'); + try { + progress.update('正在上传并抠图...'); + const avatar = await addCoverAvatar(selectedFile, uploadName.trim()); + progress.success('封面形象保存成功', 200); + toast.success(`「${avatar.name}」已保存`); + } catch (err) { + const msg = err instanceof Error ? err.message : '上传失败'; + progress.error(msg); + toast.error(msg); + } + + setUploadName(''); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [uploadName, selectedFile, addCoverAvatar]); + + // 删除处理 + const openDeleteModal = (id: string, name: string) => { + setDeleteTarget({ id, name }); + setDeleteModalOpen(true); + }; + + const handleConfirmDelete = useCallback(async () => { + if (!deleteTarget) {return;} + try { + await deleteCoverAvatar(deleteTarget.id); + toast.success('已删除'); + } catch { + toast.error('删除失败'); + } finally { + setDeleteModalOpen(false); + setDeleteTarget(null); + } + }, [deleteTarget, deleteCoverAvatar]); + + // 重命名 + const startRename = (id: string, currentName: string) => { + setEditingId(id); + setEditingName(currentName); + }; + + const cancelRename = () => { + setEditingId(null); + setEditingName(''); + }; + + const confirmRename = useCallback(async () => { + if (!editingId || !editingName.trim()) { + cancelRename(); + return; + } + try { + await renameCoverAvatar(editingId, editingName.trim()); + setEditingId(null); + setEditingName(''); + } catch { + toast.error('重命名失败'); + } + }, [editingId, editingName, renameCoverAvatar]); + + return ( +
+ {/* 页面标题和上传区域 */} +
+
+

封面形象

+

上传人物照片,AI 自动抠图,用于封面设计叠加

+
+ + {/* 上传引导卡片 */} +
setUploadModalOpen(true)} + > +
+ + + + + +
+
+ 上传人物照片 + JPG / PNG / GIF / WebP,建议半身或全身照 +
+
+ + + + +
+
+
+ + {/* 上传弹窗 */} + +
+
+ + setUploadName(e.target.value)} + style={{ width: '100%' }} + /> +
+ +
+ +
fileInputRef.current?.click()} + onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--primary)'; }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; }} + > + + {selectedFile ? ( +
+
{selectedFile.name}
+
+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +
+
+ ) : ( +
+
点击选择图片
+
+ 支持 JPG / PNG / GIF / WebP,不超过 20MB +
+
+ )} +
+
+ +
+ + +
+
+
+ + {/* 封面形象列表 */} + {isLoading ? ( +

加载中...

+ ) : coverAvatars.length === 0 ? ( +
+
+ + + + + +
+

暂无封面形象

+

上传一张人物照片,
AI 将自动抠图生成透明背景形象

+
+ ) : ( +
+
+ {coverAvatars.map(a => ( +
+ {/* 图片预览 */} +
+ {a.name} +
+ +
+
+ {editingId === a.id ? ( + setEditingName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') {confirmRename();} + if (e.key === 'Escape') {cancelRename();} + }} + onBlur={confirmRename} + autoFocus + style={{ width: '100%', height: 28, padding: '2px 8px', fontSize: 'var(--font-sm)' }} + /> + ) : ( +
+ {a.name} +
+ )} +
+
+ + +
+
+
+ ))} +
+
+ )} + + {/* 删除确认弹窗 */} + 确认删除形象 「{deleteTarget?.name}」 吗?} + description="此操作不可撤销,形象将被永久删除" + confirmText="确认删除" + cancelText="取消" + confirmButtonType="danger" + onConfirm={handleConfirmDelete} + onCancel={() => { setDeleteModalOpen(false); setDeleteTarget(null); }} + /> +
+ ); +} diff --git a/tauri-app/src/pages/Settings/Settings.tsx b/tauri-app/src/pages/Settings/Settings.tsx index 4a388b8..bc68bf5 100644 --- a/tauri-app/src/pages/Settings/Settings.tsx +++ b/tauri-app/src/pages/Settings/Settings.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { useNavigation } from '../../contexts/NavigationContext'; import AppHeader from '../../components/Layout/AppHeader'; import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal'; @@ -27,6 +27,10 @@ export default function Settings() { // ── 环境切换 ── const [showEnvModal, setShowEnvModal] = useState(false); + + // ── 缓存清理 ── + const [cacheSize, setCacheSize] = useState(0); + const [clearingCache, setClearingCache] = useState(false); const clickCountRef = useRef(0); const clickTimerRef = useRef | null>(null); @@ -128,6 +132,43 @@ export default function Settings() { }, 2000); }, []); + // 获取缓存大小 + const fetchCacheSize = useCallback(async () => { + try { + const size = await invoke('get_video_cache_size_cmd'); + setCacheSize(size); + } catch { + setCacheSize(0); + } + }, []); + + useEffect(() => { + fetchCacheSize(); + return () => { + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + } + }; + }, [fetchCacheSize]); + + // 清理缓存 + const handleClearCache = async () => { + if (cacheSize === 0) { + toast.info('暂无缓存需要清理'); + return; + } + setClearingCache(true); + try { + const freed = await invoke('clear_video_cache_cmd'); + setCacheSize(0); + toast.success(`已清理 ${formatBytes(freed)} 缓存`); + } catch { + toast.error('清理缓存失败'); + } finally { + setClearingCache(false); + } + }; + const handleSaveEnv = async (env: string) => { try { await saveAppConfig(env); @@ -149,58 +190,70 @@ export default function Settings() {
navigate('profile')} /> - {/* 系统更新 */} + {/* 关于我们 */}
-

系统更新

-
-
- 当前版本 - v{CURRENT_VERSION} +
+ {/* 品牌 */} +
+ 美家卡智影 + 美家卡 智影
-
- 版本更新 -
+ {/* 版本 + 检查更新 */} +
+ + 当前版本 v{CURRENT_VERSION} + +
{checking ? ( - - + + 检查中... ) : checkResult === 'latest' ? ( - 当前已是最新版本 + 当前已是最新版本 ) : checkResult === 'available' ? ( - 发现新版本 {updateInfo?.version} + 发现新版本 {updateInfo?.version} ) : null} -
+ {/* 更新详情(发现新版本时展开) */} {checkResult === 'available' && updateInfo && ( -
+
{updateInfo.body && ( -
-
更新内容
-
{updateInfo.body}
+
+
更新内容
+
{updateInfo.body}
)} {downloading && ( -
-
-
+
+
+
-
+
{progress}% {totalBytes > 0 && {formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}}
)} -
+
{installing ? ( ) : ( @@ -213,31 +266,31 @@ export default function Settings() { )} {updateError && ( -
+
{updateError}
)}
- {/* 关于我们 */} + {/* 缓存清理 */}
-

关于我们

+

缓存管理

- 应用名称 - 美家卡 智影 -
-
- 版本号 - - v{CURRENT_VERSION} - + 本地文件缓存 +
+ + {cacheSize > 0 ? formatBytes(cacheSize) : '暂无缓存'} + + +
@@ -248,7 +301,7 @@ export default function Settings() {

本软件由美家卡团队开发维护。授权用户可在授权范围内使用本软件进行视频创作。 - 如需商业授权或有任何疑问,请联系我们的支持团队。 + 如需商业授权或有任何疑问,请联系我们的支持团队(support@meijiaka.cn)。

diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.css b/tauri-app/src/pages/VideoCreation/CoverDesign.css index 14acd79..30f150e 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.css +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.css @@ -5,13 +5,13 @@ * 完全遵循字幕压制页面结构和间距,保持一致 */ -/* 布局 - 55:45 和字幕压制一致 */ +/* 布局 - 40:60 */ .step-layout.subtitle-burning { grid-template-columns: 55fr 45fr; } .step-layout.cover-design-variant { - grid-template-columns: 55fr 45fr; + grid-template-columns: 40fr 60fr; } /* 左侧操作区 - 与视频生成页面统一 */ @@ -389,6 +389,178 @@ flex-shrink: 0; } +/* 视觉素材横向卡片 */ +.visual-assets-row { + display: flex; + gap: var(--spacing-md); + justify-content: center; + flex-wrap: wrap; +} + +.visual-asset-card { + flex: 0 0 auto; + width: 250px; + max-width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px; + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-card); + cursor: pointer; + transition: all var(--transition-fast); +} + +.visual-asset-card:hover { + border-color: var(--primary); + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); +} + +.visual-asset-label { + font-size: var(--font-xs); + font-weight: 500; + color: var(--text-secondary); +} + +.visual-asset-card img { + width: 100%; + aspect-ratio: 9 / 16; + object-fit: cover; + border-radius: var(--radius-sm); + display: block; +} + +.visual-asset-placeholder { + width: 100%; + aspect-ratio: 9 / 16; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--text-tertiary); + font-size: var(--font-xs); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.visual-asset-hint { + font-size: 11px; + color: var(--text-tertiary); +} + +/* 弹窗背景图网格 */ +.modal-bg-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-sm); +} + +.modal-bg-thumb { + position: relative; + padding: 0; + border: 2px solid transparent; + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + background: none; + aspect-ratio: 9 / 16; + transition: all var(--transition-fast); +} + +.modal-bg-thumb:hover { + border-color: var(--border-color); + transform: scale(1.02); +} + +.modal-bg-thumb.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.modal-bg-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* 弹窗封面形象网格 */ +.modal-avatar-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-sm); +} + +.modal-avatar-thumb { + position: relative; + padding: 0; + border: 2px solid transparent; + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + background: var(--bg-secondary); + aspect-ratio: 1; + transition: all var(--transition-fast); +} + +.modal-avatar-thumb:hover { + border-color: var(--border-color); + transform: scale(1.02); +} + +.modal-avatar-thumb.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.modal-avatar-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #999; +} + +.modal-bg-error { + background: var(--bg-secondary, #f5f5f5); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-bg-error::after { + content: '图片加载失败'; + font-size: 12px; + color: #999; + padding: 0 8px; + text-align: center; +} + +.avatar-thumb-error { + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-thumb-error::after { + content: '加载失败'; + font-size: 10px; + color: #999; +} + /* 背景图片区域头部(标签 + 按钮) */ .bg-section-header { display: flex; diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx index c187a7d..131180e 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx @@ -3,13 +3,14 @@ * ================================ * * 使用 Fabric.js Canvas 实时预览封面: - * - 背景图:支持选择本地图片 - * - 模板:双标题居中 / 标题+标签列表 - * - 文字样式:固定位置、颜色、大小 + * - 背景图:点击卡片弹窗选图(6 张随机) + * - 封面形象:点击卡片弹窗选人 + * - 模板:双标题居中 * - 导出:PNG 高清导出 */ import { useState, useEffect } from 'react'; +import { useCoverAvatarStore } from '../../store'; import { invoke } from '@tauri-apps/api/core'; import { useProjectStore } from '../../store'; import { getCurrentProjectId } from '../../api/modules/localStorage'; @@ -21,6 +22,7 @@ import { pointsApi } from '../../api/modules/points'; import { usePointStore, useAuthStore } from '../../store'; import RechargeModal from '../../components/RechargeModal/RechargeModal'; import ConfirmModal from '../../components/Modal/ConfirmModal'; +import Modal from '../../components/Modal/Modal'; import { toast } from '../../store/uiStore'; import { getFriendlyErrorMessage } from '../../utils/errorMessage'; import { usePointsCheck } from '../../hooks/usePointsCheck'; @@ -41,20 +43,20 @@ function shuffleArray(arr: T[]): T[] { return result; } -function pickThree(all: BgImage[], lastIds: Set): BgImage[] { - if (all.length <= 3) {return all;} +function pickSix(all: BgImage[], lastIds: Set): BgImage[] { + if (all.length <= 6) {return shuffleArray(all);} let shuffled = shuffleArray(all); for (let i = 0; i < 10; i++) { - const picked = shuffled.slice(0, 3); + const picked = shuffled.slice(0, 6); const pickedIds = new Set(picked.map((b) => b.id)); const allSame = - pickedIds.size === 3 && - lastIds.size === 3 && + pickedIds.size === 6 && + lastIds.size === 6 && [...pickedIds].every((id) => lastIds.has(id)); if (!allSame) {return picked;} shuffled = shuffleArray(all); } - return shuffled.slice(0, 3); + return shuffled.slice(0, 6); } export default function CoverDesign() { @@ -71,11 +73,13 @@ export default function CoverDesign() { backgroundImage: string | null; mainTitle: string; subtitle: string; + avatarImage: string | null; }>({ template: 'dual-title', backgroundImage: null, mainTitle: '', subtitle: '', + avatarImage: null, }); const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false); const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false); @@ -85,12 +89,16 @@ export default function CoverDesign() { const setShowRechargeModal = usePointStore(state => state.setShowRechargeModal); const fetchBalance = usePointStore(state => state.fetchBalance); + // 弹窗状态 + const [bgModalOpen, setBgModalOpen] = useState(false); + const [avatarModalOpen, setAvatarModalOpen] = useState(false); + // 背景图配置 const categoryCode = useProjectStore((state) => state.categoryCode); const [bgList, setBgList] = useState([]); - const [displayedBgs, setDisplayedBgs] = useState([]); - const [lastBgIds, setLastBgIds] = useState>(new Set()); - const [bgLoadStatus, setBgLoadStatus] = useState<'idle' | 'loading' | 'empty' | 'error' | 'no_category'>('idle'); + const [modalBgs, setModalBgs] = useState([]); + const [lastModalBgIds, setLastModalBgIds] = useState>(new Set()); + const [, setBgLoadStatus] = useState<'idle' | 'loading' | 'empty' | 'error' | 'no_category'>('idle'); const { canvasRef, initCanvas, renderCover, exportPng } = useCoverFabric(); @@ -104,7 +112,6 @@ export default function CoverDesign() { toast.error('暂无脚本内容,请先生成脚本'); return; } - // 前置积分检查 const titlePoints = usePointStore.getState().getRule('title')?.points || 1; const canProceed = await checkBalance(titlePoints, '标题生成', true); if (!canProceed) {return;} @@ -145,7 +152,7 @@ export default function CoverDesign() { } }; - // 加载背景图(从 API 根据 script_code 获取) + // 加载背景图 useEffect(() => { const loadBackgrounds = async () => { if (!categoryCode) { @@ -163,12 +170,11 @@ export default function CoverDesign() { setBgList(list); if (list.length === 0) { setBgLoadStatus('empty'); - setDisplayedBgs([]); return; } - const picked = pickThree(list, new Set()); - setDisplayedBgs(picked); - setLastBgIds(new Set(picked.map((b) => b.id))); + // 初始随机选一张作为默认背景 + const defaultBg = list[Math.floor(Math.random() * list.length)]; + setConfig(prev => ({ ...prev, backgroundImage: defaultBg.src })); setBgLoadStatus('idle'); } catch (err) { console.error('[CoverDesign] 加载背景图失败:', err); @@ -178,18 +184,20 @@ export default function CoverDesign() { loadBackgrounds(); }, [categoryCode]); - // 换一组背景图 - const handleShuffleBgs = () => { - const picked = pickThree(bgList, lastBgIds); - setDisplayedBgs(picked); - setLastBgIds(new Set(picked.map((b) => b.id))); - // 如果当前选中的背景不在新的一组中,清空选择 - setConfig((prev) => { - const stillAvailable = picked.some((b) => b.src === prev.backgroundImage); - return stillAvailable - ? prev - : { ...prev, backgroundImage: null }; - }); + // 弹窗展示 6 张背景图 + const openBgModal = () => { + if (bgList.length === 0) {return;} + const picked = pickSix(bgList, lastModalBgIds); + setModalBgs(picked); + setLastModalBgIds(new Set(picked.map(b => b.id))); + setBgModalOpen(true); + }; + + // 弹窗换一组 + const shuffleModalBgs = () => { + const picked = pickSix(bgList, lastModalBgIds); + setModalBgs(picked); + setLastModalBgIds(new Set(picked.map(b => b.id))); }; // 初始化 Canvas @@ -205,6 +213,7 @@ export default function CoverDesign() { backgroundImage: coverConfig.backgroundImage ?? prev.backgroundImage, mainTitle: coverConfig.mainTitle ?? coverConfig.caption ?? prev.mainTitle, subtitle: coverConfig.subtitle ?? prev.subtitle, + avatarImage: coverConfig.avatarImage ?? prev.avatarImage, })); }, [coverConfig]); @@ -221,7 +230,6 @@ export default function CoverDesign() { if (!projectId) {return;} if (!config.mainTitle.trim()) {return;} - // 积分预检:封面设计固定消耗 2 积分 const COVER_POINTS = 2; await fetchBalance(); const currentBalance = usePointStore.getState().balance; @@ -237,7 +245,6 @@ export default function CoverDesign() { try { useProgressStore.getState().update('正在设计封面...'); - // 0. 删除旧封面文件(如果存在) const oldCoverPath = useProjectStore.getState().coverPath; if (oldCoverPath) { try { @@ -246,18 +253,16 @@ export default function CoverDesign() { filePath: oldCoverPath, }); } catch { - // no-op: 删除旧封面失败不影响主流程 + // no-op } } - // 1. 导出 PNG (base64) — 纯前端操作,先确认成功再扣费 const dataUrl = exportPng(); if (!dataUrl) { throw new Error('封面设计失败'); } const base64 = dataUrl.split(',')[1]; - // 2. 保存到本地(先保存,成功后再扣费,避免保存失败导致积分损失) const result = await invoke<{ code: number; data?: string; @@ -272,7 +277,6 @@ export default function CoverDesign() { throw new Error(result.message); } - // 3. 扣费:封面设计 const coverDesignPoints = usePointStore.getState().getRule('cover_design')?.points || 2; await pointsApi.consumePoints({ points: coverDesignPoints, @@ -282,13 +286,13 @@ export default function CoverDesign() { }); await usePointStore.getState().fetchBalance(); - // 4. 更新状态 setCoverPath(result.data); setCoverConfig({ template: config.template, backgroundImage: config.backgroundImage, mainTitle: config.mainTitle.trim(), subtitle: config.subtitle.trim(), + avatarImage: config.avatarImage, }); useProjectStore.getState().clearStepDirty(5); @@ -307,6 +311,13 @@ export default function CoverDesign() { } }; + // 封面形象库 + const { coverAvatars, loadCoverAvatars } = useCoverAvatarStore(); + useEffect(() => { + loadCoverAvatars(); + }, [loadCoverAvatars]); + + const currentAvatar = coverAvatars.find(a => a.imageUrl === config.avatarImage); const canGenerate = !!config.mainTitle.trim(); return ( @@ -339,6 +350,113 @@ export default function CoverDesign() { setShowPointsModal(false); }} /> + + {/* 背景图弹窗 */} + setBgModalOpen(false)} + title="选择背景图片" + width="560px" + > +
+ {modalBgs.length > 0 ? ( +
+ {modalBgs.map((bg) => ( + + ))} +
+ ) : ( +
+ 暂无背景图 +
+ )} +
+ + +
+
+
+ + {/* 封面形象弹窗 */} + setAvatarModalOpen(false)} + title="选择封面形象" + width="480px" + > +
+
+ + {coverAvatars.map((avatar) => ( + + ))} +
+ {coverAvatars.length === 0 && ( +
+ 暂无封面形象,请在「内容管理 → 封面形象」中上传 +
+ )} +
+ +
+
+
+ {/* 左侧:配置区域 */}
- {/* 背景图片 */} + {/* 视觉素材:背景图 + 封面形象 同一行 */}
-
- + +
+ {/* 背景图卡片 */} + + {/* 封面形象卡片 */} +
-
- {displayedBgs.length === 0 ? ( -
- {bgLoadStatus === 'no_category' && '请先选择脚本分类'} - {bgLoadStatus === 'loading' && '背景图加载中...'} - {bgLoadStatus === 'empty' && '暂无背景图,请联系管理员添加'} - {bgLoadStatus === 'error' && '背景图加载失败,请检查网络后刷新页面重试'} - {bgLoadStatus === 'idle' && '背景图加载失败,请检查网络后刷新页面重试'} -
- ) : ( - displayedBgs.map((bg) => ( - - )) - )} -
-
- {/* 主标题 */} + {/* 文案配置 */}
- +
-
- - {/* 副标题 */} -
- -
+