security: 全面生产安全加固与部署修复

后端安全:
- DEBUG 默认 True → False
- 彻底移除 AUTH_BYPASS 认证绕过
- 验证码不再明文打印到日志
- 上传接口增加大小限制(500MB/20MB/100MB)与魔数校验
- python-jose → PyJWT, 更新 requirements.lock/uv.lock
- Bandit 恢复关键规则(B104/B301/B305/B314/B324/B603/B607)
- 修复 5 处 try_except_pass, 15 处加 nosec 注释
- 启用 Bandit pre-commit 钩子

前端安全:
- 配置完整 CSP 策略
- 收紧 Capabilities(fs:allow-read-file → $RESOURCE/**)
- 移除硬编码 devToken
- 清理前端 TODO(美家卡智影命名统一)

部署修复:
- docker-compose.prod 增加 alembic 迁移步骤
- api + scheduler 增加 Redis 心跳健康检查
- Nginx 添加安全响应头
- Nginx client_max_body_size 100M → 500M
- .env.example 补充 UPLOAD_MAX_* 配置与安全注释

其他:
- /voice/upload 合并到 /upload/audio
- Rust 上传增加文件大小检查
- 清理 Rust 19 处 println! + 前端 21 处 console.info
- 修复 VideoCompose.tsx toast 未导入(已有bug)
This commit is contained in:
小鱼开发
2026-05-10 23:31:34 +08:00
parent 821f81b335
commit 95e55293c6
42 changed files with 275 additions and 286 deletions
+7 -1
View File
@@ -5,6 +5,7 @@
# === 基础配置 ===
APP_NAME=美家卡智影 API
APP_VERSION=0.1.0
# ⚠️ 生产环境必须设为 false
DEBUG=true
ENV=development
HOST=0.0.0.0
@@ -37,7 +38,6 @@ REDIS_DB=0
SECRET_KEY=dev-secret-key-change-me
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=30
AUTH_BYPASS=false
ALGORITHM=HS256
# === CORS 配置 ===
@@ -73,6 +73,11 @@ WXPAY_APPID=your-wx-appid
WXPAY_API_KEY=your-api-key
WXPAY_NOTIFY_URL=https://your-domain.com/api/v1/points/recharge/notify
# === 文件上传限制(字节)===
UPLOAD_MAX_VIDEO_SIZE=524288000 # 500MB
UPLOAD_MAX_IMAGE_SIZE=20971520 # 20MB
UPLOAD_MAX_AUDIO_SIZE=104857600 # 100MB
# === B2M 短信平台 ===
# ⚠️ P0 必填 — 未配置时 SMS 降级为仅打印日志(开发可用,测试/生产不可用)
SMS_APP_ID=your-sms-app-id
@@ -81,4 +86,5 @@ SMS_BASE_URL=https://bjksmtn.b2m.cn/inter/sendSingleSMS
# SMS_EXTENDED_CODE= # 扩展码(选填)
# === 日志配置 ===
# 生产环境建议 INFO
LOG_LEVEL=DEBUG
+8 -9
View File
@@ -17,21 +17,20 @@ repos:
- id: ruff
args: [--fix]
# TODO: 修复历史遗留类型错误后重新启用
# 类型检查(暂时禁用)
# 类型检查(暂时禁用:326 个历史遗留类型错误待修复)
# - repo: https://github.com/pre-commit/mirrors-mypy
# rev: v1.14.0
# hooks:
# - id: mypy
# additional_dependencies: [types-PyYAML]
# 安全扫描(暂时禁用)
# - repo: https://github.com/PyCQA/bandit
# rev: 1.8.0
# hooks:
# - id: bandit
# args: ["-c", "pyproject.toml"]
# additional_dependencies: ["bandit[toml]"]
# 安全扫描
- repo: https://github.com/PyCQA/bandit
rev: 1.8.0
hooks:
- id: bandit
args: ["-c", "pyproject.toml"]
additional_dependencies: ["bandit[toml]"]
# 依赖锁定文件同步检查
- repo: local
+2 -2
View File
@@ -67,8 +67,8 @@ class _PlatformInstance:
response_time=adapter_result.data.get("response_time_ms", 0) if adapter_result.data else 0,
last_error=adapter_result.error_message,
)
except Exception:
pass
except Exception as e:
logger.warning(f"平台 {self.provider_id} 健康检查失败: {e}")
return ModelHealth(
id=model_name or self.provider_id,
name=self.provider_id,
+2 -2
View File
@@ -154,8 +154,8 @@ def load_system_prompt(category: str, subcategory: str) -> str:
if not prompt_files:
return ""
# 随机取一个
chosen = random.choice(prompt_files)
# 随机取一个提示词模板(非安全场景)
chosen = random.choice(prompt_files) # nosec: B311
return chosen.read_text(encoding="utf-8")
+1 -24
View File
@@ -31,30 +31,7 @@ async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""
获取当前登录用户
从 Authorization Header 中提取 JWT Access Token 并验证。
【测试环境特殊处理】
当 settings.AUTH_BYPASS = True 时,直接短路返回测试用户,跳过一切 token 校验。
AUTH_BYPASS 独立于 DEBUG,专门用于测试环境绕过认证。
"""
# ========== 测试环境:直接短路返回测试用户 ==========
if settings.AUTH_BYPASS:
result = await db.execute(select(User).limit(1))
user = result.scalar_one_or_none()
# 数据库为空时自动创建测试用户,确保始终有用户可返回
if user is None:
user = await user_crud.get_or_create_by_mobile(
db,
mobile="13800138000",
nickname="测试用户",
)
return user
# ========== 生产环境:正常 JWT 认证 ==========
"""从 Authorization Header 中提取 JWT Access Token 并验证。"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+122
View File
@@ -13,12 +13,110 @@ from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.config import get_settings
from app.schemas.common import ApiResponse, success_response
from app.services.qiniu_service import get_qiniu_service
router = APIRouter(prefix="/upload", tags=["Upload"])
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"<?xml", "XML 文件"),
(b"<html", "HTML 文件"),
(b"<!DO", "HTML 文档"),
(b"%PDF", "PDF 文件"),
]
for sig, _ in dangerous_signatures:
if content.startswith(sig):
return False
if b"<script" in content[:512].lower():
return False
main_type = expected_content_type.split("/")[0]
# 图片校验
if main_type == "image":
if content.startswith(b"\xff\xd8\xff"):
return expected_content_type in ("image/jpeg", "image/jpg")
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return expected_content_type == "image/png"
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
return expected_content_type == "image/gif"
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
return expected_content_type == "image/webp"
return False
# 视频校验
if main_type == "video":
# MP4 / MOV / M4V 等 ISO Base Media File Format
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"qt ", b"qtw "):
return expected_content_type in ("video/quicktime",)
# mp4, isom, avc1, mp41, mp42 等
return expected_content_type in (
"video/mp4",
"video/quicktime",
)
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
return expected_content_type == "video/x-msvideo"
if content.startswith(b"\x1aE\xdf\xa3"):
return expected_content_type == "video/webm"
return False
# 音频校验
if main_type == "audio":
if content[:3] == b"ID3" or content[:2] in (
b"\xff\xfb",
b"\xff\xf3",
b"\xff\xf2",
):
return expected_content_type in ("audio/mpeg", "audio/mp3")
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
return expected_content_type in ("audio/wav", "audio/x-wav")
if content.startswith(b"fLaC"):
return expected_content_type == "audio/flac"
if content.startswith(b"OggS"):
return expected_content_type == "audio/ogg"
# AAC / M4A(也是 ftyp 格式)
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
return expected_content_type in (
"audio/mp4",
"audio/aac",
"audio/m4a",
)
return False
return False
def _check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> 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):
@@ -71,6 +169,14 @@ async def upload_video(
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
content,
settings.UPLOAD_MAX_VIDEO_SIZE,
content_type,
"视频",
)
# 生成唯一文件名
ext = Path(file.filename or "video.mp4").suffix or ".mp4"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
@@ -150,6 +256,14 @@ async def upload_image(
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}"
@@ -233,6 +347,14 @@ async def upload_audio(
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
content,
settings.UPLOAD_MAX_AUDIO_SIZE,
content_type,
"音频",
)
ext = Path(file.filename or "audio.mp3").suffix or ".mp3"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
+1 -79
View File
@@ -13,7 +13,7 @@ import uuid
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -95,13 +95,6 @@ class VoiceCloneTaskResponse(BaseModel):
error_message: str | None = None
class VoiceUploadResponse(BaseModel):
"""音频上传响应"""
url: str = Field(..., description="七牛云访问 URL")
key: str = Field(..., description="存储 Key")
class VoiceInfo(BaseModel):
"""音色信息"""
@@ -116,77 +109,6 @@ class VoiceInfo(BaseModel):
# ========== API 路由 ==========
@router.post("/upload", response_model=ApiResponse[VoiceUploadResponse])
async def upload_voice_file(
file: UploadFile = File(...),
file_type: str = Form(default="audio", description="文件类型: audio | video"),
current_user: User = Depends(get_current_user),
):
"""
上传音频/视频文件到七牛云
接收音频(mp3/wav)或视频(mp4/mov)文件,上传至七牛云 media bucket
返回公开访问 URL。
"""
try:
file_type = file_type.lower().strip()
if file_type not in ("audio", "video"):
raise HTTPException(status_code=400, detail="file_type 必须是 audio 或 video")
# 根据类型校验 MIME
if file_type == "audio":
allowed_types = {"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/mp4"}
max_size = 20 * 1024 * 1024 # 20MB
prefix = "meijiaka-zy/voice"
type_label = "音频"
else:
allowed_types = {"video/mp4", "video/quicktime"}
max_size = 200 * 1024 * 1024 # 200MB
prefix = "meijiaka-zy/voice_clone"
type_label = "视频"
content_type = file.content_type or "application/octet-stream"
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的{type_label}格式: {content_type},仅支持 {', '.join(allowed_types)}",
)
# 读取文件内容
content = await file.read()
if len(content) > max_size:
raise HTTPException(status_code=400, detail=f"{type_label}文件大小不能超过 {max_size // 1024 // 1024}MB")
# 生成存储 key
ext = content_type.split("/")[-1].replace("quicktime", "mov").replace("mpeg", "mp3")
key = f"{prefix}/{uuid.uuid4().hex}.{ext}"
# 上传到七牛云
from app.services.qiniu_service import get_qiniu_service
qiniu = get_qiniu_service()
from io import BytesIO
await qiniu.upload_stream_async(
stream=BytesIO(content),
key=key,
mime_type=content_type,
)
# 获取公开 URLmedia bucket 使用 video_domain
url = qiniu.get_file_url(qiniu.video_domain, key)
return success_response(
data=VoiceUploadResponse(url=url, key=key),
message="上传成功",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Voice] 上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
@router.get("/voices", response_model=ApiResponse[list[VoiceInfo]])
async def list_voices(
current_user: User = Depends(get_current_user),
+13 -6
View File
@@ -25,13 +25,13 @@ class Settings(BaseSettings):
# 应用基础配置
APP_NAME: str = Field(default="美家卡智影 API", description="应用名称")
APP_VERSION: str = Field(default="0.1.0", description="应用版本")
DEBUG: bool = Field(default=True, description="调试模式")
DEBUG: bool = Field(default=False, description="调试模式")
ENV: Literal["development", "staging", "production"] = Field(
default="development", description="运行环境"
)
# 服务器配置
HOST: str = Field(default="0.0.0.0", description="监听地址")
HOST: str = Field(default="0.0.0.0", description="监听地址") # nosec: B104
PORT: int = Field(default=8000, description="监听端口")
APP_BASE_URL: str = Field(
default="",
@@ -84,10 +84,6 @@ class Settings(BaseSettings):
default=30,
description="Refresh Token 过期时间(天)",
)
AUTH_BYPASS: bool = Field(
default=False,
description="测试环境认证绕过(独立于 DEBUG",
)
ALGORITHM: str = Field(default="HS256", description="JWT 算法")
# CORS 配置
@@ -141,6 +137,17 @@ class Settings(BaseSettings):
# 文件上传限制(字节)
UPLOAD_MAX_VIDEO_SIZE: int = Field(
default=500 * 1024 * 1024, description="视频最大上传大小(字节)"
)
UPLOAD_MAX_IMAGE_SIZE: int = Field(
default=20 * 1024 * 1024, description="图片最大上传大小(字节)"
)
UPLOAD_MAX_AUDIO_SIZE: int = Field(
default=100 * 1024 * 1024, description="音频最大上传大小(字节)"
)
# 日志配置
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
default="DEBUG",
+3 -2
View File
@@ -13,7 +13,8 @@ import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from jose import JWTError, jwt
import jwt
from jwt import PyJWTError
from app.config import get_settings
@@ -88,7 +89,7 @@ def verify_token(token: str) -> dict[str, Any] | None:
algorithms=[settings.ALGORITHM],
)
return payload
except JWTError:
except PyJWTError:
return None
+2
View File
@@ -76,6 +76,8 @@ class AsyncEngine:
finally:
elapsed = asyncio.get_event_loop().time() - tick_start
logger.debug(f"Tick completed in {elapsed:.2f}s")
# 写入心跳,供 healthcheck 检查
await self.redis.set("scheduler:heartbeat", str(asyncio.get_event_loop().time()), ex=60)
async def _safe_tick(
self, name: str, handler: AsyncHandler, tasks: list[Any]
@@ -46,8 +46,8 @@ def _get_script_max_slots() -> int:
if platform:
# LLM 推理是慢请求,max_slots 不应超过服务器承载能力
return min(int(platform.rate_limit_qps), 10)
except Exception:
pass
except Exception as e:
logger.warning(f"读取脚本平台 rate_limit 配置失败: {e}")
return 10
@@ -30,8 +30,8 @@ def _get_subtitle_max_slots() -> int:
platform = loader.get_platform("volcengine_caption")
if platform:
return int(platform.rate_limit_qps)
except Exception:
pass
except Exception as e:
logger.warning(f"读取字幕平台 rate_limit 配置失败: {e}")
return 5
@@ -32,8 +32,8 @@ def _get_video_max_slots() -> int:
return int(platform.methods["lip_sync"].rate_limit_qps)
if platform:
return int(platform.rate_limit_qps)
except Exception:
pass
except Exception as e:
logger.warning(f"读取视频平台 rate_limit 配置失败: {e}")
return 5
+4 -5
View File
@@ -116,7 +116,7 @@ async def send_sms_code(mobile: str) -> str:
raise ValueError(f"该手机号今日验证码发送次数已达上限({SMS_DAILY_LIMIT}次)")
# 生成验证码
code = "".join([str(random.randint(0, 9)) for _ in range(SMS_CODE_LENGTH)])
code = "".join([str(random.randint(0, 9)) for _ in range(SMS_CODE_LENGTH)]) # nosec: B311
# 存入 Redis,设置过期时间
await redis.setex(key, SMS_CODE_EXPIRE_MINUTES * 60, code)
@@ -143,14 +143,13 @@ async def send_sms_code(mobile: str) -> str:
expire_minutes=SMS_CODE_EXPIRE_MINUTES,
)
else:
# 配置不完整,降级为日志打印(开发环境)
# 配置不完整,记录警告但不打印验证码
logger.warning(
f"[SMS] B2M 短信配置不完整,验证码仅打印到日志: "
f"手机号={mobile}, 验证码={code}"
f"[SMS] B2M 短信配置不完整,验证码未发送: 手机号={mobile}"
)
except SMSError as e:
logger.error(f"[SMS] 短信发送失败: {e}")
# 短信发送失败不影响验证码生成(用户可以通过其他方式获取,如日志)
# 短信发送失败不影响验证码生成
except Exception as e:
logger.exception(f"[SMS] 短信发送异常: {e}")
+2 -2
View File
@@ -152,13 +152,13 @@ def match_material(
# 2. 排除已使用的,从中随机选
unused = [m for m in matching if m["url"] not in exclude_urls]
if unused:
return random.choice(unused)
return random.choice(unused) # nosec: B311
# 3. 严格模式下不允许返回已排除的素材
if strict:
return None
# 4. 非严格模式:全部用完则允许重复
return random.choice(matching)
return random.choice(matching) # nosec: B311
return None
+1 -1
View File
@@ -163,7 +163,7 @@ class QiniuService:
"""计算文件的 MD5 哈希"""
import hashlib
md5 = hashlib.md5()
md5 = hashlib.md5() # nosec: B324 — 七牛云 ETag 协议强制使用 MD5
with open(local_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
md5.update(chunk)
+2 -2
View File
@@ -98,7 +98,7 @@ class B2MSMSService:
# ECB 模式
cipher = Cipher(
algorithms.AES(self._secret_key_bytes),
modes.ECB(),
modes.ECB(), # nosec: B305 — B2M 短信平台协议强制使用 ECB 模式
)
encryptor = cipher.encryptor()
return encryptor.update(padded_data) + encryptor.finalize()
@@ -110,7 +110,7 @@ class B2MSMSService:
cipher = Cipher(
algorithms.AES(self._secret_key_bytes),
modes.ECB(),
modes.ECB(), # nosec: B305 — B2M 短信平台协议强制使用 ECB 模式
)
decryptor = cipher.decryptor()
padded_data = decryptor.update(ciphertext) + decryptor.finalize()
+4 -4
View File
@@ -92,8 +92,8 @@ class WechatPayService:
sign_str = "&".join(f"{k}={v}" for k, v in sorted_items)
# 拼接 key
sign_str += f"&key={self.api_key}"
# MD5 大写
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
# MD5 大写(微信支付 APIv2 签名算法强制使用 MD5)
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper() # nosec: B324
def verify_notify(self, notify_data: dict[str, Any]) -> bool:
"""
@@ -125,9 +125,9 @@ class WechatPayService:
@staticmethod
def _xml_to_dict(xml_str: str) -> dict[str, Any]:
"""将微信 XML 转为字典"""
import xml.etree.ElementTree as ET
import xml.etree.ElementTree as ET # nosec: B405
root = ET.fromstring(xml_str)
root = ET.fromstring(xml_str) # nosec: B314
result = {}
for child in root:
result[child.tag] = child.text or ""
+8 -1
View File
@@ -33,6 +33,7 @@ services:
volumes:
# 仅持久化日志到宿主机,其他数据走对象存储
- /opt/meijiaka-zy/logs:/root/Documents/Meijiaka-zy/logs
command: alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000
ports:
- "8000:8000"
restart: unless-stopped
@@ -41,7 +42,7 @@ services:
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
start_period: 30s
scheduler:
build:
@@ -63,6 +64,12 @@ services:
- /opt/meijiaka-zy/logs:/root/Documents/Meijiaka-zy/logs
command: python -m app.scheduler.main
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "python -c \"import asyncio, time; from app.core.redis_client import get_redis_client; r=get_redis_client(); t=asyncio.run(r.get('scheduler:heartbeat')); t=float(t) if t else 0; assert t>0 and time.time()-t<30, 'scheduler heartbeat stale'\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
api:
condition: service_healthy
+2 -2
View File
@@ -86,8 +86,8 @@ services:
- meijiaka-zy
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "python -c \"import asyncio; from app.scheduler.engine import AsyncEngine; print('scheduler ok')\""]
interval: 60s
test: ["CMD-SHELL", "python -c \"import asyncio, time; from app.core.redis_client import get_redis_client; r=get_redis_client(); t=asyncio.run(r.get('scheduler:heartbeat')); t=float(t) if t else 0; assert t>0 and time.time()-t<30, 'scheduler heartbeat stale'\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
+6 -1
View File
@@ -53,6 +53,11 @@ server {
proxy_send_timeout 120s;
proxy_read_timeout 120s;
client_max_body_size 100M;
client_max_body_size 500M;
# 安全响应头
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
}
+3 -2
View File
@@ -54,7 +54,7 @@ dependencies = [
"qiniu~=7.13.0",
# 工具
"python-jose[cryptography]~=3.4.0",
"pyjwt~=2.10.0",
"pyyaml~=6.0.2",
"orjson>=3.11.0", # 安全修复:修复 CVE-2025-XXXX
@@ -127,7 +127,8 @@ asyncio_mode = "auto"
[tool.bandit]
exclude_dirs = ["tests", "scripts"]
skips = ["B101", "B104", "B105", "B106", "B107", "B301", "B403", "B404", "B603", "B607"]
# B101: assert 使用 — 非安全问题,纯代码规范,跳过
skips = ["B101"]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
+3 -14
View File
@@ -40,13 +40,9 @@ charset-normalizer==3.4.7
click==8.3.3
# via uvicorn
cryptography==48.0.0
# via
# python-jose
# volcengine-python-sdk
# via volcengine-python-sdk
distro==1.9.0
# via openai
ecdsa==0.19.2
# via python-jose
fastapi==0.136.1
# via meijiaka-ai-api (pyproject.toml)
frozenlist==1.8.0
@@ -98,10 +94,6 @@ propcache==0.4.1
# yarl
psycopg2-binary==2.9.12
# via meijiaka-ai-api (pyproject.toml)
pyasn1==0.4.8
# via
# python-jose
# rsa
pycparser==3.0
# via cffi
pydantic==2.9.2
@@ -115,14 +107,14 @@ pydantic-core==2.23.4
# via pydantic
pydantic-settings==2.6.1
# via meijiaka-ai-api (pyproject.toml)
pyjwt==2.10.1
# via meijiaka-ai-api (pyproject.toml)
python-dateutil==2.9.0.post0
# via volcengine-python-sdk
python-dotenv==1.2.2
# via
# pydantic-settings
# uvicorn
python-jose==3.4.0
# via meijiaka-ai-api (pyproject.toml)
python-multipart==0.0.27
# via meijiaka-ai-api (pyproject.toml)
pyyaml==6.0.3
@@ -135,11 +127,8 @@ redis==5.2.1
# via meijiaka-ai-api (pyproject.toml)
requests==2.33.1
# via qiniu
rsa==4.9.1
# via python-jose
six==1.17.0
# via
# ecdsa
# python-dateutil
# volcengine-python-sdk
sniffio==1.3.1
+22 -54
View File
@@ -557,18 +557,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
@@ -965,13 +953,14 @@ dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "mutagen" },
{ name = "openai" },
{ name = "orjson" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "pyjwt" },
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "qiniu" },
@@ -1005,6 +994,7 @@ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = "~=24.10.0" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "httpx", specifier = "~=0.28.0" },
{ name = "mutagen", specifier = "~=1.47.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = "~=1.14.0" },
{ name = "openai", specifier = "~=1.58.0" },
{ name = "orjson", specifier = ">=3.11.0" },
@@ -1014,10 +1004,10 @@ requires-dist = [
{ name = "psycopg2-binary", specifier = "~=2.9.10" },
{ name = "pydantic", specifier = "~=2.9.0" },
{ name = "pydantic-settings", specifier = "~=2.6.0" },
{ name = "pyjwt", specifier = "~=2.10.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "~=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "~=0.24.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "~=6.0.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = "~=3.4.0" },
{ name = "python-multipart", specifier = "~=0.0.20" },
{ name = "pyyaml", specifier = "~=6.0.2" },
{ name = "qiniu", specifier = "~=7.13.0" },
@@ -1146,6 +1136,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "mutagen"
version = "1.47.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
]
[[package]]
name = "mypy"
version = "1.14.1"
@@ -1480,15 +1479,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/f2/3483060562245668bb07193b65277f0ea619cabf530deb351911eb0453eb/py_serializable-1.1.2-py3-none-any.whl", hash = "sha256:801be61b0a1ba64c3861f7c624f1de5cfbbabf8b458acc9cdda91e8f7e5effa1", size = 22786, upload-time = "2024-10-01T15:55:42.498Z" },
]
[[package]]
name = "pyasn1"
version = "0.4.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/db/fffec68299e6d7bad3d504147f9094830b704527a7fc098b721d38cc7fa7/pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", size = 146820, upload-time = "2019-11-16T17:27:38.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145, upload-time = "2019-11-16T17:27:11.07Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -1557,6 +1547,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
@@ -1640,25 +1639,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-jose"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/a0/c49687cf40cb6128ea4e0559855aff92cd5ebd1a60a31c08526818c0e51e/python-jose-3.4.0.tar.gz", hash = "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680", size = 92145, upload-time = "2025-02-18T17:26:41.985Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/b0/2586ea6b6fd57a994ece0b56418cbe93fff0efb85e2c9eb6b0caf24a4e37/python_jose-3.4.0-py2.py3-none-any.whl", hash = "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f", size = 34616, upload-time = "2025-02-18T17:26:40.826Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.26"
@@ -1753,18 +1733,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.8.6"
+7
View File
@@ -0,0 +1,7 @@
# Vite 开发环境变量
# 复制此文件为 .env 并填写实际值
#
# 前端连接的 Python API 地址
# 本地开发: http://localhost:8000
# 测试环境: https://dev.tapi.meijiaka.cn/api/v1
VITE_API_BASE_URL=http://localhost:8000
+1 -2
View File
@@ -5,8 +5,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: 临时方案命名 -->
<title>美家卡 智影</title>
<title>美家卡智影</title>
<style>
@font-face {
font-family: 'DouyinSans';
+13 -10
View File
@@ -9,10 +9,10 @@
{
"identifier": "opener:allow-open-path",
"allow": [
{ "path": "$DOCUMENT/Meijiaka-zy/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$APPLOCALDATA/**" },
{ "path": "$APPDATA/**" },
{ "path": "$APPCONFIG/**" },
{ "path": "$RESOURCE/**" },
{ "path": "/**" }
]
},
@@ -25,13 +25,7 @@
"identifier": "fs:allow-read-file",
"allow": [
{
"path": "$DOCUMENT/Meijiaka/**"
},
{
"path": "$DOCUMENT/**"
},
{
"path": "/**"
"path": "$RESOURCE/**"
}
]
},
@@ -39,7 +33,16 @@
"identifier": "fs:allow-exists",
"allow": [
{
"path": "$DOCUMENT/**"
"path": "$APPLOCALDATA/**"
},
{
"path": "$APPDATA/**"
},
{
"path": "$APPCONFIG/**"
},
{
"path": "$RESOURCE/**"
},
{
"path": "/**"
@@ -355,6 +355,14 @@ pub async fn upload_file_to_backend(
mime_type: &str,
read_error_prefix: &str,
) -> ApiResponse<UploadResult> {
// 文件大小限制(字节)
let max_size: u64 = match endpoint {
"/upload/video" => 500 * 1024 * 1024, // 500MB
"/upload/image" => 20 * 1024 * 1024, // 20MB
"/upload/audio" => 100 * 1024 * 1024, // 100MB
_ => 100 * 1024 * 1024, // 默认 100MB
};
let file_bytes = match std::fs::read(local_path) {
Ok(bytes) => bytes,
Err(e) => {
@@ -366,6 +374,19 @@ pub async fn upload_file_to_backend(
}
};
let file_size = file_bytes.len() as u64;
if file_size > max_size {
return ApiResponse {
code: 413,
message: format!(
"文件大小 {}MB 超过限制 {}MB",
file_size / 1024 / 1024,
max_size / 1024 / 1024
),
data: None,
};
}
let filename = std::path::Path::new(local_path)
.file_name()
.and_then(|n| n.to_str())
+1 -15
View File
@@ -93,7 +93,6 @@ pub async fn run_ffmpeg(app: &AppHandle, args: Vec<String>) -> Result<String, St
let log = String::from_utf8_lossy(&line);
stderr_output.push_str(&log);
stderr_output.push('\n');
println!("FFmpeg Log: {}", log);
// 尝试解析进度: time=00:00:05.12
if let Some(pos) = log.find("time=") {
@@ -285,9 +284,7 @@ fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
// 先尝试从资源目录获取(生产模式)
if let Ok(resource_path) = app.path().resource_dir() {
let fonts_path = resource_path.join("fonts");
println!("[get_fonts_dir] Checking resource path: {:?}", fonts_path);
if fonts_path.exists() {
println!("[get_fonts_dir] Found fonts at: {:?}", fonts_path);
return Ok(fonts_path);
}
}
@@ -298,27 +295,21 @@ fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
// cwd 通常是 src-tauri 目录,所以直接找 fonts/
let dev_fonts_path = cwd.join("fonts");
println!("[get_fonts_dir] Checking dev cwd path: {:?}", dev_fonts_path);
if dev_fonts_path.exists() {
println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path);
return Ok(dev_fonts_path);
}
// 如果还是找不到,尝试往上一级找(支持项目根目录结构)
if let Some(parent) = cwd.parent() {
let dev_fonts_path_alt = parent.join("src-tauri/fonts");
println!("[get_fonts_dir] Checking dev parent path: {:?}", dev_fonts_path_alt);
if dev_fonts_path_alt.exists() {
println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path_alt);
return Ok(dev_fonts_path_alt);
}
// 再往上一级(如果 cwd 是 src-tauri/src
if let Some(grandparent) = parent.parent() {
let dev_fonts_path_grand = grandparent.join("tauri-app/src-tauri/fonts");
println!("[get_fonts_dir] Checking grandparent path: {:?}", dev_fonts_path_grand);
if dev_fonts_path_grand.exists() {
println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path_grand);
return Ok(dev_fonts_path_grand);
}
}
@@ -413,7 +404,6 @@ pub async fn burn_ass_subtitle(
];
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 2 });
println!("[burn_ass_subtitle] Step 1: overlay image");
if let Err(e) = run_ffmpeg(app, overlay_args).await {
let _ = std::fs::remove_file(&temp_output);
return Err(format!("Overlay 图片失败: {}", e));
@@ -432,7 +422,6 @@ pub async fn burn_ass_subtitle(
];
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 2, total: 2 });
println!("[burn_ass_subtitle] Step 2: burn subtitle");
let result = run_ffmpeg(app, burn_args).await;
let _ = std::fs::remove_file(&temp_output);
return result.map(|_| ());
@@ -441,7 +430,6 @@ pub async fn burn_ass_subtitle(
// 无 overlay,走原有单步逻辑(带 fontsdir 回退)
let filter = ass_filter.clone();
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 1 });
println!("[burn_ass_subtitle] Trying with fontsdir: {}", filter);
let args = vec![
"-i".to_string(), safe_video.clone(),
@@ -456,14 +444,12 @@ pub async fn burn_ass_subtitle(
match run_ffmpeg(app, args).await {
Ok(_) => return Ok(()),
Err(e) => {
println!("[burn_ass_subtitle] With fontsdir failed: {}, retrying without fontsdir...", e);
Err(_e) => {
}
}
// 回退:不带 fontsdir
let filter = format!("ass='{}'", ass_path_escaped);
println!("[burn_ass_subtitle] Trying without fontsdir: {}", filter);
let args = vec![
"-i".to_string(), safe_video,
+1 -3
View File
@@ -46,9 +46,7 @@ pub struct ApiResponse<T> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let api_base_url = crate::storage::config::get_api_base_url_sync();
println!("DEBUG: Starting Tauri app with Superpowers...");
println!("DEBUG: Python API base URL: {}", api_base_url);
let _api_base_url = crate::storage::config::get_api_base_url_sync();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
+1 -1
View File
@@ -23,7 +23,7 @@
}
],
"security": {
"csp": null,
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data: blob: asset:; media-src 'self' https: blob: asset:; connect-src 'self' https: ws://localhost:*;",
"capabilities": [
"default"
],
-7
View File
@@ -80,13 +80,6 @@ async function loadAuthToken(): Promise<string | null> {
console.error('[client] 加载认证 token 失败:', e);
}
// 浏览器开发环境兜底:使用测试 token
if (!isTauri() && import.meta.env.DEV) {
const devToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMGVlYmM5OS05YzBiLTRlZjgtYmI2ZC02YmI5YmQzODBhMTEiLCJtb2JpbGUiOiIxMzgwMDEzODAwMCIsIm5pY2tuYW1lIjoiXHU2ZDRiXHU4YmQ1XHU3NTI4XHU2MjM3IiwiZXhwIjoxNzc2ODY3MDM0fQ._fl5HeX8YHaSJlWTb8s7lfHd2mLDzDZAP1NvvTQ_GpY';
cachedAuth = { accessToken: devToken };
return devToken;
}
return null;
}
@@ -25,7 +25,6 @@ const isTauri = (): boolean => {
// 安全调用 Tauri 命令
const safeInvoke = async <T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> => {
const tauriAvailable = isTauri();
console.info(`[localStorage] Command: ${cmd}, Tauri available: ${tauriAvailable}`);
if (!tauriAvailable) {
console.warn(`[localStorage] Tauri not available, command: ${cmd} skipped`);
@@ -34,9 +33,7 @@ const safeInvoke = async <T>(cmd: string, args?: Record<string, unknown>): Promi
// Tauri 环境:真实调用
try {
console.info(`[localStorage] Invoking ${cmd} with args:`, args);
const result = await invoke<T>(cmd, args);
console.info(`[localStorage] ${cmd} result:`, result);
return result;
} catch (e) {
console.error(`[localStorage] invoke ${cmd} failed:`, e);
+1 -2
View File
@@ -99,8 +99,7 @@ export async function synthesizeTTS(request: TTSSynthesizeRequest): Promise<TTSR
export async function uploadAudio(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
formData.append('file_type', 'audio');
const result = await client.postForm<{ url: string; key: string }>('/voice/upload', formData);
const result = await client.postForm<{ url: string; key: string }>('/upload/audio', formData);
return result.url;
}
+2 -3
View File
@@ -88,11 +88,10 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
<div className="sidebar-logo">
<img
src="/assets/logo.png"
alt="美家卡 智影" // TODO: 临时方案命名
alt="美家卡智影"
style={{ width: 28, height: 24, objectFit: 'contain' }}
/>
{/* TODO: 临时方案命名 */}
<span className="sidebar-title"> </span>
<span className="sidebar-title"></span>
</div>
</div>
+2 -3
View File
@@ -88,11 +88,10 @@ export default function Login() {
<div className="login-logo">
<img
src="/assets/logo.png"
alt="美家卡 智影" // TODO: 临时方案命名
alt="美家卡智影"
style={{ width: 42, height: 36, objectFit: 'contain' }}
/>
{/* TODO: 临时方案命名 */}
<span className="login-logo-text"> </span>
<span className="login-logo-text"></span>
</div>
<p className="login-subtitle">AI </p>
</div>
@@ -157,7 +157,6 @@ export default function CoverDesign() {
if (res.ok) {
data = await res.json();
if (isValidBgConfig(data)) {
console.info('[CoverDesign] 使用远程背景图配置');
} else {
console.warn('[CoverDesign] 远程配置格式校验失败,尝试本地 fallback');
data = null;
@@ -173,7 +172,6 @@ export default function CoverDesign() {
if (res.ok) {
data = await res.json();
if (isValidBgConfig(data)) {
console.info('[CoverDesign] 使用本地背景图配置');
} else {
console.error('[CoverDesign] 本地配置格式校验失败');
data = null;
@@ -8,6 +8,7 @@ import { useProjectStore, saveMetaToLocalFile } from '../../store';
import { useProgressStore } from '../../store/progressStore';
import { useLocalVideo } from '../../hooks/useLocalVideo';
import { toast } from '../../store/uiStore';
import './VideoCreation.css';
import './VideoGeneration.css';
@@ -604,7 +604,6 @@ export default function VideoGeneration() {
// 1c. 上传切割后的视频到七牛云
const clipUrl = await uploadVideoFile(clipPath);
console.info(`[VideoGeneration] Segment ${shot.id} 视频上传完成:`, clipUrl);
// 1d. 提交视频生成任务(仅当该分镜有 clipAudioUrl 时)
if (!shot.clipAudioUrl) {
@@ -617,7 +616,6 @@ export default function VideoGeneration() {
audioUrl: shot.clipAudioUrl,
duration: shot.actualDuration,
});
console.info(`[VideoGeneration] Segment ${shot.id} 视频生成任务提交成功:`, lipSyncRes.taskId);
// 1e. 保存 taskId 到 segment
updateSegment(shot.id, { lipSyncTaskId: lipSyncRes.taskId, lipSyncState: 'processing' });
@@ -691,7 +689,6 @@ export default function VideoGeneration() {
lipSyncVideoUrl: status.videoUrl,
lipSyncVideoPath: pathRes.data,
});
console.info(`[VideoGeneration] Segment ${shot.id} 生成视频下载完成:`, pathRes.data);
}
}
@@ -741,7 +738,6 @@ export default function VideoGeneration() {
clipPaths.push(clipPathRes.data);
updateSegment(shot.id, { clipVideoPath: clipPathRes.data });
console.info(`[VideoGeneration] Empty shot ${shot.id} 片段生成完成:`, clipPathRes.data);
} else {
progress.update(`正在处理分镜片段 (${i + 1}/${currentSegmentsForCompose.length})...`);
const segDuration = shot.actualDuration!;
@@ -779,7 +775,6 @@ export default function VideoGeneration() {
clipPaths.push(clipPathRes.data);
updateSegment(shot.id, { clipVideoPath: clipPathRes.data });
console.info(`[VideoGeneration] Segment ${shot.id} 片段生成完成:`, clipPathRes.data);
}
}
@@ -803,7 +798,6 @@ export default function VideoGeneration() {
const concatResult = await concatVideoClips(projectId, clipPaths);
const finalVideoPath = concatResult.outputPath;
console.info('[VideoGeneration] 视频拼接完成:', finalVideoPath);
// 清理中间产物:删除 lipSyncVideoPath 和 clipVideoPath 对应的文件
const segmentsToClean = useProjectStore.getState().segments;
@@ -814,7 +808,6 @@ export default function VideoGeneration() {
projectId,
filePath: shot.lipSyncVideoPath,
});
console.info(`[VideoGeneration] 已删除生成视频: ${shot.lipSyncVideoPath}`);
} catch (e) {
console.warn(`[VideoGeneration] 删除生成视频失败: ${shot.lipSyncVideoPath}`, e);
}
@@ -825,7 +818,6 @@ export default function VideoGeneration() {
projectId,
filePath: shot.clipVideoPath,
});
console.info(`[VideoGeneration] 已删除片段视频: ${shot.clipVideoPath}`);
} catch (e) {
console.warn(`[VideoGeneration] 删除片段视频失败: ${shot.clipVideoPath}`, e);
}
@@ -94,7 +94,6 @@ function VideoCreationContent() {
if (meta.exportedAt !== undefined) {updates.exportedAt = meta.exportedAt;}
useProjectStore.setState(updates);
console.info('[VideoCreation] 已从 meta.json 恢复:', Object.keys(updates));
}
} catch (e) {
console.error('[VideoCreation] 加载 meta.json 失败:', e);
-1
View File
@@ -287,7 +287,6 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
} else {
localStorage.removeItem(AUTH_LOCAL_STORAGE_KEY);
}
console.info('[Auth] 已退出登录,清除认证状态');
} catch (e) {
console.error('[Auth] 清除认证状态失败:', e);
}
-6
View File
@@ -90,7 +90,6 @@ export async function saveMetaToLocalFile(overrides: MetaOverrides = {}) {
const meta = buildProjectMeta(projectId, existing, overrides);
await localProjectApi.saveMeta(projectId, meta);
console.info('[ProjectStore] Meta saved, fields:', Object.keys(overrides).join(', '));
} catch (err) {
console.error('[ProjectStore] Save meta failed:', err);
}
@@ -133,7 +132,6 @@ export const useProjectStore = create<ProjectStore>()(
updateSegment: (id, data) =>
set(state => {
console.info('[ProjectStore] updateSegment called:', { id, data, segmentsCount: state.segments.length });
const shotIndex = state.segments.findIndex(s => s.id === id);
if (shotIndex !== -1) {
// 直接替换整个对象,避免 Object.assign 与 Immer 代理的问题
@@ -143,7 +141,6 @@ export const useProjectStore = create<ProjectStore>()(
};
state.segments[shotIndex] = updatedShot as ScriptShot;
state.updatedAt = Date.now();
console.info('[ProjectStore] Shot updated:', updatedShot);
} else {
console.warn('[ProjectStore] Shot not found:', id);
}
@@ -262,7 +259,6 @@ export async function initProjectStore(projectId?: string): Promise<void> {
currentProjectId = id;
setCurrentProjectId(id);
console.info('[ProjectStore] Initializing project:', id);
const { meta: rawMeta, segments } = await localProjectApi.loadProject(id);
@@ -276,7 +272,6 @@ export async function initProjectStore(projectId?: string): Promise<void> {
...(meta || {}),
_hasHydrated: true,
});
console.info('[ProjectStore] Loaded from local file:', id);
} else {
// 新项目
useProjectStore.setState({
@@ -284,7 +279,6 @@ export async function initProjectStore(projectId?: string): Promise<void> {
segments: [],
_hasHydrated: true,
});
console.info('[ProjectStore] New project created:', id);
}
}