style: 后端代码black格式化
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 138h4m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 138h4m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h4m39s
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 138h4m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 138h4m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h4m39s
This commit is contained in:
@@ -237,8 +237,6 @@ async def reset_password(
|
||||
return MessageResponse(message="密码重置成功")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
@@ -246,6 +244,7 @@ async def logout(
|
||||
):
|
||||
"""登出 - 将当前 token 加入黑名单"""
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
if credentials:
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
@@ -255,6 +254,7 @@ async def logout(
|
||||
logger.warning(f"Operation failed in apps/api/app/api/routes/auth.py: {e}", exc_info=True)
|
||||
return MessageResponse(message="已登出")
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def get_current_user_info(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -161,10 +161,7 @@ def list_plans(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"无效的状态值: {status_filter},"
|
||||
f"可选值: draft, editing, rendering, completed, failed"
|
||||
),
|
||||
detail=(f"无效的状态值: {status_filter}," f"可选值: draft, editing, rendering, completed, failed"),
|
||||
)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
@@ -261,10 +258,7 @@ def update_plan(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"无效的状态值: {body.status},"
|
||||
f"可选值: draft, editing, rendering, completed, failed"
|
||||
),
|
||||
detail=(f"无效的状态值: {body.status}," f"可选值: draft, editing, rendering, completed, failed"),
|
||||
)
|
||||
svc.transition_status(plan_id, target_status)
|
||||
except ValueError as exc:
|
||||
@@ -419,7 +413,7 @@ class TimelineSceneResponse(BaseModel):
|
||||
"""时间线场景"""
|
||||
|
||||
scene: str = Field(..., description="场景描述")
|
||||
time: str = Field(..., description="时间范围,如 \"0:00 - 0:05\"")
|
||||
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
|
||||
duration: float = Field(..., ge=0, description="时长(秒)")
|
||||
color: str = Field(..., description="展示颜色")
|
||||
clip_id: str = Field(default="", description="关联的片段 ID")
|
||||
|
||||
@@ -97,8 +97,7 @@ def create_job(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的任务类型: {request.job_type},"
|
||||
f"可选值: {[t.value for t in JobType]}",
|
||||
detail=f"不支持的任务类型: {request.job_type}," f"可选值: {[t.value for t in JobType]}",
|
||||
)
|
||||
|
||||
use_case = CreateJobUseCase(job_repo)
|
||||
|
||||
@@ -205,8 +205,6 @@ async def cancel_subscription(
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/payment-callback")
|
||||
async def payment_callback(
|
||||
user_id: str,
|
||||
@@ -234,14 +232,16 @@ async def payment_callback(
|
||||
|
||||
# 创建账单记录
|
||||
record_id = uuid.uuid4().hex
|
||||
record = repo.create({
|
||||
"id": record_id,
|
||||
"user_id": user_id,
|
||||
"plan_name": _get_plan_name(plan),
|
||||
"amount": amount,
|
||||
"billing_cycle": billing_cycle,
|
||||
"status": "pending",
|
||||
})
|
||||
record = repo.create(
|
||||
{
|
||||
"id": record_id,
|
||||
"user_id": user_id,
|
||||
"plan_name": _get_plan_name(plan),
|
||||
"amount": amount,
|
||||
"billing_cycle": billing_cycle,
|
||||
"status": "pending",
|
||||
}
|
||||
)
|
||||
|
||||
# 在事务中标记支付成功并更新订阅
|
||||
repo.mark_paid(record_id, payment_method, payment_id)
|
||||
@@ -258,6 +258,7 @@ async def payment_callback(
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@router.post("/toggle-auto-renew", response_model=SimpleResponse)
|
||||
async def toggle_auto_renew(
|
||||
request: ToggleAutoRenewRequest,
|
||||
|
||||
@@ -88,7 +88,8 @@ def synthesize(
|
||||
|
||||
# 提交 CosyVoice 合成任务
|
||||
workflow = TTSWorkflowService(
|
||||
repository=repository, cosyvoice_service=cosyvoice_service,
|
||||
repository=repository,
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
)
|
||||
job = workflow.start_synthesis(job.id)
|
||||
|
||||
@@ -98,12 +99,11 @@ def synthesize(
|
||||
if task_id:
|
||||
try:
|
||||
from worker_app.tasks import process_tts_synthesis
|
||||
|
||||
process_tts_synthesis.delay(job.id)
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
workflow.process_synthesis_failure(
|
||||
job.id, f"Celery 任务调度失败: {e}"
|
||||
)
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
job_id=job.id,
|
||||
@@ -124,9 +124,7 @@ def list_tts_jobs(
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListTTSJobsUseCase(repository)
|
||||
skip = (page - 1) * page_size
|
||||
items, total = use_case.execute(
|
||||
user_id, status=status_filter, skip=skip, limit=page_size
|
||||
)
|
||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size)
|
||||
return ListTTSJobResponse(
|
||||
items=[_to_response(j) for j in items],
|
||||
total=total,
|
||||
|
||||
@@ -59,14 +59,10 @@ def _to_response(profile) -> VoiceCloneProfileResponse:
|
||||
|
||||
|
||||
def _get_workflow_service(
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
|
||||
get_voice_clone_profile_repository
|
||||
),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> VoiceCloneWorkflowService:
|
||||
return VoiceCloneWorkflowService(
|
||||
repository=repository, cosyvoice_service=cosyvoice_service
|
||||
)
|
||||
return VoiceCloneWorkflowService(repository=repository, cosyvoice_service=cosyvoice_service)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -109,13 +105,9 @@ def create_voice_clone(
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
||||
try:
|
||||
workflow.process_clone_failure(
|
||||
profile.id, f"Celery 任务调度失败: {e}"
|
||||
)
|
||||
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(
|
||||
f"Failed to mark profile as failed after dispatch error: {inner_e}"
|
||||
)
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
return _to_response(profile)
|
||||
|
||||
@@ -126,16 +118,12 @@ def list_voice_clones(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
|
||||
get_voice_clone_profile_repository
|
||||
),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
) -> ListVoiceCloneResponse:
|
||||
"""获取用户的音色克隆列表。"""
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListVoiceClonesUseCase(repository)
|
||||
items, total = use_case.execute(
|
||||
user_id, status=status_filter, skip=skip, limit=limit
|
||||
)
|
||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||
return ListVoiceCloneResponse(
|
||||
items=[_to_response(p) for p in items],
|
||||
total=total,
|
||||
@@ -146,9 +134,7 @@ def list_voice_clones(
|
||||
def get_voice_clone(
|
||||
clone_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
|
||||
get_voice_clone_profile_repository
|
||||
),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
) -> VoiceCloneProfileResponse:
|
||||
"""获取音色克隆详情。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -156,9 +142,7 @@ def get_voice_clone(
|
||||
try:
|
||||
profile = use_case.execute(clone_id, user_id)
|
||||
except VoiceCloneNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
@@ -166,9 +150,7 @@ def get_voice_clone(
|
||||
def get_voice_clone_status(
|
||||
clone_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
|
||||
get_voice_clone_profile_repository
|
||||
),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
) -> VoiceCloneStatusResponse:
|
||||
"""查询音色克隆状态(用于前端轮询)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -176,9 +158,7 @@ def get_voice_clone_status(
|
||||
try:
|
||||
profile = use_case.execute(clone_id, user_id)
|
||||
except VoiceCloneNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
return VoiceCloneStatusResponse(
|
||||
id=profile.id,
|
||||
status=profile.status,
|
||||
@@ -195,18 +175,14 @@ def get_voice_clone_status(
|
||||
def delete_voice_clone(
|
||||
clone_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
|
||||
get_voice_clone_profile_repository
|
||||
),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
) -> Response:
|
||||
"""删除音色克隆档案。"""
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = DeleteVoiceCloneUseCase(repository)
|
||||
deleted = use_case.execute(clone_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@@ -224,9 +200,7 @@ def retry_voice_clone(
|
||||
try:
|
||||
profile = workflow.retry_clone(clone_id, user_id)
|
||||
except VoiceCloneNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
except VoiceCloneNotRetryableError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -245,12 +219,8 @@ def retry_voice_clone(
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
||||
try:
|
||||
workflow.process_clone_failure(
|
||||
profile.id, f"Celery 任务调度失败: {e}"
|
||||
)
|
||||
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(
|
||||
f"Failed to mark profile as failed after dispatch error: {inner_e}"
|
||||
)
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
return _to_response(profile)
|
||||
|
||||
@@ -27,6 +27,7 @@ class AuthenticatedUser:
|
||||
def _get_redis_client():
|
||||
"""获取 Redis 客户端用于 JWT 黑名单"""
|
||||
import redis as redis_lib
|
||||
|
||||
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ def _token_fingerprint(token: str) -> str:
|
||||
def blacklist_token(token: str, exp: int) -> None:
|
||||
"""将 token 加入黑名单,TTL 为 token 剩余有效期"""
|
||||
import time
|
||||
|
||||
redis_client = _get_redis_client()
|
||||
key = f"jwt:blacklist:{_token_fingerprint(token)}"
|
||||
ttl = max(exp - int(time.time()), 1)
|
||||
|
||||
@@ -17,4 +17,3 @@ engine, SessionLocal = build_session_factory(
|
||||
assert_auto_create_schema_allowed(settings.ENVIRONMENT, settings.AUTO_CREATE_SCHEMA)
|
||||
if settings.AUTO_CREATE_SCHEMA:
|
||||
initialize_database(engine)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
API 版本管理中间件
|
||||
"""
|
||||
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ class TTSSynthesizeRequest(BaseModel):
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
format: str = Field("mp3", description="输出格式(mp3/wav/pcm)")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(
|
||||
default=None, alias="metadata", description="额外元数据"
|
||||
)
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
@@ -47,9 +45,7 @@ class TTSJobResponse(BaseModel):
|
||||
error_message: str = ""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
metadata_: Optional[Dict[str, Any]] = Field(
|
||||
default=None, alias="metadata", description="额外元数据"
|
||||
)
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -18,9 +18,7 @@ class CreateVoiceCloneRequest(BaseModel):
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
gender: str = Field("unknown", description="性别")
|
||||
max_retries: int = Field(3, ge=1, le=10, description="最大重试次数")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(
|
||||
default=None, alias="metadata", description="额外元数据"
|
||||
)
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
@@ -42,9 +40,7 @@ class VoiceCloneProfileResponse(BaseModel):
|
||||
error_message: str = ""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
metadata_: Optional[Dict[str, Any]] = Field(
|
||||
default=None, alias="metadata", description="额外元数据"
|
||||
)
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -319,7 +319,9 @@ class EditPlanService:
|
||||
text_content=text_content.strip() if text_content is not None else existing.text_content,
|
||||
start_time=start_time if start_time is not None else existing.start_time,
|
||||
duration=duration if duration is not None else existing.duration,
|
||||
transition_effect=transition_effect.strip() if transition_effect is not None else existing.transition_effect,
|
||||
transition_effect=(
|
||||
transition_effect.strip() if transition_effect is not None else existing.transition_effect
|
||||
),
|
||||
status=existing.status,
|
||||
config=config if config is not None else existing.config,
|
||||
created_at=existing.created_at,
|
||||
|
||||
@@ -156,11 +156,7 @@ class EditTemplateService:
|
||||
if name is not None and new_name != existing.name:
|
||||
all_templates = self._template_repo.list_all(skip=0, limit=1000)
|
||||
for t in all_templates:
|
||||
if (
|
||||
t.id != template_id
|
||||
and t.name == new_name
|
||||
and t.status == EditTemplateStatus.ACTIVE
|
||||
):
|
||||
if t.id != template_id and t.name == new_name and t.status == EditTemplateStatus.ACTIVE:
|
||||
raise ValueError(f"模板名称已存在: {new_name}")
|
||||
|
||||
# 构建更新后的实体
|
||||
@@ -288,9 +284,7 @@ class EditTemplateService:
|
||||
# 解析枚举类型
|
||||
new_clip_type = ClipType(clip_type) if clip_type is not None else existing.clip_type
|
||||
new_transition = (
|
||||
TransitionEffect(transition_effect)
|
||||
if transition_effect is not None
|
||||
else existing.transition_effect
|
||||
TransitionEffect(transition_effect) if transition_effect is not None else existing.transition_effect
|
||||
)
|
||||
|
||||
updated = TemplateClipConfig(
|
||||
@@ -301,7 +295,9 @@ class EditTemplateService:
|
||||
min_duration=min_duration if min_duration is not None else existing.min_duration,
|
||||
max_duration=max_duration if max_duration is not None else existing.max_duration,
|
||||
text_template=text_template.strip() if text_template is not None else existing.text_template,
|
||||
material_requirements=material_requirements if material_requirements is not None else existing.material_requirements,
|
||||
material_requirements=(
|
||||
material_requirements if material_requirements is not None else existing.material_requirements
|
||||
),
|
||||
transition_effect=new_transition,
|
||||
config=config if config is not None else existing.config,
|
||||
created_at=existing.created_at,
|
||||
|
||||
@@ -154,9 +154,7 @@ class VideoComposeService:
|
||||
|
||||
# 状态检查
|
||||
if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING):
|
||||
errors.append(
|
||||
f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}"
|
||||
)
|
||||
errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}")
|
||||
|
||||
# 加载片段
|
||||
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||||
@@ -183,9 +181,7 @@ class VideoComposeService:
|
||||
errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材")
|
||||
no_asset_count += 1
|
||||
if clip.duration <= 0:
|
||||
warnings.append(
|
||||
f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长"
|
||||
)
|
||||
warnings.append(f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长")
|
||||
no_duration_count += 1
|
||||
elif clip.status == EditPlanClipStatus.PENDING:
|
||||
pending_count += 1
|
||||
@@ -237,10 +233,7 @@ class VideoComposeService:
|
||||
raise ValueError(f"剪辑计划没有片段: {plan_id}")
|
||||
|
||||
# 只处理 ready 且有 asset_id 的片段
|
||||
ready_clips = [
|
||||
c for c in clips
|
||||
if c.status == EditPlanClipStatus.READY and c.asset_id
|
||||
]
|
||||
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id]
|
||||
ready_clips.sort(key=lambda c: c.order)
|
||||
|
||||
if not ready_clips:
|
||||
@@ -286,13 +279,20 @@ class VideoComposeService:
|
||||
command.extend(["-map", "[outa]"])
|
||||
|
||||
# 编码参数
|
||||
command.extend([
|
||||
"-c:v", codec,
|
||||
"-crf", str(crf),
|
||||
"-preset", preset,
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
])
|
||||
command.extend(
|
||||
[
|
||||
"-c:v",
|
||||
codec,
|
||||
"-crf",
|
||||
str(crf),
|
||||
"-preset",
|
||||
preset,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
]
|
||||
)
|
||||
|
||||
# 输出
|
||||
command.append(output_path)
|
||||
@@ -333,13 +333,20 @@ class VideoComposeService:
|
||||
# 简单命令:input → filter → output
|
||||
filter_str = ",".join(chain.filters)
|
||||
command = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", clip.asset_id,
|
||||
"-filter_complex", f"{filter_str}[outv]",
|
||||
"-map", "[outv]",
|
||||
"-c:v", DEFAULT_CODEC,
|
||||
"-crf", str(DEFAULT_CRF),
|
||||
"-preset", DEFAULT_PRESET,
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
clip.asset_id,
|
||||
"-filter_complex",
|
||||
f"{filter_str}[outv]",
|
||||
"-map",
|
||||
"[outv]",
|
||||
"-c:v",
|
||||
DEFAULT_CODEC,
|
||||
"-crf",
|
||||
str(DEFAULT_CRF),
|
||||
"-preset",
|
||||
DEFAULT_PRESET,
|
||||
output_path,
|
||||
]
|
||||
|
||||
@@ -376,7 +383,9 @@ class VideoComposeService:
|
||||
"rendered_clips": len(rendered_clips),
|
||||
"failed_clips": len(failed_clips),
|
||||
"total_duration": total_duration,
|
||||
"can_compose": len(ready_clips) > 0 and plan.status in (
|
||||
"can_compose": len(ready_clips) > 0
|
||||
and plan.status
|
||||
in (
|
||||
EditPlanStatus.EDITING,
|
||||
EditPlanStatus.RENDERING,
|
||||
),
|
||||
@@ -408,10 +417,7 @@ class VideoComposeService:
|
||||
filters: list[str] = []
|
||||
|
||||
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop)
|
||||
filters.append(
|
||||
f"scale={output_width}:{output_height}"
|
||||
f":force_original_aspect_ratio=increase"
|
||||
)
|
||||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase")
|
||||
|
||||
# 2. crop: 居中裁剪
|
||||
filters.append(f"crop={output_width}:{output_height}")
|
||||
@@ -476,10 +482,7 @@ class VideoComposeService:
|
||||
return filter_str, total_duration
|
||||
|
||||
# ── 检查是否有转场 ─────────────────────────────────────────────
|
||||
has_transitions = any(
|
||||
t != TransitionEffect.CUT and t != "cut"
|
||||
for t in transitions
|
||||
)
|
||||
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
||||
|
||||
if not has_transitions:
|
||||
return _build_concat_filter(clip_chains)
|
||||
@@ -534,18 +537,14 @@ def _build_concat_filter(
|
||||
audio_parts: list[str] = []
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
if chain.audio_label:
|
||||
audio_parts.append(
|
||||
f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]"
|
||||
)
|
||||
audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]")
|
||||
|
||||
if audio_parts:
|
||||
parts.extend(audio_parts)
|
||||
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
|
||||
audio_count = sum(1 for c in clip_chains if c.audio_label)
|
||||
if audio_count > 0:
|
||||
parts.append(
|
||||
f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]"
|
||||
)
|
||||
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
|
||||
|
||||
return ";".join(parts), total_duration
|
||||
|
||||
@@ -618,9 +617,7 @@ def _build_xfade_filter(
|
||||
if len(audio_labels) >= 2:
|
||||
# 简单拼接音频(不做 crossfade)
|
||||
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
|
||||
parts.append(
|
||||
f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]"
|
||||
)
|
||||
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
|
||||
elif len(audio_labels) == 1:
|
||||
parts.append(f"[{audio_labels[0]}]acopy[outa]")
|
||||
|
||||
|
||||
+11
-11
@@ -5,19 +5,19 @@ module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ VITE_API_URL=http://localhost:8000
|
||||
使用 Zustand 创建 Store:
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
import { create } from "zustand";
|
||||
|
||||
interface MyStore {
|
||||
data: any;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe('App route guard', () => {
|
||||
test('redirects anonymous users to login', async ({ page }) => {
|
||||
await page.goto('/projects');
|
||||
test.describe("App route guard", () => {
|
||||
test("redirects anonymous users to login", async ({ page }) => {
|
||||
await page.goto("/projects");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe('Authentication page', () => {
|
||||
test('renders login form', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByPlaceholder('邮箱')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('密码')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible();
|
||||
test.describe("Authentication page", () => {
|
||||
test("renders login form", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByPlaceholder("邮箱")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("密码")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /登\s*录/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,61 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> };
|
||||
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null; edit_plan_id?: string | null };
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
mime_type?: string;
|
||||
file_type?: string;
|
||||
}>;
|
||||
};
|
||||
type GenerationTaskResponse = {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message?: string | null;
|
||||
strategy_id?: string | null;
|
||||
edit_plan_id?: string | null;
|
||||
};
|
||||
type ProjectTitleResponse = { id: string; text: string; usage_count: number };
|
||||
type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number };
|
||||
type GeneratedVideoResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
};
|
||||
|
||||
test.describe('Core generation and download flow', () => {
|
||||
test('generates an MP4 from the browser and exposes a playable download', async ({ page, request }) => {
|
||||
test.describe("Core generation and download flow", () => {
|
||||
test("generates an MP4 from the browser and exposes a playable download", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
@@ -58,28 +87,33 @@ test.describe('Core generation and download flow', () => {
|
||||
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectData.id, name: libraryName, kind: 'video' },
|
||||
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
const projectTitleText = `E2E 生成标题 ${suffix}`;
|
||||
const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, {
|
||||
headers,
|
||||
data: { text: projectTitleText, category: 'marketing', favorite: true },
|
||||
});
|
||||
const title = await request.post(
|
||||
`${apiBase}/projects/${projectData.id}/titles`,
|
||||
{
|
||||
headers,
|
||||
data: { text: projectTitleText, category: "marketing", favorite: true },
|
||||
},
|
||||
);
|
||||
expect(title.status(), await title.text()).toBe(200);
|
||||
const titleData = (await title.json()) as ProjectTitleResponse;
|
||||
|
||||
const fixture = fs.readFileSync(path.join(currentDir, 'fixtures', 'sample.mp4'));
|
||||
const fixture = fs.readFileSync(
|
||||
path.join(currentDir, "fixtures", "sample.mp4"),
|
||||
);
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: 'e2e-generation-source.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
name: "e2e-generation-source.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: fixture,
|
||||
},
|
||||
},
|
||||
@@ -97,17 +131,27 @@ test.describe('Core generation and download flow', () => {
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((item) => item.name === 'e2e-generation-source.mp4');
|
||||
return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing';
|
||||
const asset = data.items.find(
|
||||
(item) => item.name === "e2e-generation-source.mp4",
|
||||
);
|
||||
return asset
|
||||
? `${asset.mime_type || asset.file_type || ""}:${asset.status}`
|
||||
: "missing";
|
||||
},
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 3_000, 5_000] }
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 3_000, 5_000] },
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user, projectId }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -121,81 +165,139 @@ test.describe('Core generation and download flow', () => {
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/generation`);
|
||||
await expect(page.getByText('剪辑参数')).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator('.ant-select-selector').first().click();
|
||||
await expect(page.getByText("剪辑参数")).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator(".ant-select-selector").first().click();
|
||||
await page.getByText(`${libraryName} (video)`).click();
|
||||
await page.locator('.ant-select-selector').nth(1).click();
|
||||
await page.locator(".ant-select-selector").nth(1).click();
|
||||
await page.getByText(projectTitleText).click();
|
||||
|
||||
await expect(page.getByText(/素材就绪度:/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: '重新生成计划' })).toBeEnabled({ timeout: 20_000 });
|
||||
await page.getByRole('button', { name: '重新生成计划' }).click();
|
||||
await expect(page.getByText('剪辑计划预览')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/素材就绪度:/)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("button", { name: "重新生成计划" }),
|
||||
).toBeEnabled({ timeout: 20_000 });
|
||||
await page.getByRole("button", { name: "重新生成计划" }).click();
|
||||
await expect(page.getByText("剪辑计划预览")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText(/自动选择/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({ timeout: 20_000 });
|
||||
const confirmGenerationButton = page.getByRole('complementary').getByRole('button', { name: '确认计划并生成' });
|
||||
await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
const confirmGenerationButton = page
|
||||
.getByRole("complementary")
|
||||
.getByRole("button", { name: "确认计划并生成" });
|
||||
await expect(confirmGenerationButton).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
const createTaskResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/generation/tasks") &&
|
||||
response.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await confirmGenerationButton.click();
|
||||
const createTaskResponse = await createTaskResponsePromise;
|
||||
expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(200);
|
||||
const createdTask = (await createTaskResponse.json()) as GenerationTaskResponse;
|
||||
expect(createdTask.edit_plan_id || '').not.toBe('');
|
||||
expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(
|
||||
200,
|
||||
);
|
||||
const createdTask =
|
||||
(await createTaskResponse.json()) as GenerationTaskResponse;
|
||||
expect(createdTask.edit_plan_id || "").not.toBe("");
|
||||
|
||||
await expect(page.getByText(/生成状态:生成完成/)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(page.getByText(/生成失败|生成任务加载失败|生成结果加载失败/)).toHaveCount(0);
|
||||
await expect(page.getByText(/生成状态:生成完成/)).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(/生成失败|生成任务加载失败|生成结果加载失败/),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const task = await request.get(`${apiBase}/generation/tasks/${createdTask.id}`, { headers });
|
||||
const task = await request.get(
|
||||
`${apiBase}/generation/tasks/${createdTask.id}`,
|
||||
{ headers },
|
||||
);
|
||||
if (!task.ok()) {
|
||||
return `http_${task.status()}`;
|
||||
}
|
||||
const data = (await task.json()) as GenerationTaskResponse;
|
||||
return `${data.status}:${data.result_count}:${data.strategy_id || ''}:${data.error_message || ''}`;
|
||||
return `${data.status}:${data.result_count}:${data.strategy_id || ""}:${data.error_message || ""}`;
|
||||
},
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 5_000] }
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 5_000] },
|
||||
)
|
||||
.toMatch(new RegExp(`^completed:[1-9]\\d*:${titleData.id}:`));
|
||||
|
||||
const results = await request.get(`${apiBase}/generation/tasks/${createdTask.id}/results`, { headers });
|
||||
const results = await request.get(
|
||||
`${apiBase}/generation/tasks/${createdTask.id}/results`,
|
||||
{ headers },
|
||||
);
|
||||
expect(results.status(), await results.text()).toBe(200);
|
||||
const resultsData = (await results.json()) as { items: GeneratedVideoResponse[] };
|
||||
const resultsData = (await results.json()) as {
|
||||
items: GeneratedVideoResponse[];
|
||||
};
|
||||
expect(resultsData.items.length).toBeGreaterThan(0);
|
||||
const generatedVideo = resultsData.items[0];
|
||||
expect(generatedVideo.name).toMatch(/\.mp4$/);
|
||||
expect(generatedVideo.file_size).toBeGreaterThan(0);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/results`);
|
||||
const resultCard = page.locator('.xx-vertical-card').filter({ hasText: generatedVideo.name });
|
||||
const resultCard = page
|
||||
.locator(".xx-vertical-card")
|
||||
.filter({ hasText: generatedVideo.name });
|
||||
await expect(resultCard).toBeVisible({ timeout: 20_000 });
|
||||
await expect(resultCard.getByText('待复核')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(resultCard.getByRole('button', { name: /下载/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '批量获取下载地址' })).toBeEnabled();
|
||||
await resultCard.getByRole('button', { name: '可发布' }).click();
|
||||
await expect(page.getByText('成片复核状态已更新')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(resultCard.locator('.xx-pill.ok', { hasText: '可发布' })).toBeVisible({ timeout: 20_000 });
|
||||
const reviewedVideo = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}`, { headers });
|
||||
await expect(resultCard.getByText("待复核")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
resultCard.getByRole("button", { name: /下载/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "批量获取下载地址" }),
|
||||
).toBeEnabled();
|
||||
await resultCard.getByRole("button", { name: "可发布" }).click();
|
||||
await expect(page.getByText("成片复核状态已更新")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
resultCard.locator(".xx-pill.ok", { hasText: "可发布" }),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
const reviewedVideo = await request.get(
|
||||
`${apiBase}/generated-videos/${generatedVideo.id}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(reviewedVideo.status(), await reviewedVideo.text()).toBe(200);
|
||||
const reviewedVideoData = (await reviewedVideo.json()) as { review_status: string; generation_params: Record<string, unknown> };
|
||||
expect(reviewedVideoData.review_status).toBe('approved');
|
||||
const reviewedVideoData = (await reviewedVideo.json()) as {
|
||||
review_status: string;
|
||||
generation_params: Record<string, unknown>;
|
||||
};
|
||||
expect(reviewedVideoData.review_status).toBe("approved");
|
||||
expect(reviewedVideoData.generation_params.title_id).toBe(titleData.id);
|
||||
expect(reviewedVideoData.generation_params.edit_plan_id).toBe(createdTask.edit_plan_id);
|
||||
const downloadUrlResponse = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}/download-url`, { headers });
|
||||
expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(200);
|
||||
const downloadData = (await downloadUrlResponse.json()) as { download_url: string };
|
||||
const videoResponse = await request.get(downloadData.download_url, { timeout: 30_000 });
|
||||
expect(reviewedVideoData.generation_params.edit_plan_id).toBe(
|
||||
createdTask.edit_plan_id,
|
||||
);
|
||||
const downloadUrlResponse = await request.get(
|
||||
`${apiBase}/generated-videos/${generatedVideo.id}/download-url`,
|
||||
{ headers },
|
||||
);
|
||||
expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(
|
||||
200,
|
||||
);
|
||||
const downloadData = (await downloadUrlResponse.json()) as {
|
||||
download_url: string;
|
||||
};
|
||||
const videoResponse = await request.get(downloadData.download_url, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(videoResponse.status(), await videoResponse.text()).toBe(200);
|
||||
expect(videoResponse.headers()['content-type'] || '').toContain('video/mp4');
|
||||
expect(videoResponse.headers()["content-type"] || "").toContain(
|
||||
"video/mp4",
|
||||
);
|
||||
const videoBody = await videoResponse.body();
|
||||
expect(videoBody.length).toBeGreaterThan(1024);
|
||||
|
||||
@@ -203,20 +305,43 @@ test.describe('Core generation and download flow', () => {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
expect(assetsAfterGeneration.status(), await assetsAfterGeneration.text()).toBe(200);
|
||||
const assetsAfterGenerationData = (await assetsAfterGeneration.json()) as { items: Array<{ name: string; metadata: Record<string, unknown> }> };
|
||||
const sourceAsset = assetsAfterGenerationData.items.find((item) => item.name === 'e2e-generation-source.mp4');
|
||||
expect(
|
||||
assetsAfterGeneration.status(),
|
||||
await assetsAfterGeneration.text(),
|
||||
).toBe(200);
|
||||
const assetsAfterGenerationData = (await assetsAfterGeneration.json()) as {
|
||||
items: Array<{ name: string; metadata: Record<string, unknown> }>;
|
||||
};
|
||||
const sourceAsset = assetsAfterGenerationData.items.find(
|
||||
(item) => item.name === "e2e-generation-source.mp4",
|
||||
);
|
||||
expect(sourceAsset?.metadata.generation_use_count).toBe(1);
|
||||
expect(sourceAsset?.metadata.review_status).toBe('pending_review');
|
||||
const titleAfterGeneration = await request.get(`${apiBase}/projects/${projectData.id}/titles`, { headers });
|
||||
expect(titleAfterGeneration.status(), await titleAfterGeneration.text()).toBe(200);
|
||||
const titlesData = (await titleAfterGeneration.json()) as { items: ProjectTitleResponse[] };
|
||||
expect(titlesData.items.find((item) => item.id === titleData.id)?.usage_count).toBe(1);
|
||||
expect(sourceAsset?.metadata.review_status).toBe("pending_review");
|
||||
const titleAfterGeneration = await request.get(
|
||||
`${apiBase}/projects/${projectData.id}/titles`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
titleAfterGeneration.status(),
|
||||
await titleAfterGeneration.text(),
|
||||
).toBe(200);
|
||||
const titlesData = (await titleAfterGeneration.json()) as {
|
||||
items: ProjectTitleResponse[];
|
||||
};
|
||||
expect(
|
||||
titlesData.items.find((item) => item.id === titleData.id)?.usage_count,
|
||||
).toBe(1);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/tasks`);
|
||||
await expect(page.getByText('项目任务中心')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('视频生成')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('已完成').first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(createdTask.id)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText("项目任务中心")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("视频生成")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText("已完成").first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText(createdTask.id)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Project title library flow', () => {
|
||||
test('creates a reusable title from the browser', async ({ page, request }) => {
|
||||
test.describe("Project title library flow", () => {
|
||||
test("creates a reusable title from the browser", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-title-${suffix}@example.com`;
|
||||
@@ -27,22 +36,33 @@ test.describe('Project title library flow', () => {
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD } });
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
});
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E Title Project ${suffix}`, description: 'Playwright title smoke' },
|
||||
data: {
|
||||
name: `E2E Title Project ${suffix}`,
|
||||
description: "Playwright title smoke",
|
||||
},
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as { id: string };
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -55,23 +75,36 @@ test.describe('Project title library flow', () => {
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/titles`);
|
||||
await expect(page.getByRole('heading', { name: '标题库' })).toBeVisible({ timeout: 20_000 });
|
||||
const titleText = `E2E 标题 ${suffix}`;
|
||||
await page.getByPlaceholder('例如:3 秒抓住注意力,30 秒讲清卖点').fill(titleText);
|
||||
const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, {
|
||||
headers,
|
||||
data: { text: titleText, category: 'default', favorite: true },
|
||||
await expect(page.getByRole("heading", { name: "标题库" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
const titleText = `E2E 标题 ${suffix}`;
|
||||
await page
|
||||
.getByPlaceholder("例如:3 秒抓住注意力,30 秒讲清卖点")
|
||||
.fill(titleText);
|
||||
const title = await request.post(
|
||||
`${apiBase}/projects/${projectData.id}/titles`,
|
||||
{
|
||||
headers,
|
||||
data: { text: titleText, category: "default", favorite: true },
|
||||
},
|
||||
);
|
||||
expect(title.status(), await title.text()).toBe(200);
|
||||
await page.reload();
|
||||
await expect(page.getByText(titleText)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('.xx-title-row').filter({ hasText: titleText }).getByText('常用').first()).toBeVisible();
|
||||
await page.getByPlaceholder('搜索标题').fill(titleText);
|
||||
await expect(
|
||||
page
|
||||
.locator(".xx-title-row")
|
||||
.filter({ hasText: titleText })
|
||||
.getByText("常用")
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await page.getByPlaceholder("搜索标题").fill(titleText);
|
||||
await expect(page.getByText(titleText)).toBeVisible();
|
||||
await expect(page.getByText('使用次数:0')).toBeVisible();
|
||||
await expect(page.getByText("使用次数:0")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
@@ -16,8 +22,11 @@ const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) =
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
|
||||
test.describe('Core media upload flow', () => {
|
||||
test('uploads a MOV asset from the browser and shows it as ready', async ({ page, request }) => {
|
||||
test.describe("Core media upload flow", () => {
|
||||
test("uploads a MOV asset from the browser and shows it as ready", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
@@ -48,7 +57,7 @@ test.describe('Core media upload flow', () => {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E Project ${suffix}`,
|
||||
description: 'Playwright upload smoke',
|
||||
description: "Playwright upload smoke",
|
||||
},
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
@@ -59,7 +68,7 @@ test.describe('Core media upload flow', () => {
|
||||
data: {
|
||||
project_id: projectData.id,
|
||||
name: `E2E Video Library ${suffix}`,
|
||||
kind: 'video',
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
@@ -67,8 +76,14 @@ test.describe('Core media upload flow', () => {
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user, projectId }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -82,11 +97,13 @@ test.describe('Core media upload flow', () => {
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/assets`);
|
||||
await expect(page.getByText('点击或拖拽素材到这里上传')).toBeEnabled({ timeout: 20_000 });
|
||||
await expect(page.getByText("点击或拖拽素材到这里上传")).toBeEnabled({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
@@ -94,15 +111,17 @@ test.describe('Core media upload flow', () => {
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: 'e2e-sample.MOV',
|
||||
mimeType: 'video/quicktime',
|
||||
buffer: Buffer.from('playwright mov upload smoke'),
|
||||
name: "e2e-sample.MOV",
|
||||
mimeType: "video/quicktime",
|
||||
buffer: Buffer.from("playwright mov upload smoke"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
|
||||
await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByText(/上传失败|素材列表加载失败|素材库加载失败/),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -114,21 +133,46 @@ test.describe('Core media upload flow', () => {
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as { items: Array<{ name: string; status: string; file_type?: string; mime_type?: string }> };
|
||||
const asset = data.items.find((item) => item.name === 'e2e-sample.MOV');
|
||||
return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing';
|
||||
const data = (await assets.json()) as {
|
||||
items: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
file_type?: string;
|
||||
mime_type?: string;
|
||||
}>;
|
||||
};
|
||||
const asset = data.items.find(
|
||||
(item) => item.name === "e2e-sample.MOV",
|
||||
);
|
||||
return asset
|
||||
? `${asset.mime_type || asset.file_type || ""}:${asset.status}`
|
||||
: "missing";
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText(/素材就绪度|Ready/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/预计成片|视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('e2e-sample.MOV', { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator('.xx-vertical-card').filter({ hasText: 'e2e-sample.MOV' }).getByRole('button', { name: /通\s*过/ }).click();
|
||||
await expect(page.getByText('复核状态已更新')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/已通过|approved/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/素材就绪度|Ready/)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(/预计成片|视频素材数量偏少|素材准备度良好/),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await page
|
||||
.locator(".xx-vertical-card")
|
||||
.filter({ hasText: "e2e-sample.MOV" })
|
||||
.getByRole("button", { name: /通\s*过/ })
|
||||
.click();
|
||||
await expect(page.getByText("复核状态已更新")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText(/已通过|approved/)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查
|
||||
*/
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const PASSWORD = 'Test123456!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
@@ -39,72 +39,83 @@ async function createAuthedUser(request: any, label: string) {
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Subscription route guard', () => {
|
||||
test('redirects anonymous users to login', async ({ page }) => {
|
||||
await page.goto('/subscription');
|
||||
test.describe("Subscription route guard", () => {
|
||||
test("redirects anonymous users to login", async ({ page }) => {
|
||||
await page.goto("/subscription");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('订阅信息查看', () => {
|
||||
test('获取当前订阅信息 - 正向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-info');
|
||||
test.describe("订阅信息查看", () => {
|
||||
test("获取当前订阅信息 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-info");
|
||||
|
||||
const response = await request.get(`${apiBase}/subscription/current`, { headers });
|
||||
const response = await request.get(`${apiBase}/subscription/current`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.plan_id, '应返回 plan_id').toBeTruthy();
|
||||
expect(data.status, '应返回 status').toBeTruthy();
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
||||
expect(data.status, "应返回 status").toBeTruthy();
|
||||
});
|
||||
|
||||
test('未登录获取订阅信息 - 反向', async ({ request }) => {
|
||||
test("未登录获取订阅信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/current`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('订阅降级', () => {
|
||||
test('Pro 用户降级到 Standard - 正向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-downgrade');
|
||||
test.describe("订阅降级", () => {
|
||||
test("Pro 用户降级到 Standard - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade");
|
||||
|
||||
// 先升级到 Pro
|
||||
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: 'pro',
|
||||
billing_cycle: 'monthly',
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功: ${await upgrade.text()}`).toBeTruthy();
|
||||
expect(
|
||||
upgrade.ok(),
|
||||
`升级到 Pro 应成功: ${await upgrade.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 降级到 Standard
|
||||
const downgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: 'standard',
|
||||
billing_cycle: 'monthly',
|
||||
const downgrade = await request.post(
|
||||
`${apiBase}/subscription/change-plan`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// 降级应成功或返回提示信息(某些业务可能限制降级)
|
||||
expect(downgrade.status(), '降级请求应返回 2xx 或 4xx').toBeLessThan(500);
|
||||
expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500);
|
||||
|
||||
const data = await downgrade.json();
|
||||
// 成功或失败都应有明确响应
|
||||
expect(data).toBeTruthy();
|
||||
});
|
||||
|
||||
test('降级到相同套餐 - 反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-same');
|
||||
test("降级到相同套餐 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-same");
|
||||
|
||||
// 用户默认为 free,再次选择 free
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: 'free',
|
||||
billing_cycle: 'monthly',
|
||||
target_plan_id: "free",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,86 +128,111 @@ test.describe('订阅降级', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('降级到无效套餐 - 反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-badplan');
|
||||
test("降级到无效套餐 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-badplan");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: 'nonexistent_plan',
|
||||
billing_cycle: 'monthly',
|
||||
target_plan_id: "nonexistent_plan",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status(), '无效套餐应返回 4xx').toBeGreaterThanOrEqual(400);
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('订阅过期处理', () => {
|
||||
test('取消订阅 - 反向(免费用户)', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-cancel');
|
||||
test.describe("订阅过期处理", () => {
|
||||
test("取消订阅 - 反向(免费用户)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-cancel");
|
||||
|
||||
// 免费用户取消订阅应返回错误
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`, { headers });
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 免费用户可能不需要取消,返回 400 或类似错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.detail || data.message, '应返回错误信息').toBeTruthy();
|
||||
expect(data.detail || data.message, "应返回错误信息").toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('未登录取消订阅 - 反向', async ({ request }) => {
|
||||
test("未登录取消订阅 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('切换自动续费 - 正向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-autorenew');
|
||||
test("切换自动续费 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-autorenew");
|
||||
|
||||
// 关闭自动续费
|
||||
const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
});
|
||||
expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy();
|
||||
const disableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
disableResp.ok(),
|
||||
`关闭自动续费应成功: ${await disableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 重新开启自动续费
|
||||
const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
});
|
||||
expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy();
|
||||
const enableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
enableResp.ok(),
|
||||
`开启自动续费应成功: ${await enableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('无效参数切换自动续费 - 反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-autoren-bad');
|
||||
test("无效参数切换自动续费 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-autoren-bad");
|
||||
|
||||
// 缺少 enabled 字段
|
||||
const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: {},
|
||||
});
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('账单记录', () => {
|
||||
test('获取账单记录 - 正向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'sub-bills');
|
||||
test.describe("账单记录", () => {
|
||||
test("获取账单记录 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-bills");
|
||||
|
||||
const response = await request.get(`${apiBase}/subscription/billing-records`, { headers });
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取账单记录应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data), '账单记录应为数组').toBeTruthy();
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test('未登录获取账单记录 - 反向', async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/billing-records`);
|
||||
test("未登录获取账单记录 - 反向", async ({ request }) => {
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
+111
-59
@@ -4,10 +4,10 @@
|
||||
* 覆盖:创建素材库、列出素材库、创建素材记录
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const PASSWORD = 'Test123456!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
@@ -43,49 +43,64 @@ async function createAuthedUser(request: any, label: string) {
|
||||
}
|
||||
|
||||
/** 创建一个项目并返回 project id */
|
||||
async function createProject(request: any, headers: Record<string, string>, suffix: string): Promise<string> {
|
||||
async function createProject(
|
||||
request: any,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Asset Test Proj ${suffix}`, description: 'E2E asset test' },
|
||||
data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" },
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
test.describe('素材库流程', () => {
|
||||
test('创建素材库', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'lib-create');
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
test.describe("素材库流程", () => {
|
||||
test("创建素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `视频素材库 ${Date.now()}`,
|
||||
kind: 'video',
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok(), `创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, '应返回素材库 ID').toBeTruthy();
|
||||
expect(data.name).toContain('视频素材库');
|
||||
expect(data.kind).toBe('video');
|
||||
expect(data.id, "应返回素材库 ID").toBeTruthy();
|
||||
expect(data.name).toContain("视频素材库");
|
||||
expect(data.kind).toBe("video");
|
||||
expect(data.project_id).toBe(projectId);
|
||||
});
|
||||
|
||||
test('创建素材库 - 无效 kind 反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'lib-badkind');
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
test("创建素材库 - 无效 kind 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-badkind");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: 'Bad Kind Library',
|
||||
kind: 'invalid_kind',
|
||||
name: "Bad Kind Library",
|
||||
kind: "invalid_kind",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,33 +108,45 @@ test.describe('素材库流程', () => {
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('创建素材库 - 不存在的项目反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'lib-nopj');
|
||||
test("创建素材库 - 不存在的项目反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-nopj");
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: {
|
||||
project_id: 'nonexistent-project-999',
|
||||
name: 'Orphan Library',
|
||||
kind: 'video',
|
||||
project_id: "nonexistent-project-999",
|
||||
name: "Orphan Library",
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status(), '不存在的项目应返回 404').toBe(404);
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test('列出素材库', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'lib-list');
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
test("列出素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-list");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
// 创建 2 个不同类型的素材库
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name: `Video Lib ${Date.now()}`, kind: 'video' },
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `Video Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name: `Image Lib ${Date.now()}`, kind: 'image' },
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `Image Lib ${Date.now()}`,
|
||||
kind: "image",
|
||||
},
|
||||
});
|
||||
|
||||
// 列出(按 project_id 过滤)
|
||||
@@ -128,25 +155,36 @@ test.describe('素材库流程', () => {
|
||||
params: { project_id: projectId },
|
||||
});
|
||||
|
||||
expect(response.ok(), `列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
expect(items.length, '应至少有 2 个素材库').toBeGreaterThanOrEqual(2);
|
||||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2);
|
||||
|
||||
const kinds = items.map((i: any) => i.kind);
|
||||
expect(kinds).toContain('video');
|
||||
expect(kinds).toContain('image');
|
||||
expect(kinds).toContain("video");
|
||||
expect(kinds).toContain("image");
|
||||
});
|
||||
|
||||
test('创建素材记录', async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, 'asset-create');
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
test("创建素材记录", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
// 创建素材库
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name: `Asset Lib ${Date.now()}`, kind: 'video' },
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `Asset Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
expect(lib.ok()).toBeTruthy();
|
||||
const libData = await lib.json();
|
||||
@@ -159,31 +197,42 @@ test.describe('素材库流程', () => {
|
||||
library_id: libData.id,
|
||||
name: `test_video_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/test_${Date.now()}.mp4`,
|
||||
mime_type: 'video/mp4',
|
||||
metadata: { duration: 15.5, resolution: '1080p' },
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 15.5, resolution: "1080p" },
|
||||
file_size: 1024000,
|
||||
status: 'ready',
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok(), `创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, '应返回素材 ID').toBeTruthy();
|
||||
expect(data.name).toContain('test_video');
|
||||
expect(data.mime_type).toBe('video/mp4');
|
||||
expect(data.id, "应返回素材 ID").toBeTruthy();
|
||||
expect(data.name).toContain("test_video");
|
||||
expect(data.mime_type).toBe("video/mp4");
|
||||
expect(data.library_id).toBe(libData.id);
|
||||
});
|
||||
|
||||
test('列出素材', async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, 'asset-list');
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
test("列出素材", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-list");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
// 创建素材库
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name: `List Lib ${Date.now()}`, kind: 'video' },
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `List Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy();
|
||||
const libData = await lib.json();
|
||||
@@ -196,8 +245,8 @@ test.describe('素材库流程', () => {
|
||||
library_id: libData.id,
|
||||
name: `clip_a_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_a.mp4`,
|
||||
mime_type: 'video/mp4',
|
||||
status: 'ready',
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
});
|
||||
@@ -208,8 +257,8 @@ test.describe('素材库流程', () => {
|
||||
library_id: libData.id,
|
||||
name: `clip_b_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_b.mp4`,
|
||||
mime_type: 'video/mp4',
|
||||
status: 'ready',
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
});
|
||||
@@ -220,19 +269,22 @@ test.describe('素材库流程', () => {
|
||||
params: { library_id: libData.id },
|
||||
});
|
||||
|
||||
expect(response.ok(), `列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
expect(items.length, '应至少有 2 个素材').toBeGreaterThanOrEqual(2);
|
||||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('未登录创建素材库 - 反向', async ({ request }) => {
|
||||
test("未登录创建素材库 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
data: {
|
||||
project_id: 'some-project',
|
||||
name: 'Unauthorized Library',
|
||||
kind: 'video',
|
||||
project_id: "some-project",
|
||||
name: "Unauthorized Library",
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const PASSWORD = 'Test123456!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
@@ -17,65 +17,85 @@ function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
test.describe('认证流程', () => {
|
||||
test.describe("认证流程", () => {
|
||||
// ─── 注册 ────────────────────────────────────────────
|
||||
|
||||
test('注册新用户 - 正向', async ({ request }) => {
|
||||
const email = uniqueEmail('reg-ok');
|
||||
const username = uniqueUsername('regok');
|
||||
test("注册新用户 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("reg-ok");
|
||||
const username = uniqueUsername("regok");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username,
|
||||
display_name: 'E2E 注册测试',
|
||||
display_name: "E2E 注册测试",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok(), `注册应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`注册应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.user_id, '应返回 user_id').toBeTruthy();
|
||||
expect(data.user_id, "应返回 user_id").toBeTruthy();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
|
||||
test('注册已存在邮箱 - 反向', async ({ request }) => {
|
||||
const email = uniqueEmail('reg-dup');
|
||||
const username1 = uniqueUsername('regdup1');
|
||||
const username2 = uniqueUsername('regdup2');
|
||||
test("注册已存在邮箱 - 反向", async ({ request }) => {
|
||||
const email = uniqueEmail("reg-dup");
|
||||
const username1 = uniqueUsername("regdup1");
|
||||
const username2 = uniqueUsername("regdup2");
|
||||
|
||||
// 第一次注册
|
||||
const first = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: username1, display_name: 'User 1' },
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username: username1,
|
||||
display_name: "User 1",
|
||||
},
|
||||
});
|
||||
expect(first.ok(), '第一次注册应成功').toBeTruthy();
|
||||
expect(first.ok(), "第一次注册应成功").toBeTruthy();
|
||||
|
||||
// 第二次使用相同邮箱
|
||||
const second = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: username2, display_name: 'User 2' },
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username: username2,
|
||||
display_name: "User 2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(second.status(), '重复邮箱注册应返回 4xx').toBeGreaterThanOrEqual(400);
|
||||
expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(
|
||||
400,
|
||||
);
|
||||
expect(second.status()).toBeLessThan(500);
|
||||
|
||||
const body = await second.json();
|
||||
// 错误信息应包含"已注册"或"exists"相关提示
|
||||
const detail = (body.detail || body.message || body.error || '').toString().toLowerCase();
|
||||
const detail = (body.detail || body.message || body.error || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
expect(
|
||||
detail.includes('已') || detail.includes('exist') || detail.includes('registered') || detail.includes('duplicate'),
|
||||
detail.includes("已") ||
|
||||
detail.includes("exist") ||
|
||||
detail.includes("registered") ||
|
||||
detail.includes("duplicate"),
|
||||
`错误信息应提示邮箱已注册,实际: "${detail}"`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('注册无效邮箱格式 - 反向', async ({ request }) => {
|
||||
test("注册无效邮箱格式 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email: 'not-an-email',
|
||||
email: "not-an-email",
|
||||
password: PASSWORD,
|
||||
username: uniqueUsername('bademail'),
|
||||
display_name: 'Bad Email',
|
||||
username: uniqueUsername("bademail"),
|
||||
display_name: "Bad Email",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,13 +103,13 @@ test.describe('认证流程', () => {
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('注册弱密码 - 反向', async ({ request }) => {
|
||||
test("注册弱密码 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email: uniqueEmail('weakpwd'),
|
||||
password: '123',
|
||||
username: uniqueUsername('weakpwd'),
|
||||
display_name: 'Weak',
|
||||
email: uniqueEmail("weakpwd"),
|
||||
password: "123",
|
||||
username: uniqueUsername("weakpwd"),
|
||||
display_name: "Weak",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,63 +118,71 @@ test.describe('认证流程', () => {
|
||||
|
||||
// ─── 登录 ────────────────────────────────────────────
|
||||
|
||||
test('登录成功 - 正向', async ({ request }) => {
|
||||
const email = uniqueEmail('login-ok');
|
||||
const username = uniqueUsername('loginok');
|
||||
test("登录成功 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("login-ok");
|
||||
const username = uniqueUsername("loginok");
|
||||
|
||||
// 先注册
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: 'Login Test' },
|
||||
data: { email, password: PASSWORD, username, display_name: "Login Test" },
|
||||
});
|
||||
expect(reg.ok(), '注册应成功').toBeTruthy();
|
||||
expect(reg.ok(), "注册应成功").toBeTruthy();
|
||||
|
||||
// 登录
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
});
|
||||
|
||||
expect(response.ok(), `登录应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`登录应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.access_token, '应返回 access_token').toBeTruthy();
|
||||
expect(data.token_type).toBe('bearer');
|
||||
expect(data.access_token, "应返回 access_token").toBeTruthy();
|
||||
expect(data.token_type).toBe("bearer");
|
||||
expect(data.email).toBe(email);
|
||||
});
|
||||
|
||||
test('登录错误密码 - 反向', async ({ request }) => {
|
||||
const email = uniqueEmail('login-bad');
|
||||
const username = uniqueUsername('loginbad');
|
||||
test("登录错误密码 - 反向", async ({ request }) => {
|
||||
const email = uniqueEmail("login-bad");
|
||||
const username = uniqueUsername("loginbad");
|
||||
|
||||
// 先注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: 'Bad Login' },
|
||||
data: { email, password: PASSWORD, username, display_name: "Bad Login" },
|
||||
});
|
||||
|
||||
// 使用错误密码登录
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: 'WrongPassword999!' },
|
||||
data: { email, password: "WrongPassword999!" },
|
||||
});
|
||||
|
||||
expect(response.status(), '错误密码应返回 401').toBe(401);
|
||||
expect(response.status(), "错误密码应返回 401").toBe(401);
|
||||
});
|
||||
|
||||
test('登录不存在的邮箱 - 反向', async ({ request }) => {
|
||||
test("登录不存在的邮箱 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
|
||||
expect(response.status(), '不存在的用户应返回 401').toBe(401);
|
||||
expect(response.status(), "不存在的用户应返回 401").toBe(401);
|
||||
});
|
||||
|
||||
// ─── 登出 ────────────────────────────────────────────
|
||||
|
||||
test('登出成功', async ({ request }) => {
|
||||
const email = uniqueEmail('logout');
|
||||
const username = uniqueUsername('logout');
|
||||
test("登出成功", async ({ request }) => {
|
||||
const email = uniqueEmail("logout");
|
||||
const username = uniqueUsername("logout");
|
||||
|
||||
// 注册 & 登录
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: 'Logout Test' },
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username,
|
||||
display_name: "Logout Test",
|
||||
},
|
||||
});
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
@@ -164,7 +192,10 @@ test.describe('认证流程', () => {
|
||||
|
||||
// 登出
|
||||
const logout = await request.post(`${apiBase}/auth/logout`, { headers });
|
||||
expect(logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`).toBeTruthy();
|
||||
expect(
|
||||
logout.ok(),
|
||||
`登出应返回 2xx,实际: ${logout.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const body = await logout.json();
|
||||
expect(body.message).toBeTruthy();
|
||||
@@ -176,12 +207,12 @@ test.describe('认证流程', () => {
|
||||
|
||||
// ─── 获取当前用户信息 ─────────────────────────────────
|
||||
|
||||
test('获取当前用户信息 - 正向', async ({ request }) => {
|
||||
const email = uniqueEmail('me-ok');
|
||||
const username = uniqueUsername('meok');
|
||||
test("获取当前用户信息 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("me-ok");
|
||||
const username = uniqueUsername("meok");
|
||||
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: 'Me Test' },
|
||||
data: { email, password: PASSWORD, username, display_name: "Me Test" },
|
||||
});
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
@@ -192,7 +223,10 @@ test.describe('认证流程', () => {
|
||||
headers: { Authorization: `Bearer ${access_token}` },
|
||||
});
|
||||
|
||||
expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取用户信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.user_id).toBeTruthy();
|
||||
@@ -200,25 +234,25 @@ test.describe('认证流程', () => {
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
|
||||
test('无 token 获取用户信息 - 反向', async ({ request }) => {
|
||||
test("无 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`);
|
||||
// HTTPBearer 无凭证返回 403
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('无效 token 获取用户信息 - 反向', async ({ request }) => {
|
||||
test("无效 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: 'Bearer invalid.token.here' },
|
||||
headers: { Authorization: "Bearer invalid.token.here" },
|
||||
});
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('过期 token 获取用户信息 - 反向', async ({ request }) => {
|
||||
test("过期 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
// 使用一个伪造的过期 JWT(header.payload.signature)
|
||||
// eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"}
|
||||
// eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期)
|
||||
const expiredToken =
|
||||
'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature';
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature";
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${expiredToken}` },
|
||||
@@ -227,17 +261,17 @@ test.describe('认证流程', () => {
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('token 格式错误 - 反向', async ({ request }) => {
|
||||
test("token 格式错误 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: 'Bearer not-a-jwt' },
|
||||
headers: { Authorization: "Bearer not-a-jwt" },
|
||||
});
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('空 Bearer token - 反向', async ({ request }) => {
|
||||
test("空 Bearer token - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: 'Bearer ' },
|
||||
headers: { Authorization: "Bearer " },
|
||||
});
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* 覆盖:创建项目、列出项目、获取项目详情
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const PASSWORD = 'Test123456!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
@@ -42,41 +42,44 @@ async function createAuthedUser(request: any, label: string) {
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('项目流程', () => {
|
||||
test('创建项目', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'proj-create');
|
||||
test.describe("项目流程", () => {
|
||||
test("创建项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-create");
|
||||
const projectName = `E2E 测试项目 ${Date.now()}`;
|
||||
|
||||
const response = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: {
|
||||
name: projectName,
|
||||
description: 'Playwright E2E 回归测试创建',
|
||||
description: "Playwright E2E 回归测试创建",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok(), `创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, '应返回项目 ID').toBeTruthy();
|
||||
expect(data.id, "应返回项目 ID").toBeTruthy();
|
||||
expect(data.name).toBe(projectName);
|
||||
expect(data.owner_user_id, '应返回所有者 ID').toBeTruthy();
|
||||
expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy();
|
||||
});
|
||||
|
||||
test('创建项目名称为空 - 反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'proj-empty');
|
||||
test("创建项目名称为空 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-empty");
|
||||
|
||||
const response = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: '', description: 'Should fail' },
|
||||
data: { name: "", description: "Should fail" },
|
||||
});
|
||||
|
||||
// name 有 min_length=1 约束,应返回 422
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('列出项目', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'proj-list');
|
||||
test("列出项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-list");
|
||||
|
||||
// 先创建 2 个项目
|
||||
await request.post(`${apiBase}/projects`, {
|
||||
@@ -91,29 +94,37 @@ test.describe('项目流程', () => {
|
||||
// 列出
|
||||
const response = await request.get(`${apiBase}/projects`, { headers });
|
||||
|
||||
expect(response.ok(), `列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.projects || data || [];
|
||||
expect(Array.isArray(items)).toBeTruthy();
|
||||
expect(items.length, '应至少有 2 个项目').toBeGreaterThanOrEqual(2);
|
||||
expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('获取项目详情', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'proj-detail');
|
||||
test("获取项目详情", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-detail");
|
||||
|
||||
// 先创建
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Detail Proj ${Date.now()}`, description: 'Detail test' },
|
||||
data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" },
|
||||
});
|
||||
expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
|
||||
// 获取详情
|
||||
const response = await request.get(`${apiBase}/projects/${projectId}`, { headers });
|
||||
const response = await request.get(`${apiBase}/projects/${projectId}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(response.ok(), `获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy();
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(projectId);
|
||||
@@ -121,32 +132,38 @@ test.describe('项目流程', () => {
|
||||
expect(data.owner_user_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('获取不存在的项目 - 反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'proj-404');
|
||||
test("获取不存在的项目 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-404");
|
||||
|
||||
const response = await request.get(`${apiBase}/projects/nonexistent-project-id-999`, { headers });
|
||||
const response = await request.get(
|
||||
`${apiBase}/projects/nonexistent-project-id-999`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
expect(response.status(), '不存在的项目应返回 404').toBe(404);
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test('未登录列出项目 - 反向', async ({ request }) => {
|
||||
test("未登录列出项目 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/projects`);
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('未授权访问他人项目 - 反向', async ({ request }) => {
|
||||
test("未授权访问他人项目 - 反向", async ({ request }) => {
|
||||
// 用户 A 创建项目
|
||||
const { headers: headersA } = await createAuthedUser(request, 'proj-owner');
|
||||
const { headers: headersA } = await createAuthedUser(request, "proj-owner");
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers: headersA,
|
||||
data: { name: `Owner Proj ${Date.now()}`, description: 'Owner test' },
|
||||
data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" },
|
||||
});
|
||||
expect(created.ok(), '用户 A 创建项目应成功').toBeTruthy();
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
|
||||
// 用户 B 尝试访问用户 A 的项目
|
||||
const { headers: headersB } = await createAuthedUser(request, 'proj-intruder');
|
||||
const { headers: headersB } = await createAuthedUser(
|
||||
request,
|
||||
"proj-intruder",
|
||||
);
|
||||
const response = await request.get(`${apiBase}/projects/${projectId}`, {
|
||||
headers: headersB,
|
||||
});
|
||||
@@ -155,18 +172,24 @@ test.describe('项目流程', () => {
|
||||
expect([403, 404]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('未授权删除他人项目 - 反向', async ({ request }) => {
|
||||
test("未授权删除他人项目 - 反向", async ({ request }) => {
|
||||
// 用户 A 创建项目
|
||||
const { headers: headersA } = await createAuthedUser(request, 'proj-del-owner');
|
||||
const { headers: headersA } = await createAuthedUser(
|
||||
request,
|
||||
"proj-del-owner",
|
||||
);
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers: headersA,
|
||||
data: { name: `Delete Test Proj ${Date.now()}` },
|
||||
});
|
||||
expect(created.ok(), '用户 A 创建项目应成功').toBeTruthy();
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
|
||||
// 用户 B 尝试删除用户 A 的项目
|
||||
const { headers: headersB } = await createAuthedUser(request, 'proj-del-attempt');
|
||||
const { headers: headersB } = await createAuthedUser(
|
||||
request,
|
||||
"proj-del-attempt",
|
||||
);
|
||||
const response = await request.delete(`${apiBase}/projects/${projectId}`, {
|
||||
headers: headersB,
|
||||
});
|
||||
@@ -174,8 +197,8 @@ test.describe('项目流程', () => {
|
||||
expect([403, 404]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('使用无效项目 ID 获取详情 - 反向', async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, 'proj-badid');
|
||||
test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-badid");
|
||||
|
||||
const response = await request.get(`${apiBase}/projects/`, { headers });
|
||||
|
||||
|
||||
@@ -1,59 +1,65 @@
|
||||
/**
|
||||
* Playwright E2E 测试配置
|
||||
*/
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const externalBaseURL = process.env.E2E_BASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [['html'], ['list']],
|
||||
reporter: [["html"], ["list"]],
|
||||
use: {
|
||||
baseURL: externalBaseURL || 'http://localhost:3000',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: process.env.E2E_VIDEO ? 'retain-on-failure' : 'off',
|
||||
baseURL: externalBaseURL || "http://localhost:3000",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: process.env.E2E_VIDEO ? "retain-on-failure" : "off",
|
||||
},
|
||||
|
||||
projects: process.env.E2E_ALL_BROWSERS
|
||||
? [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' },
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
name: "Mobile Chrome",
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
name: "Mobile Safari",
|
||||
use: { ...devices["iPhone 12"] },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' },
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
webServer: externalBaseURL
|
||||
? undefined
|
||||
: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 3000',
|
||||
url: 'http://localhost:3000',
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 3000",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,10 +6,7 @@ import apiClient from "./client";
|
||||
|
||||
/** 查重记录状态 */
|
||||
export type DuplicationStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** 查重记录 */
|
||||
export interface DuplicationRecord {
|
||||
|
||||
@@ -11,11 +11,7 @@ import type { AssetItem } from "./assets";
|
||||
|
||||
/** 剪辑计划状态枚举 */
|
||||
export type EditPlanStatus =
|
||||
| "draft"
|
||||
| "editing"
|
||||
| "rendering"
|
||||
| "completed"
|
||||
| "failed";
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
@@ -218,7 +214,8 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
type: inferMediaType(asset.mime_type || ""),
|
||||
thumbnail_url: typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
|
||||
thumbnail_url:
|
||||
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
|
||||
duration:
|
||||
typeof ext.duration === "number"
|
||||
? ext.duration
|
||||
@@ -229,7 +226,8 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
tags: [],
|
||||
created_at: asset.created_at ?? "",
|
||||
quality_score: asset.quality_score ?? undefined,
|
||||
classification_status: (asset.classification_status ?? undefined) as MediaAsset["classification_status"],
|
||||
classification_status: (asset.classification_status ??
|
||||
undefined) as MediaAsset["classification_status"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ export const getTTSJobs = async (
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<TTSJobListResponse>(
|
||||
`/tts/jobs${qs ? `?${qs}` : ""}`,
|
||||
|
||||
@@ -128,7 +128,8 @@ export const getVoiceClones = async (
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(
|
||||
`/voice-clones${qs ? `?${qs}` : ""}`,
|
||||
@@ -143,7 +144,8 @@ export const getVoiceClonesWithTotal = async (
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(
|
||||
`/voice-clones${qs ? `?${qs}` : ""}`,
|
||||
@@ -155,7 +157,9 @@ export const getVoiceClonesWithTotal = async (
|
||||
export const getVoiceCloneDetail = async (
|
||||
id: string,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`);
|
||||
const response = await apiClient.get<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -186,8 +190,14 @@ export const updateVoiceClone = async (
|
||||
data: Partial<Pick<VoiceClone, "name">>,
|
||||
): Promise<VoiceClone> => {
|
||||
// 后端暂未提供更新端点,暂用详情接口模拟
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`);
|
||||
return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString() });
|
||||
const response = await apiClient.get<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}`,
|
||||
);
|
||||
return toVoiceClone({
|
||||
...response.data,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
/** 获取克隆状态 */
|
||||
|
||||
@@ -72,7 +72,8 @@ export const fetchVoices = async (
|
||||
if (params?.type) searchParams.set("type", params.type);
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<UnifiedVoiceListResponse>(
|
||||
`/voices${qs ? `?${qs}` : ""}`,
|
||||
@@ -82,7 +83,8 @@ export const fetchVoices = async (
|
||||
|
||||
/** 获取预设音色列表(无需鉴权) */
|
||||
export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => {
|
||||
const response = await apiClient.get<PresetVoiceListResponse>("/voices/presets");
|
||||
const response =
|
||||
await apiClient.get<PresetVoiceListResponse>("/voices/presets");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
* - 筛选增强:类型筛选 + 质量分筛选
|
||||
* - 视图切换:网格视图 / 列表视图
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import "./AssetSelector.css";
|
||||
import { Input, Select, Button } from "@/components/ui";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
@@ -167,7 +173,6 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
);
|
||||
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([]);
|
||||
}, [onSelectionChange]);
|
||||
@@ -184,7 +189,10 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
JSON.stringify(filteredAssets[idx]),
|
||||
);
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
if (
|
||||
selectedIds.length > 1 &&
|
||||
selectedIds.includes(filteredAssets[idx].id)
|
||||
) {
|
||||
const batchAssets = filteredAssets.filter((a) =>
|
||||
selectedIds.includes(a.id),
|
||||
);
|
||||
@@ -423,9 +431,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
<p className="as-card-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="as-card-meta">
|
||||
{formatSize(asset.size)}
|
||||
</div>
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -486,7 +492,8 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.duration != null &&
|
||||
` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -515,10 +522,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{previewAsset.thumbnail_url ? (
|
||||
<img
|
||||
src={previewAsset.thumbnail_url}
|
||||
alt={previewAsset.name}
|
||||
/>
|
||||
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
||||
) : (
|
||||
<span className="as-preview-overlay-thumb-icon">
|
||||
{MATERIAL_TYPE_ICONS[previewAsset.type]}
|
||||
@@ -535,9 +539,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
<span>大小: {formatSize(previewAsset.size)}</span>
|
||||
)}
|
||||
{previewAsset.quality_score != null && (
|
||||
<span>
|
||||
质量分: {previewAsset.quality_score}
|
||||
</span>
|
||||
<span>质量分: {previewAsset.quality_score}</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && (
|
||||
<span>标签: {previewAsset.tags.join(", ")}</span>
|
||||
|
||||
@@ -49,7 +49,8 @@ const getNextDefaultName = (): string => {
|
||||
/* ── 支持的文件扩展名 ─────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"];
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg";
|
||||
const ACCEPTED_MIME =
|
||||
".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg";
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -372,9 +373,7 @@ const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
|
||||
<div className="xx-vcmodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-vcmodal-step-label">
|
||||
{step.label}
|
||||
</span>
|
||||
<span className="xx-vcmodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -395,9 +394,7 @@ const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-vcmodal-progress-spinner xx-vcmodal-progress-spinner--cloning" />
|
||||
<p className="xx-vcmodal-progress-text">
|
||||
AI 正在克隆你的声音…
|
||||
</p>
|
||||
<p className="xx-vcmodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-vcmodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-input::placeholder {
|
||||
@@ -66,7 +68,9 @@
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone:hover {
|
||||
@@ -167,15 +171,34 @@
|
||||
animation: vcmodal-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-wave-bar:nth-child(1) { height: 40%; animation-delay: 0s; }
|
||||
.xx-vcmodal-record-wave-bar:nth-child(2) { height: 70%; animation-delay: 0.15s; }
|
||||
.xx-vcmodal-record-wave-bar:nth-child(3) { height: 100%; animation-delay: 0.3s; }
|
||||
.xx-vcmodal-record-wave-bar:nth-child(4) { height: 60%; animation-delay: 0.45s; }
|
||||
.xx-vcmodal-record-wave-bar:nth-child(5) { height: 30%; animation-delay: 0.6s; }
|
||||
.xx-vcmodal-record-wave-bar:nth-child(1) {
|
||||
height: 40%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(2) {
|
||||
height: 70%;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(3) {
|
||||
height: 100%;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(4) {
|
||||
height: 60%;
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(5) {
|
||||
height: 30%;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes vcmodal-wave {
|
||||
from { transform: scaleY(0.4); }
|
||||
to { transform: scaleY(1); }
|
||||
from {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 录制按钮 */
|
||||
@@ -188,7 +211,10 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -212,8 +238,13 @@
|
||||
}
|
||||
|
||||
@keyframes vcmodal-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提示 ─────────────────────────────────────────────────── */
|
||||
@@ -357,7 +388,9 @@
|
||||
}
|
||||
|
||||
@keyframes vcmodal-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-text {
|
||||
@@ -390,9 +423,17 @@
|
||||
}
|
||||
|
||||
@keyframes vcmodal-bounce {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(1); }
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-title {
|
||||
|
||||
@@ -39,7 +39,11 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const CloneModal: React.FC<CloneModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [voiceDescription, setVoiceDescription] = useState("");
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number {
|
||||
.xx-clonemodal-step:not(.xx-clonemodal-step--active)
|
||||
.xx-clonemodal-step-number {
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
@@ -98,7 +99,9 @@
|
||||
color: var(--xx-color-text, #111827);
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -122,7 +125,9 @@
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,17 +35,72 @@ export interface NavGroup {
|
||||
|
||||
/** 全量导航项(Header 扁平列表使用) */
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) },
|
||||
{ key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) },
|
||||
{ key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) },
|
||||
{ key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) },
|
||||
{ key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) },
|
||||
{ key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) },
|
||||
{ key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) },
|
||||
{ key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) },
|
||||
{ key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) },
|
||||
{ key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) },
|
||||
{ key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) },
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
icon: React.createElement(DashboardOutlined),
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
];
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -53,29 +108,94 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "创作工具",
|
||||
items: [
|
||||
{ key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) },
|
||||
{ key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) },
|
||||
{ key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) },
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
icon: React.createElement(DashboardOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "资源管理",
|
||||
items: [
|
||||
{ key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) },
|
||||
{ key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) },
|
||||
{ key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) },
|
||||
{ key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) },
|
||||
{ key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) },
|
||||
{ key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) },
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统",
|
||||
items: [
|
||||
{ key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) },
|
||||
{ key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) },
|
||||
{ key: "admin", label: "控制台", path: "/admin", icon: React.createElement(ControlOutlined) },
|
||||
{ key: "subscription", label: "订阅管理", path: "/subscription", icon: React.createElement(CrownOutlined) },
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/admin",
|
||||
icon: React.createElement(ControlOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "订阅管理",
|
||||
path: "/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -72,9 +72,7 @@ export const useCloneProgress = () => {
|
||||
|
||||
/** 更新某条克隆(如改名) */
|
||||
const updateClone = useCallback((updated: VoiceClone) => {
|
||||
setClones((prev) =>
|
||||
prev.map((c) => (c.id === updated.id ? updated : c)),
|
||||
);
|
||||
setClones((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -73,7 +73,10 @@ const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
): { status: StatusType; label: string } => {
|
||||
if (classificationStatus === "processing" || classificationStatus === "pending") {
|
||||
if (
|
||||
classificationStatus === "processing" ||
|
||||
classificationStatus === "pending"
|
||||
) {
|
||||
return { status: "info", label: "处理中" };
|
||||
}
|
||||
if (score == null) return { status: "info", label: "待诊断" };
|
||||
@@ -111,7 +114,10 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
thumbUrl: metadata.thumbnail_url as string | undefined,
|
||||
status,
|
||||
statusLabel: label,
|
||||
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
|
||||
duration:
|
||||
metadata.duration != null
|
||||
? formatDuration(metadata.duration as number)
|
||||
: undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at
|
||||
? new Date(item.created_at).toISOString().slice(0, 10)
|
||||
@@ -243,10 +249,10 @@ const AssetLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 获取素材库列表 ── */
|
||||
const {
|
||||
data: apiLibraries = [],
|
||||
isLoading: libLoading,
|
||||
} = useQuery<AssetLibraryItem[], Error>({
|
||||
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<
|
||||
AssetLibraryItem[],
|
||||
Error
|
||||
>({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
staleTime: 60_000,
|
||||
@@ -399,7 +405,10 @@ const AssetLibrary: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newLib = await createLibMutation.mutateAsync({ name: newLibName.trim(), kind: newLibKind });
|
||||
const newLib = await createLibMutation.mutateAsync({
|
||||
name: newLibName.trim(),
|
||||
kind: newLibKind,
|
||||
});
|
||||
setActiveLibId(newLib.id);
|
||||
setCreateModalOpen(false);
|
||||
setNewLibName("");
|
||||
|
||||
@@ -505,8 +505,12 @@
|
||||
}
|
||||
|
||||
@keyframes ep-drop-pulse {
|
||||
from { opacity: 0.6; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 片段卡片 */
|
||||
@@ -1198,7 +1202,9 @@
|
||||
fill: none;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease, stroke 0.3s ease;
|
||||
transition:
|
||||
stroke-dashoffset 0.5s ease,
|
||||
stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.ep-gen-progress-pct {
|
||||
@@ -1229,7 +1235,9 @@
|
||||
.ep-gen-progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease, background-color 0.3s ease;
|
||||
transition:
|
||||
width 0.5s ease,
|
||||
background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.ep-gen-task-id {
|
||||
|
||||
@@ -181,7 +181,11 @@ const EditingPlanner: React.FC = () => {
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: { name?: string; config?: Record<string, unknown>; total_duration?: number };
|
||||
data: {
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
total_duration?: number;
|
||||
};
|
||||
}) => updateEditPlan(id, data),
|
||||
onSuccess: () => {
|
||||
showToast("剪辑计划已更新", "success");
|
||||
@@ -193,7 +197,7 @@ const EditingPlanner: React.FC = () => {
|
||||
const { data: taskData } = useQuery<TaskItem>({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId!),
|
||||
enabled: !!taskId && (genPhase === "progress"),
|
||||
enabled: !!taskId && genPhase === "progress",
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (!data) return 2000;
|
||||
@@ -214,12 +218,7 @@ const EditingPlanner: React.FC = () => {
|
||||
}, [taskData]);
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
}: {
|
||||
templateId: string;
|
||||
duration: number;
|
||||
}) =>
|
||||
mutationFn: ({ templateId }: { templateId: string; duration: number }) =>
|
||||
createGenerationTask({
|
||||
template_id: templateId,
|
||||
asset_ids: clips
|
||||
@@ -278,17 +277,14 @@ const EditingPlanner: React.FC = () => {
|
||||
[selectedClipId],
|
||||
);
|
||||
|
||||
const handleReorderClips = useCallback(
|
||||
(fromIdx: number, toIdx: number) => {
|
||||
setClips((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, moved);
|
||||
return next.map((c, i) => ({ ...c, order: i }));
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleReorderClips = useCallback((fromIdx: number, toIdx: number) => {
|
||||
setClips((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, moved);
|
||||
return next.map((c, i) => ({ ...c, order: i }));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
(asset: MediaAsset, insertIdx: number) => {
|
||||
@@ -373,7 +369,8 @@ const EditingPlanner: React.FC = () => {
|
||||
const newClips: EditPlanClip[] = tpl.segments.map((seg, i) => ({
|
||||
id: newClipId(),
|
||||
template_segment_id: seg.id || `seg-${i}`,
|
||||
material_type: (seg.material_type as EditPlanClip["material_type"]) || "video",
|
||||
material_type:
|
||||
(seg.material_type as EditPlanClip["material_type"]) || "video",
|
||||
script_text: "",
|
||||
duration: Math.round((seg.duration_min + seg.duration_max) / 2),
|
||||
transition: { type: "none", duration: 0 },
|
||||
@@ -489,7 +486,8 @@ const EditingPlanner: React.FC = () => {
|
||||
segment_order: c.order + 1,
|
||||
duration_min: Math.max(1, c.duration - 3),
|
||||
duration_max: c.duration + 3,
|
||||
material_type: c.material_type === "voiceover" ? null : c.material_type,
|
||||
material_type:
|
||||
c.material_type === "voiceover" ? null : c.material_type,
|
||||
})),
|
||||
};
|
||||
updateMutation.mutate({ id: loadedTemplateId, data: payload });
|
||||
@@ -519,12 +517,14 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
const handleRetry = () => {
|
||||
if (!taskId) return;
|
||||
retryTask(taskId).then(() => {
|
||||
setGenPhase("progress");
|
||||
showToast("任务已重新提交", "success");
|
||||
}).catch(() => {
|
||||
showToast("重试失败", "error");
|
||||
});
|
||||
retryTask(taskId)
|
||||
.then(() => {
|
||||
setGenPhase("progress");
|
||||
showToast("任务已重新提交", "success");
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("重试失败", "error");
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseProgressModal = () => {
|
||||
@@ -546,18 +546,18 @@ const EditingPlanner: React.FC = () => {
|
||||
<div className="ep-toolbar">
|
||||
<div className="ep-toolbar-left">
|
||||
<div className="ep-mode-switch">
|
||||
{(["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[]).map(
|
||||
(mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`ep-mode-btn${currentMode === mode ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(mode)}
|
||||
title={MODE_LABELS[mode]}
|
||||
>
|
||||
{MODE_LABELS[mode]}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{(
|
||||
["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[]
|
||||
).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`ep-mode-btn${currentMode === mode ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(mode)}
|
||||
title={MODE_LABELS[mode]}
|
||||
>
|
||||
{MODE_LABELS[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ep-toolbar-center">
|
||||
@@ -568,9 +568,7 @@ const EditingPlanner: React.FC = () => {
|
||||
"未命名模板"}
|
||||
</span>
|
||||
)}
|
||||
{editPlanId && (
|
||||
<span className="ep-toolbar-plan-badge">已保存</span>
|
||||
)}
|
||||
{editPlanId && <span className="ep-toolbar-plan-badge">已保存</span>}
|
||||
</div>
|
||||
<div className="ep-toolbar-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={openSaveModal}>
|
||||
|
||||
@@ -31,7 +31,9 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<div className="ep-clip-props-summary">
|
||||
<div className="ep-clip-props-summary-item">
|
||||
<span className="ep-clip-props-summary-label">总片段数</span>
|
||||
<span className="ep-clip-props-summary-value">{clips.length}</span>
|
||||
<span className="ep-clip-props-summary-value">
|
||||
{clips.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-clip-props-summary-item">
|
||||
<span className="ep-clip-props-summary-label">总时长</span>
|
||||
|
||||
@@ -129,7 +129,8 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
|
||||
/* ── progress 阶段 ── */
|
||||
if (phase === "progress") {
|
||||
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5";
|
||||
const stepColor =
|
||||
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -146,11 +147,15 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||
<circle
|
||||
className="ep-gen-progress-ring-bg"
|
||||
cx="60" cy="60" r="52"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
/>
|
||||
<circle
|
||||
className="ep-gen-progress-ring-fill"
|
||||
cx="60" cy="60" r="52"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * 52}`,
|
||||
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
||||
@@ -180,9 +185,7 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
{task?.id && (
|
||||
<div className="ep-gen-task-id">任务 ID: {task.id}</div>
|
||||
)}
|
||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -13,10 +13,7 @@ import {
|
||||
type TemplateCategory,
|
||||
type TemplateMode,
|
||||
} from "@/api/editingPlanner";
|
||||
import {
|
||||
getMediaAssets,
|
||||
type MediaAsset,
|
||||
} from "@/api/editPlans";
|
||||
import { getMediaAssets, type MediaAsset } from "@/api/editPlans";
|
||||
import { getAssetLibraries } from "@/api/assets";
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector";
|
||||
|
||||
@@ -77,7 +74,10 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
|
||||
/* 过滤模板 */
|
||||
const filteredTemplates = templates.filter((tpl) => {
|
||||
if (searchText && !tpl.name.toLowerCase().includes(searchText.toLowerCase()))
|
||||
if (
|
||||
searchText &&
|
||||
!tpl.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
return false;
|
||||
if (filterCategory && tpl.category !== filterCategory) return false;
|
||||
return true;
|
||||
@@ -128,7 +128,10 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
value={filterCategory || undefined}
|
||||
onChange={(v: string) => setFilterCategory(v || "")}
|
||||
allowClear
|
||||
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||
options={categories.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -178,10 +181,7 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
{/* 底部操作 */}
|
||||
{loadedTemplateId && (
|
||||
<div className="ep-left-footer">
|
||||
<button
|
||||
className="ep-new-template-btn"
|
||||
onClick={onNewTemplate}
|
||||
>
|
||||
<button className="ep-new-template-btn" onClick={onNewTemplate}>
|
||||
✨ 新建空白模板
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,13 @@
|
||||
}
|
||||
|
||||
@keyframes ep-preview-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文案字幕 */
|
||||
|
||||
@@ -34,10 +34,7 @@ const formatTime = (seconds: number): string => {
|
||||
};
|
||||
|
||||
/* ── 根据播放进度计算当前片段索引 ── */
|
||||
const getClipIndexAtTime = (
|
||||
clips: EditPlanClip[],
|
||||
time: number,
|
||||
): number => {
|
||||
const getClipIndexAtTime = (clips: EditPlanClip[], time: number): number => {
|
||||
let elapsed = 0;
|
||||
for (let i = 0; i < clips.length; i++) {
|
||||
elapsed += clips[i].duration;
|
||||
@@ -47,10 +44,7 @@ const getClipIndexAtTime = (
|
||||
};
|
||||
|
||||
/* ── 根据片段索引计算起始时间 ── */
|
||||
const getClipStartTime = (
|
||||
clips: EditPlanClip[],
|
||||
clipIndex: number,
|
||||
): number => {
|
||||
const getClipStartTime = (clips: EditPlanClip[], clipIndex: number): number => {
|
||||
let time = 0;
|
||||
for (let i = 0; i < clipIndex; i++) {
|
||||
time += clips[i].duration;
|
||||
@@ -71,7 +65,8 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const currentClipIndex = clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1;
|
||||
const currentClipIndex =
|
||||
clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1;
|
||||
const currentClip = currentClipIndex >= 0 ? clips[currentClipIndex] : null;
|
||||
const clipStartTime =
|
||||
currentClipIndex >= 0 ? getClipStartTime(clips, currentClipIndex) : 0;
|
||||
@@ -120,7 +115,14 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
}
|
||||
startPlayback();
|
||||
}
|
||||
}, [isPlaying, currentTime, totalDuration, clips.length, startPlayback, stopPlayback]);
|
||||
}, [
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
clips.length,
|
||||
startPlayback,
|
||||
stopPlayback,
|
||||
]);
|
||||
|
||||
/* ── 停止/重置 ── */
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -150,7 +152,10 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
(clientX: number) => {
|
||||
if (!progressRef.current || totalDuration === 0) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (clientX - rect.left) / rect.width),
|
||||
);
|
||||
setCurrentTime(ratio * totalDuration);
|
||||
},
|
||||
[totalDuration],
|
||||
@@ -247,7 +252,9 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
>
|
||||
{/* 片段类型图标 */}
|
||||
<div className="ep-preview-type-icon">
|
||||
{currentClip ? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄" : "🎬"}
|
||||
{currentClip
|
||||
? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄"
|
||||
: "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 文案字幕 */}
|
||||
@@ -302,11 +309,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<button
|
||||
className="ep-preview-btn"
|
||||
onClick={handleStop}
|
||||
title="停止"
|
||||
>
|
||||
<button className="ep-preview-btn" onClick={handleStop} title="停止">
|
||||
⏹
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -150,7 +150,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
/* ── 转场标签 ── */
|
||||
const getTransitionLabel = (clip: EditPlanClip) => {
|
||||
if (!clip.transition || clip.transition.type === "none") return null;
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === clip.transition?.type);
|
||||
const opt = TRANSITION_OPTIONS.find(
|
||||
(o) => o.value === clip.transition?.type,
|
||||
);
|
||||
return opt ? opt.label : clip.transition.type;
|
||||
};
|
||||
|
||||
@@ -161,7 +163,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
};
|
||||
|
||||
/* ── 片段颜色 ── */
|
||||
const clipColors = ["#4f46e5", "#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706"];
|
||||
const clipColors = [
|
||||
"#4f46e5",
|
||||
"#7c3aed",
|
||||
"#2563eb",
|
||||
"#0891b2",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
];
|
||||
const getClipColor = (idx: number) => clipColors[idx % clipColors.length];
|
||||
|
||||
return (
|
||||
@@ -169,7 +178,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
{/* 可视化时长条 */}
|
||||
<div className="ep-timeline-bar">
|
||||
<div className="ep-timeline-bar-label">
|
||||
时间线 <span className="ep-timeline-bar-duration">{totalDuration}s</span>
|
||||
时间线{" "}
|
||||
<span className="ep-timeline-bar-duration">{totalDuration}s</span>
|
||||
</div>
|
||||
<div className="ep-timeline-bar-track">
|
||||
{clips.map((clip, idx) => (
|
||||
@@ -266,9 +276,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="ep-clip-duration-text">{clip.duration}s</span>
|
||||
<span className="ep-clip-duration-text">
|
||||
{clip.duration}s
|
||||
</span>
|
||||
{clip.media_asset_id && (
|
||||
<span className="ep-clip-asset-badge" title="已关联素材">
|
||||
<span
|
||||
className="ep-clip-asset-badge"
|
||||
title="已关联素材"
|
||||
>
|
||||
🔗
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -38,10 +38,7 @@ import Tag from "@/components/ui/Tag";
|
||||
import Form, { FormItem } from "@/components/ui/Form";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets";
|
||||
import {
|
||||
createEditPlan,
|
||||
generateEditPlan,
|
||||
} from "@/api/editPlans";
|
||||
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
|
||||
import apiClient from "@/api/client";
|
||||
import { fetchPresetVoices } from "@/api/voices";
|
||||
import type { PresetVoiceItem } from "@/api/voices";
|
||||
@@ -228,7 +225,9 @@ const GeneratePage: React.FC = () => {
|
||||
});
|
||||
const libraryId = libraries.length > 0 ? libraries[0].id : undefined;
|
||||
|
||||
const { data: materials = [], isLoading: materialsLoading } = useQuery<AssetItem[]>({
|
||||
const { data: materials = [], isLoading: materialsLoading } = useQuery<
|
||||
AssetItem[]
|
||||
>({
|
||||
queryKey: ["generate-assets", libraryId],
|
||||
queryFn: () => getAssets(libraryId!),
|
||||
enabled: libraryId !== undefined,
|
||||
@@ -575,11 +574,25 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-section-body">
|
||||
<div className="xx-material-grid">
|
||||
{materialsLoading ? (
|
||||
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1", textAlign: "center", padding: "24px 0" }}>
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
gridColumn: "1 / -1",
|
||||
textAlign: "center",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
加载素材中…
|
||||
</Typography.Paragraph>
|
||||
) : materials.length === 0 ? (
|
||||
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1", textAlign: "center", padding: "24px 0" }}>
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
gridColumn: "1 / -1",
|
||||
textAlign: "center",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
暂无素材,请先在素材库中上传
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
@@ -662,11 +675,21 @@ const GeneratePage: React.FC = () => {
|
||||
{voiceMode === "preset" ? (
|
||||
<div className="xx-voice-grid">
|
||||
{presetVoicesLoading ? (
|
||||
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1" }}>
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
gridColumn: "1 / -1",
|
||||
}}
|
||||
>
|
||||
加载预置音色中…
|
||||
</Typography.Paragraph>
|
||||
) : presetVoices.length === 0 ? (
|
||||
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1" }}>
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
gridColumn: "1 / -1",
|
||||
}}
|
||||
>
|
||||
暂无预置音色
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
@@ -732,12 +755,22 @@ const GeneratePage: React.FC = () => {
|
||||
value={customVoiceText}
|
||||
onChange={(e) => setCustomVoiceText(e.target.value)}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
icon={<AudioOutlined />}
|
||||
loading={synthesizeMutation.isPending}
|
||||
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
|
||||
disabled={
|
||||
!customVoiceText.trim() || synthesizeMutation.isPending
|
||||
}
|
||||
onClick={handleSynthesizeVoice}
|
||||
>
|
||||
{synthesizeMutation.isPending ? "合成中…" : "合成语音"}
|
||||
@@ -821,7 +854,9 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div className="xx-voice-header">
|
||||
<div className={`xx-voice-avatar xx-voice-avatar--${cv.status}`}>
|
||||
<div
|
||||
className={`xx-voice-avatar xx-voice-avatar--${cv.status}`}
|
||||
>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -833,11 +868,22 @@ const GeneratePage: React.FC = () => {
|
||||
className="xx-voice-status-dot"
|
||||
style={{ background: statusCfg.color }}
|
||||
/>
|
||||
<span style={{ color: statusCfg.color, fontSize: 12 }}>
|
||||
<span
|
||||
style={{
|
||||
color: statusCfg.color,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
{isReady && (
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12, marginLeft: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{formatDuration(cv.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
@@ -853,7 +899,9 @@ const GeneratePage: React.FC = () => {
|
||||
{cv.status === "processing" && (
|
||||
<div className="xx-clone-progress xx-clone-progress--indeterminate">
|
||||
<div className="xx-clone-progress-bar" />
|
||||
<span className="xx-clone-progress-text">处理中…</span>
|
||||
<span className="xx-clone-progress-text">
|
||||
处理中…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -657,39 +657,74 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-polling-fade {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xx-clone-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片状态变体 */
|
||||
.xx-voice-card--processing {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #f59e0b) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--accent-color, #f59e0b) 4%, var(--bg-primary));
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-color, #f59e0b) 40%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-color, #f59e0b) 4%,
|
||||
var(--bg-primary)
|
||||
);
|
||||
}
|
||||
|
||||
.xx-voice-card--failed {
|
||||
border-color: color-mix(in srgb, var(--error-color, #ef4444) 30%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--error-color, #ef4444) 30%,
|
||||
transparent
|
||||
);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* 头像状态变体 */
|
||||
.xx-voice-avatar--processing {
|
||||
background: linear-gradient(135deg, var(--accent-color, #f59e0b), var(--accent-dark, #d97706));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--accent-color, #f59e0b),
|
||||
var(--accent-dark, #d97706)
|
||||
);
|
||||
animation: xx-clone-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-voice-avatar--failed {
|
||||
background: linear-gradient(135deg, var(--color-gray-400, #94a3b8), var(--color-gray-500, #64748b));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-gray-400, #94a3b8),
|
||||
var(--color-gray-500, #64748b)
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes xx-clone-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* 状态行 */
|
||||
@@ -744,8 +779,12 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-progress-flow {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: 60px 0; }
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-progress--indeterminate .xx-clone-progress-text {
|
||||
@@ -753,8 +792,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-progress-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-progress-text {
|
||||
|
||||
@@ -179,7 +179,11 @@ const TaskHistory: React.FC = () => {
|
||||
<div className="xx-history-empty-icon">❌</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => refetch()}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -31,13 +31,20 @@ import "./my-voices.css";
|
||||
* ============================================================ */
|
||||
function formatDate(isoStr: string): string {
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 状态配置
|
||||
* ============================================================ */
|
||||
const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
|
||||
const STATUS_CONFIG: Record<
|
||||
VoiceCloneStatus,
|
||||
{ label: string; dotClass: string }
|
||||
> = {
|
||||
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
|
||||
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
|
||||
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
|
||||
@@ -118,7 +125,15 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
buttonSize="sm"
|
||||
onClick={() => onTogglePlay(voice)}
|
||||
>
|
||||
{isPlaying ? <><PauseCircleOutlined /> 暂停</> : <><PlayCircleOutlined /> 试听</>}
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 暂停
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined /> 试听
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : voice.status === "failed" ? (
|
||||
<Button buttonType="ghost" buttonSize="sm" disabled>
|
||||
@@ -160,7 +175,8 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
* ============================================================ */
|
||||
const MyVoices: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress();
|
||||
const { clones, loading, removeClone, updateClone, hasProcessing } =
|
||||
useCloneProgress();
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
@@ -173,27 +189,33 @@ const MyVoices: React.FC = () => {
|
||||
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
|
||||
const id = ++_toastId;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
|
||||
setTimeout(
|
||||
() => setToasts((prev) => prev.filter((t) => t.id !== id)),
|
||||
3000,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 试听播放
|
||||
const handleTogglePlay = useCallback((voice: VoiceClone) => {
|
||||
if (playingId === voice.id) {
|
||||
audioRef.current?.pause();
|
||||
setPlayingId(null);
|
||||
return;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
// Mock: 使用 sample_url 或占位 URL
|
||||
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
|
||||
audio.onended = () => setPlayingId(null);
|
||||
setPlayingId(voice.id);
|
||||
}, [playingId, showToast]);
|
||||
const handleTogglePlay = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
if (playingId === voice.id) {
|
||||
audioRef.current?.pause();
|
||||
setPlayingId(null);
|
||||
return;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
// Mock: 使用 sample_url 或占位 URL
|
||||
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
|
||||
audio.onended = () => setPlayingId(null);
|
||||
setPlayingId(voice.id);
|
||||
},
|
||||
[playingId, showToast],
|
||||
);
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (voice: VoiceClone) => {
|
||||
@@ -205,7 +227,9 @@ const MyVoices: React.FC = () => {
|
||||
const handleEditConfirm = async () => {
|
||||
if (!editingVoice || !editName.trim()) return;
|
||||
try {
|
||||
const updated = await updateVoiceClone(editingVoice.id, { name: editName.trim() });
|
||||
const updated = await updateVoiceClone(editingVoice.id, {
|
||||
name: editName.trim(),
|
||||
});
|
||||
updateClone(updated);
|
||||
setEditModalOpen(false);
|
||||
setEditingVoice(null);
|
||||
@@ -239,7 +263,9 @@ const MyVoices: React.FC = () => {
|
||||
|
||||
// 统计
|
||||
const readyCount = clones.filter((v) => v.status === "ready").length;
|
||||
const processingCount = clones.filter((v) => v.status === "processing").length;
|
||||
const processingCount = clones.filter(
|
||||
(v) => v.status === "processing",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="xx-mv-page">
|
||||
@@ -333,7 +359,9 @@ const MyVoices: React.FC = () => {
|
||||
>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditName(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setEditName(e.target.value)
|
||||
}
|
||||
placeholder="输入音色名称"
|
||||
autoFocus
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
|
||||
@@ -34,8 +34,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-mv-polling-fade {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 统计栏 ────────────────────────────────────────────── */
|
||||
@@ -93,20 +98,31 @@
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
padding: var(--space-md, 20px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.xx-mv-card:hover {
|
||||
border-color: var(--color-primary-400, #818cf8);
|
||||
box-shadow: 0 4px 20px color-mix(in srgb, var(--primary-color, #4f46e5) 8%, transparent);
|
||||
box-shadow: 0 4px 20px
|
||||
color-mix(in srgb, var(--primary-color, #4f46e5) 8%, transparent);
|
||||
}
|
||||
|
||||
.xx-mv-card--processing {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #f59e0b) 30%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-color, #f59e0b) 30%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.xx-mv-card--failed {
|
||||
border-color: color-mix(in srgb, var(--error-color, #ef4444) 25%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--error-color, #ef4444) 25%,
|
||||
transparent
|
||||
);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -153,8 +169,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-mv-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-mv-card-info {
|
||||
@@ -204,8 +225,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-mv-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 元信息 ────────────────────────────────────────────── */
|
||||
@@ -262,8 +288,12 @@
|
||||
}
|
||||
|
||||
@keyframes xx-mv-progress-flow {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: 60px 0; }
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-mv-progress--indeterminate .xx-mv-progress-text {
|
||||
@@ -271,8 +301,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-mv-progress-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-mv-progress-text {
|
||||
@@ -314,7 +349,9 @@
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.xx-mv-icon-btn:hover {
|
||||
|
||||
@@ -724,7 +724,11 @@ const ProductLibrary: React.FC = () => {
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{error?.message || "加载失败"}</p>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => refetch()}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -33,13 +33,7 @@ interface TemplateClipConfig {
|
||||
}
|
||||
|
||||
/** 模板类型 */
|
||||
type EditTemplateType =
|
||||
| "口播"
|
||||
| "种草"
|
||||
| "产品"
|
||||
| "品牌"
|
||||
| "混剪"
|
||||
| "Vlog";
|
||||
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog";
|
||||
|
||||
/** 模板数据(UI 层,映射自后端 TemplateItem) */
|
||||
interface EditTemplate {
|
||||
@@ -211,10 +205,7 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="xx-template-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="xx-template-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="xx-template-modal-close"
|
||||
@@ -231,9 +222,17 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
>
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span style={{ fontSize: 48 }}>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ?? "📋"}
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
|
||||
"📋"}
|
||||
</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, color: "#fff", marginTop: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -367,10 +366,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
onUse,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="xx-template-card"
|
||||
onClick={() => onPreview(template)}
|
||||
>
|
||||
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-template-thumb">
|
||||
<div
|
||||
@@ -432,8 +428,12 @@ const TemplateLibrary: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部");
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(null);
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">(
|
||||
"全部",
|
||||
);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// ── 获取模板列表 ──
|
||||
const {
|
||||
|
||||
@@ -403,8 +403,12 @@
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-template-modal {
|
||||
@@ -419,8 +423,14 @@
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-template-modal-close {
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
* Tab 2:我的克隆 — getVoiceClonesWithTotal()
|
||||
* 统计:fetchVoices({ limit: 1 }) 获取 preset_count / clone_count
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
SoundOutlined,
|
||||
@@ -116,12 +122,22 @@ const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
const genderLabel = (g: VoiceGender) => {
|
||||
const map: Record<VoiceGender, string> = { male: "男声", female: "女声", child: "童声", elderly: "老年" };
|
||||
const map: Record<VoiceGender, string> = {
|
||||
male: "男声",
|
||||
female: "女声",
|
||||
child: "童声",
|
||||
elderly: "老年",
|
||||
};
|
||||
return map[g];
|
||||
};
|
||||
|
||||
const languageLabel = (l: VoiceLanguage) => {
|
||||
const map: Record<VoiceLanguage, string> = { zh: "中文", en: "英文", ja: "日文", ko: "韩文" };
|
||||
const map: Record<VoiceLanguage, string> = {
|
||||
zh: "中文",
|
||||
en: "英文",
|
||||
ja: "日文",
|
||||
ko: "韩文",
|
||||
};
|
||||
return map[l];
|
||||
};
|
||||
|
||||
@@ -156,9 +172,22 @@ interface VoiceCardProps {
|
||||
}
|
||||
|
||||
const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
id: _id, name, subtitle, tags, duration, gender,
|
||||
isPlaying, isSelected, currentTime, starred, status = "ready",
|
||||
onPlay, onPause, onSeek, onSelect, onToggleStar,
|
||||
id: _id,
|
||||
name,
|
||||
subtitle,
|
||||
tags,
|
||||
duration,
|
||||
gender,
|
||||
isPlaying,
|
||||
isSelected,
|
||||
currentTime,
|
||||
starred,
|
||||
status = "ready",
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onSelect,
|
||||
onToggleStar,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -182,11 +211,16 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
|
||||
<div className="xx-voice-info">
|
||||
<div className="xx-voice-name-row">
|
||||
<h4 className="xx-voice-name" title={name}>{name}</h4>
|
||||
<h4 className="xx-voice-name" title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
{starred !== undefined && (
|
||||
<button
|
||||
className={`xx-voice-star${starred ? " active" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleStar?.(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleStar?.();
|
||||
}}
|
||||
title={starred ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<HeartOutlined />
|
||||
@@ -196,7 +230,9 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
<div className="xx-voice-subtitle">{subtitle}</div>
|
||||
<div className="xx-voice-tags">
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="xx-voice-tag">{tag}</span>
|
||||
<span key={tag} className="xx-voice-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,20 +244,19 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{status === "failed" && (
|
||||
<div className="xx-voice-status xx-voice-status--failed">
|
||||
克隆失败
|
||||
</div>
|
||||
<div className="xx-voice-status xx-voice-status--failed">克隆失败</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && (
|
||||
<div className="xx-voice-wave" />
|
||||
)}
|
||||
{status === "ready" && <div className="xx-voice-wave" />}
|
||||
|
||||
{status === "ready" && (
|
||||
<div className="xx-voice-controls">
|
||||
<button
|
||||
className="xx-voice-play-btn"
|
||||
onClick={(e) => { e.stopPropagation(); isPlaying ? onPause() : onPlay(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
isPlaying ? onPause() : onPlay();
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
@@ -231,7 +266,10 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
className="xx-voice-progress"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
|
||||
<div
|
||||
className="xx-voice-progress-bar"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="xx-voice-time">
|
||||
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
|
||||
@@ -292,7 +330,12 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status];
|
||||
const isFailed = voice.status === "failed";
|
||||
const isProcessing = voice.status === "processing";
|
||||
const genderText = voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender;
|
||||
const genderText =
|
||||
voice.gender === "male"
|
||||
? "男声"
|
||||
: voice.gender === "female"
|
||||
? "女声"
|
||||
: voice.gender;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -305,7 +348,10 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-action-btn xx-clone-action-btn--danger"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
@@ -315,7 +361,10 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-action-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onRetry(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRetry();
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
@@ -325,11 +374,15 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="xx-clone-card-header">
|
||||
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
|
||||
<div
|
||||
className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}
|
||||
>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-header-info">
|
||||
<h4 className="xx-clone-name" title={voice.name}>{voice.name}</h4>
|
||||
<h4 className="xx-clone-name" title={voice.name}>
|
||||
{voice.name}
|
||||
</h4>
|
||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||
<span className="xx-clone-status-dot" />
|
||||
{statusCfg.label}
|
||||
@@ -347,12 +400,11 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
{(voice.gender || voice.language) && (
|
||||
<span className="xx-clone-meta-item">
|
||||
<UserOutlined />
|
||||
{genderText}{voice.language ? ` · ${voice.language}` : ""}
|
||||
{genderText}
|
||||
{voice.language ? ` · ${voice.language}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-clone-meta-item">
|
||||
{voice.createdAt}
|
||||
</span>
|
||||
<span className="xx-clone-meta-item">{voice.createdAt}</span>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
@@ -370,7 +422,10 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-play-btn"
|
||||
onClick={(e) => { e.stopPropagation(); isPlaying ? onPause() : onPlay(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
isPlaying ? onPause() : onPlay();
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
@@ -378,13 +433,20 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
<div className="xx-clone-progress">
|
||||
<div
|
||||
className="xx-clone-progress-bar"
|
||||
style={{ width: isPlaying ? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%` : "0%" }}
|
||||
style={{
|
||||
width: isPlaying
|
||||
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-use-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onUse(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUse();
|
||||
}}
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
@@ -400,7 +462,10 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-retry-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onRetry(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRetry();
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
重试克隆
|
||||
@@ -420,13 +485,22 @@ const CloneDetailModal: React.FC<{
|
||||
onRetry: () => void;
|
||||
}> = ({ voice, onClose, onUse, onDelete, onRetry }) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status];
|
||||
const genderText = voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender || "未知";
|
||||
const genderText =
|
||||
voice.gender === "male"
|
||||
? "男声"
|
||||
: voice.gender === "female"
|
||||
? "女声"
|
||||
: voice.gender || "未知";
|
||||
const langText = voice.language || "未知";
|
||||
|
||||
return (
|
||||
<div className="xx-clone-detail-overlay" onClick={onClose}>
|
||||
<div className="xx-clone-detail" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="xx-clone-detail-close" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-detail-close"
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseCircleOutlined />
|
||||
</button>
|
||||
|
||||
@@ -465,7 +539,10 @@ const CloneDetailModal: React.FC<{
|
||||
<span>{voice.createdAt}</span>
|
||||
</div>
|
||||
{voice.errorMessage && (
|
||||
<div className="xx-clone-detail-row" style={{ color: "var(--error-color, #ef4444)" }}>
|
||||
<div
|
||||
className="xx-clone-detail-row"
|
||||
style={{ color: "var(--error-color, #ef4444)" }}
|
||||
>
|
||||
<span className="xx-clone-detail-label">错误</span>
|
||||
<span>{voice.errorMessage}</span>
|
||||
</div>
|
||||
@@ -474,11 +551,21 @@ const CloneDetailModal: React.FC<{
|
||||
|
||||
<div className="xx-clone-detail-actions">
|
||||
{voice.status === "failed" && (
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<ReloadOutlined />} onClick={onRetry}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<DeleteOutlined />} onClick={onDelete}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onDelete}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
{voice.status === "ready" && (
|
||||
@@ -520,7 +607,9 @@ const VoiceLibrary: React.FC = () => {
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(null);
|
||||
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(
|
||||
null,
|
||||
);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false);
|
||||
|
||||
@@ -581,7 +670,8 @@ const VoiceLibrary: React.FC = () => {
|
||||
[presetData],
|
||||
);
|
||||
const clonedVoices = useMemo(
|
||||
() => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))),
|
||||
() =>
|
||||
(cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))),
|
||||
[cloneData],
|
||||
);
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0;
|
||||
@@ -607,27 +697,30 @@ const VoiceLibrary: React.FC = () => {
|
||||
return list;
|
||||
}, [presetVoices, filterGender, filterLang, searchText]);
|
||||
|
||||
const handlePlay = useCallback((voiceId: string, duration: number) => {
|
||||
if (playingId === voiceId) return;
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
setPlayingId(voiceId);
|
||||
setCurrentTime(0);
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
const handlePlay = useCallback(
|
||||
(voiceId: string, duration: number) => {
|
||||
if (playingId === voiceId) return;
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
setPlayingId(voiceId);
|
||||
setCurrentTime(0);
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setPlayingId(null);
|
||||
return 0;
|
||||
}
|
||||
setPlayingId(null);
|
||||
return 0;
|
||||
}
|
||||
return prev + 0.1;
|
||||
});
|
||||
}, 100);
|
||||
}, [playingId]);
|
||||
return prev + 0.1;
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
[playingId],
|
||||
);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
@@ -637,12 +730,15 @@ const VoiceLibrary: React.FC = () => {
|
||||
setPlayingId(null);
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback((voiceId: string, time: number, duration: number) => {
|
||||
setCurrentTime(time);
|
||||
if (playingId !== voiceId) {
|
||||
handlePlay(voiceId, duration);
|
||||
}
|
||||
}, [playingId, handlePlay]);
|
||||
const handleSeek = useCallback(
|
||||
(voiceId: string, time: number, duration: number) => {
|
||||
setCurrentTime(time);
|
||||
if (playingId !== voiceId) {
|
||||
handlePlay(voiceId, duration);
|
||||
}
|
||||
},
|
||||
[playingId, handlePlay],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -720,7 +816,10 @@ const VoiceLibrary: React.FC = () => {
|
||||
<div className="xx-voices-tabs">
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "preset" ? " active" : ""}`}
|
||||
onClick={() => { setActiveTab("preset"); handlePause(); }}
|
||||
onClick={() => {
|
||||
setActiveTab("preset");
|
||||
handlePause();
|
||||
}}
|
||||
>
|
||||
<AudioOutlined />
|
||||
预置音色
|
||||
@@ -728,7 +827,10 @@ const VoiceLibrary: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
|
||||
onClick={() => { setActiveTab("cloned"); handlePause(); }}
|
||||
onClick={() => {
|
||||
setActiveTab("cloned");
|
||||
handlePause();
|
||||
}}
|
||||
>
|
||||
<UserOutlined />
|
||||
我的克隆
|
||||
@@ -775,7 +877,9 @@ const VoiceLibrary: React.FC = () => {
|
||||
|
||||
{presetLoading && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon"><SoundOutlined /></div>
|
||||
<div className="xx-voices-empty-icon">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<p>加载预置音色中...</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -806,9 +910,19 @@ const VoiceLibrary: React.FC = () => {
|
||||
|
||||
{!presetLoading && filteredPreset.length === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon"><SoundOutlined /></div>
|
||||
<div className="xx-voices-empty-icon">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<p>未找到匹配的音色</p>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => { setSearchText(""); setFilterGender("all"); setFilterLang("all"); }}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
setSearchText("");
|
||||
setFilterGender("all");
|
||||
setFilterLang("all");
|
||||
}}
|
||||
>
|
||||
清除筛选条件
|
||||
</Button>
|
||||
</div>
|
||||
@@ -850,22 +964,29 @@ const VoiceLibrary: React.FC = () => {
|
||||
{/* 空状态 */}
|
||||
{!cloneLoading && clonedVoices.length === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon"><UserOutlined /></div>
|
||||
<div className="xx-voices-empty-icon">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<h3>暂无克隆音色</h3>
|
||||
<p>上传音频素材即可克隆专属音色</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setCloneModalOpen(true)}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => setCloneModalOpen(true)}
|
||||
>
|
||||
<PlusOutlined /> 去克隆音色
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 处理中提示 */}
|
||||
{!cloneLoading && clonedVoices.some((v) => v.status === "processing") && (
|
||||
<div className="xx-clone-processing-hint">
|
||||
<RobotOutlined />
|
||||
<span>部分音色正在克隆处理中,完成后将自动出现在列表中。</span>
|
||||
</div>
|
||||
)}
|
||||
{!cloneLoading &&
|
||||
clonedVoices.some((v) => v.status === "processing") && (
|
||||
<div className="xx-clone-processing-hint">
|
||||
<RobotOutlined />
|
||||
<span>部分音色正在克隆处理中,完成后将自动出现在列表中。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -138,19 +138,39 @@
|
||||
|
||||
/* 头像背景色(CSS 变量,无硬编码) */
|
||||
.xx-voice-card.xx-voice-gender--male .xx-voice-avatar {
|
||||
background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--primary-color) 50%, var(--color-primary-400) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-700) 0%,
|
||||
var(--primary-color) 50%,
|
||||
var(--color-primary-400) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.xx-voice-card.xx-voice-gender--female .xx-voice-avatar {
|
||||
background: linear-gradient(135deg, var(--color-secondary-700, #9d174d) 0%, var(--color-secondary-500, #db2777) 50%, var(--color-secondary-400, #ec4899) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-secondary-700, #9d174d) 0%,
|
||||
var(--color-secondary-500, #db2777) 50%,
|
||||
var(--color-secondary-400, #ec4899) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.xx-voice-card.xx-voice-gender--child .xx-voice-avatar {
|
||||
background: linear-gradient(135deg, var(--color-accent-700, #065f46) 0%, var(--secondary-color) 50%, var(--color-secondary-400, #34d399) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-accent-700, #065f46) 0%,
|
||||
var(--secondary-color) 50%,
|
||||
var(--color-secondary-400, #34d399) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.xx-voice-card.xx-voice-gender--elderly .xx-voice-avatar {
|
||||
background: linear-gradient(135deg, var(--color-accent-700, #92400e) 0%, var(--accent-color) 50%, var(--color-accent-400, #fbbf24) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-accent-700, #92400e) 0%,
|
||||
var(--accent-color) 50%,
|
||||
var(--color-accent-400, #fbbf24) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.xx-voice-info {
|
||||
@@ -257,8 +277,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 波形 */
|
||||
@@ -266,17 +291,31 @@
|
||||
grid-column: 1 / -1;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
background: repeating-linear-gradient(90deg, var(--primary-color) 0 4px, transparent 4px 8px);
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--primary-color) 0 4px,
|
||||
transparent 4px 8px
|
||||
);
|
||||
opacity: 0.15;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-voice-card:hover .xx-voice-wave { opacity: 0.3; }
|
||||
.xx-voice-card.playing .xx-voice-wave { opacity: 0.5; animation: xx-wave-pulse 0.8s ease-in-out infinite; }
|
||||
.xx-voice-card:hover .xx-voice-wave {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.xx-voice-card.playing .xx-voice-wave {
|
||||
opacity: 0.5;
|
||||
animation: xx-wave-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes xx-wave-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放控制 */
|
||||
@@ -302,8 +341,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-voice-play-btn:hover { transform: scale(1.1); box-shadow: var(--shadow-primary); }
|
||||
.xx-voice-play-btn:active { transform: scale(0.95); }
|
||||
.xx-voice-play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
.xx-voice-play-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.xx-voice-progress {
|
||||
flex: 1;
|
||||
@@ -314,7 +358,9 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-voice-progress:hover { height: 6px; }
|
||||
.xx-voice-progress:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.xx-voice-progress-bar {
|
||||
height: 100%;
|
||||
@@ -452,7 +498,12 @@
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--primary-color) 50%, var(--color-primary-400) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-700) 0%,
|
||||
var(--primary-color) 50%,
|
||||
var(--color-primary-400) 100%
|
||||
);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
@@ -461,13 +512,22 @@
|
||||
}
|
||||
|
||||
.xx-clone-avatar--processing {
|
||||
background: linear-gradient(135deg, var(--text-tertiary), var(--text-secondary));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--text-tertiary),
|
||||
var(--text-secondary)
|
||||
);
|
||||
animation: xx-clone-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes xx-clone-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-header-info {
|
||||
@@ -524,8 +584,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
@@ -684,8 +749,12 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-detail {
|
||||
@@ -703,8 +772,14 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-detail-close {
|
||||
@@ -809,38 +884,97 @@
|
||||
animation: xx-skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-skeleton-clone-line--name { width: 60%; }
|
||||
.xx-skeleton-clone-line--status { width: 30%; height: 20px; }
|
||||
.xx-skeleton-clone-line--desc { width: 90%; }
|
||||
.xx-skeleton-clone-line--meta { width: 45%; }
|
||||
.xx-skeleton-clone-line--footer { width: 100%; height: 32px; margin-top: auto; }
|
||||
.xx-skeleton-clone-line--name {
|
||||
width: 60%;
|
||||
}
|
||||
.xx-skeleton-clone-line--status {
|
||||
width: 30%;
|
||||
height: 20px;
|
||||
}
|
||||
.xx-skeleton-clone-line--desc {
|
||||
width: 90%;
|
||||
}
|
||||
.xx-skeleton-clone-line--meta {
|
||||
width: 45%;
|
||||
}
|
||||
.xx-skeleton-clone-line--footer {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@keyframes xx-skeleton-shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.xx-voice-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||||
.xx-voice-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-voices-page { padding: var(--space-md); gap: var(--space-md); }
|
||||
.xx-voice-grid { grid-template-columns: 1fr; }
|
||||
.xx-voice-card { padding: var(--space-sm); gap: var(--space-xs) var(--space-sm); }
|
||||
.xx-voices-filters { flex-direction: column; align-items: stretch; }
|
||||
.xx-voices-filters .xx-input, .xx-voices-filters .xx-select { width: 100% !important; }
|
||||
.xx-voices-tab { padding: var(--space-xs) var(--space-md); font-size: var(--font-size-sm); }
|
||||
.xx-voices-page {
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.xx-voice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.xx-voice-card {
|
||||
padding: var(--space-sm);
|
||||
gap: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
.xx-voices-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.xx-voices-filters .xx-input,
|
||||
.xx-voices-filters .xx-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
.xx-voices-tab {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-voices-page { padding: var(--space-sm); }
|
||||
.xx-voice-avatar { width: 40px; height: 40px; font-size: 16px; }
|
||||
.xx-voice-card { grid-template-columns: 40px 1fr; }
|
||||
.xx-voice-wave { height: 14px; }
|
||||
.xx-voice-play-btn { width: 28px; height: 28px; font-size: var(--font-size-sm); }
|
||||
.xx-voices-tab { padding: var(--space-xs) var(--space-sm); font-size: var(--font-size-xs); }
|
||||
.xx-voices-tab-count { min-width: 16px; height: 16px; font-size: 10px; }
|
||||
.xx-voices-page {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
.xx-voice-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.xx-voice-card {
|
||||
grid-template-columns: 40px 1fr;
|
||||
}
|
||||
.xx-voice-wave {
|
||||
height: 14px;
|
||||
}
|
||||
.xx-voice-play-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.xx-voices-tab {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.xx-voices-tab-count {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 性能优化配置
|
||||
*/
|
||||
import { defineConfig, configDefaults } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { defineConfig, configDefaults } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
|
||||
@@ -21,14 +21,14 @@ export default defineConfig({
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
@@ -40,13 +40,13 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
'antd-vendor': ['antd', '@ant-design/icons'],
|
||||
'state-vendor': ['zustand', '@tanstack/react-query', 'axios'],
|
||||
"react-vendor": ["react", "react-dom", "react-router-dom"],
|
||||
"antd-vendor": ["antd", "@ant-design/icons"],
|
||||
"state-vendor": ["zustand", "@tanstack/react-query", "axios"],
|
||||
},
|
||||
},
|
||||
},
|
||||
minify: 'esbuild',
|
||||
minify: "esbuild",
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
@@ -59,14 +59,14 @@ export default defineConfig({
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react-router-dom',
|
||||
'antd',
|
||||
'@ant-design/icons',
|
||||
'zustand',
|
||||
'@tanstack/react-query',
|
||||
'axios',
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom",
|
||||
"antd",
|
||||
"@ant-design/icons",
|
||||
"zustand",
|
||||
"@tanstack/react-query",
|
||||
"axios",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+13
-13
@@ -1,31 +1,31 @@
|
||||
/**
|
||||
* Vitest 配置文件
|
||||
*/
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/test/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/mockData',
|
||||
"node_modules/",
|
||||
"src/test/",
|
||||
"**/*.d.ts",
|
||||
"**/*.config.*",
|
||||
"**/mockData",
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,14 +197,17 @@ class VideoDeduplicator:
|
||||
|
||||
phash_similarity = 1.0 - (avg_distance / 64)
|
||||
|
||||
return {"duplicate": True, "duplicate_of": existing.id, "reason": "phash_similar", "similarity": phash_similarity}
|
||||
return {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "phash_similar",
|
||||
"similarity": phash_similarity,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _average_histogram_similarity(
|
||||
histograms_a: list[list[float]], histograms_b: list[list[float]]
|
||||
) -> float:
|
||||
def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float:
|
||||
"""
|
||||
计算两组颜色直方图之间的平均余弦相似度。
|
||||
|
||||
|
||||
@@ -439,7 +439,9 @@ class EditingModeProcessor:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -23,7 +23,17 @@ ALLOWED_OUTPUT_DIRS = ["/tmp/video_output", "/var/app/rendered"]
|
||||
ALLOWED_INPUT_PREFIXES = ("s3://", "oss://", "local://", "/var/storage/")
|
||||
|
||||
# 允许的转场效果白名单
|
||||
ALLOWED_TRANSITIONS = {"fade", "slideleft", "slideright", "dissolve", "wipeleft", "wiperight", "cut", "slideup", "slidedown"}
|
||||
ALLOWED_TRANSITIONS = {
|
||||
"fade",
|
||||
"slideleft",
|
||||
"slideright",
|
||||
"dissolve",
|
||||
"wipeleft",
|
||||
"wiperight",
|
||||
"cut",
|
||||
"slideup",
|
||||
"slidedown",
|
||||
}
|
||||
|
||||
# 转场效果映射
|
||||
_XFADE_TRANSITION_MAP = {
|
||||
@@ -41,6 +51,7 @@ _XFADE_TRANSITION_MAP = {
|
||||
|
||||
class VideoComposeError(Exception):
|
||||
"""视频合成服务异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -56,6 +67,7 @@ class PIPPosition(StrEnum):
|
||||
@dataclass
|
||||
class Clip:
|
||||
"""视频片段"""
|
||||
|
||||
asset_id: str # 资源ID,对应输入路径
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
@@ -97,15 +109,15 @@ class VideoComposeService:
|
||||
def _validate_output_path(self, path: str) -> str:
|
||||
"""
|
||||
校验输出路径是否在允许范围内 (P0 修复)
|
||||
|
||||
|
||||
防止路径穿越攻击,如 /app/config/../../../etc/passwd
|
||||
|
||||
|
||||
Args:
|
||||
path: 用户提供的输出路径
|
||||
|
||||
|
||||
Returns:
|
||||
标准化后的绝对路径
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: 路径不在允许范围内
|
||||
"""
|
||||
@@ -119,10 +131,10 @@ class VideoComposeService:
|
||||
def _validate_input_path(self, path: str) -> bool:
|
||||
"""
|
||||
校验输入路径格式是否合法 (P1-1 修复)
|
||||
|
||||
|
||||
Args:
|
||||
path: 输入文件路径
|
||||
|
||||
|
||||
Returns:
|
||||
是否合法
|
||||
"""
|
||||
@@ -131,10 +143,10 @@ class VideoComposeService:
|
||||
def _validate_transition(self, transition: str) -> str:
|
||||
"""
|
||||
校验转场效果是否在白名单内 (P1-2 修复)
|
||||
|
||||
|
||||
Args:
|
||||
transition: 转场效果名称
|
||||
|
||||
|
||||
Returns:
|
||||
安全的转场效果名称
|
||||
"""
|
||||
@@ -150,11 +162,11 @@ class VideoComposeService:
|
||||
def compose(self, clips: list[Clip], output_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
合成视频
|
||||
|
||||
|
||||
Args:
|
||||
clips: 视频片段列表,每个片段包含 asset_id 和转场配置
|
||||
output_path: 输出文件路径
|
||||
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
@@ -169,7 +181,7 @@ class VideoComposeService:
|
||||
# 生成默认输出路径并校验
|
||||
if output_path is None:
|
||||
output_path = self._generate_output_path()
|
||||
|
||||
|
||||
# P0: 校验输出路径
|
||||
validated_output = self._validate_output_path(output_path)
|
||||
|
||||
@@ -177,7 +189,7 @@ class VideoComposeService:
|
||||
|
||||
# 获取输入路径列表
|
||||
input_paths = [clip.asset_id for clip in clips]
|
||||
|
||||
|
||||
try:
|
||||
if self.config.mode == EditingMode.ONE_TAKE:
|
||||
return self._one_take(input_paths, validated_output, clips)
|
||||
@@ -231,14 +243,24 @@ class VideoComposeService:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
self._ffprobe_bin, "-v", "error",
|
||||
"-show_entries", "stream=width,height,r_frame_rate,duration,codec_name",
|
||||
"-show_entries", "format=duration,size",
|
||||
"-of", "json", video_path,
|
||||
self._ffprobe_bin,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=width,height,r_frame_rate,duration,codec_name",
|
||||
"-show_entries",
|
||||
"format=duration,size",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
import json
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get("streams", [{}])
|
||||
video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
|
||||
@@ -260,7 +282,9 @@ class VideoComposeService:
|
||||
logger.warning(f"获取视频信息失败 {video_path}: {e}")
|
||||
return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0}
|
||||
|
||||
def _get_pip_position_offset(self, main_width: int, main_height: int, pip_width: int, pip_height: int) -> tuple[int, int]:
|
||||
def _get_pip_position_offset(
|
||||
self, main_width: int, main_height: int, pip_width: int, pip_height: int
|
||||
) -> tuple[int, int]:
|
||||
"""获取画中画位置偏移量"""
|
||||
margin = 10
|
||||
position_offsets = {
|
||||
@@ -274,16 +298,28 @@ class VideoComposeService:
|
||||
def _normalize_video(self, input_path: str, output_path: str) -> dict:
|
||||
"""标准化视频格式"""
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", input_path,
|
||||
"-r", str(self.config.output_fps),
|
||||
"-vf", f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
||||
"-r", str(self.config.output_fps),
|
||||
"-c:v", self.config.output_codec,
|
||||
"-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf),
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-an", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-r",
|
||||
str(self.config.output_fps),
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
||||
"-r",
|
||||
str(self.config.output_fps),
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-an",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return self._get_video_info(output_path)
|
||||
@@ -311,27 +347,45 @@ class VideoComposeService:
|
||||
if p != output_path:
|
||||
os.remove(p)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
def _one_take_with_xfade(self, normalized_paths: list[str], durations: list[float], output_path: str, clips: list[Clip]) -> str:
|
||||
def _one_take_with_xfade(
|
||||
self, normalized_paths: list[str], durations: list[float], output_path: str, clips: list[Clip]
|
||||
) -> str:
|
||||
"""使用 xfade 滤镜实现转场 (P1-2: 转场参数白名单校验)"""
|
||||
if len(normalized_paths) == 2:
|
||||
# 获取当前片段的转场效果并校验白名单
|
||||
transition = "fade"
|
||||
if len(clips) > 1:
|
||||
transition = self._get_validated_transition(clips[1].transition)
|
||||
|
||||
|
||||
trans_duration = self.config.transition_duration
|
||||
offset1 = durations[0] - trans_duration / 2
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", normalized_paths[0], "-i", normalized_paths[1],
|
||||
"-filter_complex", f"[0:v][1:v]xfade=transition={transition}:duration={trans_duration}:offset={offset1}[v]",
|
||||
"-map", "[v]",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
normalized_paths[0],
|
||||
"-i",
|
||||
normalized_paths[1],
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]xfade=transition={transition}:duration={trans_duration}:offset={offset1}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return output_path
|
||||
@@ -346,15 +400,26 @@ class VideoComposeService:
|
||||
f.write(f"file '{os.path.abspath(path)}'\n")
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", concat_file, "-c", "copy", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
try:
|
||||
os.remove(concat_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -373,7 +438,9 @@ class VideoComposeService:
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(self.config.output_width, self.config.output_height, pip_width, pip_height)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4")
|
||||
pip_info = self._get_video_info(video_paths[1])
|
||||
@@ -381,19 +448,43 @@ class VideoComposeService:
|
||||
if pip_info["duration"] > main_info["duration"]:
|
||||
temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", video_paths[1], "-t", str(main_info["duration"]),
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", temp_pip,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
temp_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = temp_pip
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", video_paths[1],
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", pip_normalized,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
pip_normalized,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = pip_normalized
|
||||
@@ -401,21 +492,49 @@ class VideoComposeService:
|
||||
if main_info["duration"] > pip_info["duration"]:
|
||||
looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", pip_normalized_input,
|
||||
"-t", str(main_info["duration"]),
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_pip,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = looped_pip
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", main_normalized, "-i", pip_normalized_input,
|
||||
"-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map", "[v]",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
main_normalized,
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
@@ -424,7 +543,9 @@ class VideoComposeService:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -445,38 +566,87 @@ class VideoComposeService:
|
||||
if bg_info["duration"] < audio_duration:
|
||||
looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", bg_normalized,
|
||||
"-t", str(audio_duration),
|
||||
"-vf", f"scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_bg,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = looped_bg
|
||||
elif bg_info["duration"] > audio_duration:
|
||||
temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(audio_duration),
|
||||
"-c:v", "copy", temp_bg,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
temp_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = temp_bg
|
||||
|
||||
blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_normalized,
|
||||
"-vf", f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", blurred_bg,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-vf",
|
||||
f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
blurred_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", blurred_bg, "-i", audio_path,
|
||||
"-filter_complex", "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
|
||||
"-map", "[v]", "-map", "1:a",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", "-shortest", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
blurred_bg,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
@@ -485,7 +655,9 @@ class VideoComposeService:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -510,39 +682,97 @@ class VideoComposeService:
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(self.config.output_width, self.config.output_height, pip_width, pip_height)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", voice_normalized, "-t", str(final_duration),
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", voice_adjusted,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
voice_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
voice_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(final_duration),
|
||||
"-c:v", "copy", bg_adjusted,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
bg_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
if audio_path:
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted, "-i", audio_path,
|
||||
"-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map", "[v]", "-map", "2:a", "-shortest",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"2:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted,
|
||||
"-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map", "[v]", "-map", "1:a", "-shortest",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
@@ -551,7 +781,9 @@ class VideoComposeService:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -108,9 +108,12 @@ def _probe_duration(local_path: Path) -> float:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_path),
|
||||
],
|
||||
check=True,
|
||||
@@ -152,10 +155,14 @@ def _concatenate_clips(
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", str(concat_file),
|
||||
"-c", "copy",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_file),
|
||||
"-c",
|
||||
"copy",
|
||||
str(output_path),
|
||||
]
|
||||
_run_ffmpeg(command)
|
||||
@@ -345,7 +352,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True
|
||||
)
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
return {"status": "error", "message": "数据库连接失败"}
|
||||
|
||||
@@ -40,16 +40,14 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
|
||||
session = SessionLocal()
|
||||
repo = SQLAlchemyTTSJobRepository(session)
|
||||
workflow = TTSWorkflowService(
|
||||
repository=repo, cosyvoice_service=CosyVoiceService(),
|
||||
repository=repo,
|
||||
cosyvoice_service=CosyVoiceService(),
|
||||
)
|
||||
|
||||
updated_job = workflow.poll_and_process_synthesis(job_id, timeout=120)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
f"TTS synthesis completed: job_id={job_id}, "
|
||||
f"audio_url={updated_job.output_audio_url}"
|
||||
)
|
||||
logger.info(f"TTS synthesis completed: job_id={job_id}, " f"audio_url={updated_job.output_audio_url}")
|
||||
return {
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
|
||||
@@ -47,16 +47,14 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
|
||||
session = SessionLocal()
|
||||
repo = SQLAlchemyVoiceCloneProfileRepository(session)
|
||||
workflow = VoiceCloneWorkflowService(
|
||||
repository=repo, cosyvoice_service=CosyVoiceService(),
|
||||
repository=repo,
|
||||
cosyvoice_service=CosyVoiceService(),
|
||||
)
|
||||
|
||||
updated_profile = workflow.poll_and_process_clone(profile_id, timeout=300)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Voice clone completed: profile_id={profile_id}, "
|
||||
f"voice_id={updated_profile.voice_id}"
|
||||
)
|
||||
logger.info(f"Voice clone completed: profile_id={profile_id}, " f"voice_id={updated_profile.voice_id}")
|
||||
return {
|
||||
"ok": True,
|
||||
"profile_id": profile_id,
|
||||
|
||||
@@ -153,11 +153,7 @@ class SQLAlchemyAssetRepository:
|
||||
query = query.filter(AssetModel.status == status)
|
||||
if classification_category is not None:
|
||||
# classification_result 是 JSON Text,用 LIKE 匹配 category 字段
|
||||
query = query.filter(
|
||||
AssetModel.classification_result.like(
|
||||
f'%"{classification_category}"%'
|
||||
)
|
||||
)
|
||||
query = query.filter(AssetModel.classification_result.like(f'%"{classification_category}"%'))
|
||||
query = query.order_by(AssetModel.quality_score.desc().nullslast())
|
||||
if limit > 0:
|
||||
query = query.limit(limit)
|
||||
@@ -166,10 +162,7 @@ class SQLAlchemyAssetRepository:
|
||||
# 内存中过滤 tags(tags 存在 metadata 中)
|
||||
if tags:
|
||||
tag_set = set(tags)
|
||||
candidates = [
|
||||
a for a in candidates
|
||||
if tag_set.issubset(set(a.metadata.get("tags", [])))
|
||||
]
|
||||
candidates = [a for a in candidates if tag_set.issubset(set(a.metadata.get("tags", [])))]
|
||||
return candidates
|
||||
|
||||
def _to_domain(self, model: AssetModel) -> Asset:
|
||||
|
||||
@@ -43,6 +43,7 @@ class SQLAlchemyBillingRepository:
|
||||
def update_subscription_on_payment(self, user_id: str, plan: str, expires_at: datetime) -> None:
|
||||
"""在支付成功后更新用户订阅状态(事务内调用)"""
|
||||
from packages.adapters.sqlalchemy_impl.models import UserModel
|
||||
|
||||
model = self.session.get(UserModel, user_id)
|
||||
if model:
|
||||
model.subscription_plan = plan
|
||||
|
||||
@@ -36,11 +36,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
"""根据 ID 获取片段"""
|
||||
model = (
|
||||
self.session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.id == clip_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.id == clip_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
@@ -68,11 +64,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
"""更新片段"""
|
||||
model = (
|
||||
self.session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.id == clip.id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.id == clip.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"EditPlanClip {clip.id} not found")
|
||||
model.plan_id = clip.plan_id
|
||||
@@ -93,11 +85,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
"""删除片段"""
|
||||
model = (
|
||||
self.session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.id == clip_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.id == clip_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
@@ -106,11 +94,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
"""删除计划下所有片段,返回删除数量"""
|
||||
count = (
|
||||
self.session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.plan_id == plan_id)
|
||||
.delete()
|
||||
)
|
||||
count = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.plan_id == plan_id).delete()
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
@@ -51,11 +51,7 @@ class SQLAlchemyEditPlanRepository:
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
"""根据 ID 获取计划"""
|
||||
model = (
|
||||
self.session.query(EditPlanModel)
|
||||
.filter(EditPlanModel.id == plan_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
@@ -77,11 +73,7 @@ class SQLAlchemyEditPlanRepository:
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
"""更新计划"""
|
||||
model = (
|
||||
self.session.query(EditPlanModel)
|
||||
.filter(EditPlanModel.id == plan.id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditPlanModel).filter(EditPlanModel.id == plan.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"EditPlan {plan.id} not found")
|
||||
model.template_id = plan.template_id
|
||||
@@ -96,11 +88,7 @@ class SQLAlchemyEditPlanRepository:
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
"""删除计划"""
|
||||
model = (
|
||||
self.session.query(EditPlanModel)
|
||||
.filter(EditPlanModel.id == plan_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
|
||||
@@ -59,11 +59,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
|
||||
def get(self, template_id: str) -> Optional[EditTemplate]:
|
||||
"""根据 ID 获取模板"""
|
||||
model = (
|
||||
self.session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.id == template_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditTemplateModel).filter(EditTemplateModel.id == template_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
@@ -87,11 +83,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
|
||||
def update(self, template: EditTemplate) -> EditTemplate:
|
||||
"""更新模板"""
|
||||
model = (
|
||||
self.session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.id == template.id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditTemplateModel).filter(EditTemplateModel.id == template.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"EditTemplate {template.id} not found")
|
||||
model.name = template.name
|
||||
@@ -108,11 +100,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
|
||||
def delete(self, template_id: str) -> bool:
|
||||
"""删除模板"""
|
||||
model = (
|
||||
self.session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.id == template_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(EditTemplateModel).filter(EditTemplateModel.id == template_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
|
||||
@@ -125,9 +125,7 @@ class SQLAlchemyJobRepository:
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Job]:
|
||||
query = self.session.query(JobModel).filter(
|
||||
JobModel.created_by_user_id == user_id
|
||||
)
|
||||
query = self.session.query(JobModel).filter(JobModel.created_by_user_id == user_id)
|
||||
if job_type is not None:
|
||||
jt = job_type.value if isinstance(job_type, JobType) else job_type
|
||||
query = query.filter(JobModel.job_type == jt)
|
||||
|
||||
@@ -408,6 +408,7 @@ class TemplateCategoryModel(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class JobModel(Base):
|
||||
"""Phase 8 任务 2.10 — 统一异步任务 ORM 模型。"""
|
||||
|
||||
@@ -461,8 +462,10 @@ class TTSJobModel(Base):
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class BillingRecordModel(Base):
|
||||
"""账单记录"""
|
||||
|
||||
__tablename__ = "billing_records"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
|
||||
@@ -40,11 +40,7 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
|
||||
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""根据 ID 获取配置"""
|
||||
model = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.id == config_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
@@ -70,11 +66,7 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
|
||||
def update(self, config: TemplateClipConfig) -> TemplateClipConfig:
|
||||
"""更新配置"""
|
||||
model = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.id == config.id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"TemplateClipConfig {config.id} not found")
|
||||
model.template_id = config.template_id
|
||||
@@ -93,11 +85,7 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
|
||||
def delete(self, config_id: str) -> bool:
|
||||
"""删除配置"""
|
||||
model = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.id == config_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
@@ -132,9 +120,9 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
max_duration=model.max_duration or 0.0,
|
||||
text_template=model.text_template or "",
|
||||
material_requirements=model.material_requirements or {},
|
||||
transition_effect=TransitionEffect(model.transition_effect)
|
||||
if model.transition_effect
|
||||
else TransitionEffect.CUT,
|
||||
transition_effect=(
|
||||
TransitionEffect(model.transition_effect) if model.transition_effect else TransitionEffect.CUT
|
||||
),
|
||||
config=model.config or {},
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
|
||||
@@ -58,11 +58,7 @@ class SQLAlchemyTTSJobRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def update(self, job: TTSJob) -> TTSJob:
|
||||
model = (
|
||||
self.session.query(TTSJobModel)
|
||||
.filter(TTSJobModel.id == job.id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(TTSJobModel).filter(TTSJobModel.id == job.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"TTSJob {job.id} not found")
|
||||
model.input_text = job.input_text
|
||||
@@ -88,11 +84,7 @@ class SQLAlchemyTTSJobRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def delete(self, job_id: str) -> bool:
|
||||
model = (
|
||||
self.session.query(TTSJobModel)
|
||||
.filter(TTSJobModel.id == job_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(TTSJobModel).filter(TTSJobModel.id == job_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
model.status = "deleted"
|
||||
@@ -118,12 +110,9 @@ class SQLAlchemyTTSJobRepository:
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def count_by_user(self, user_id: str, *, status: Optional[str] = None) -> int:
|
||||
query = (
|
||||
self.session.query(TTSJobModel)
|
||||
.filter(
|
||||
TTSJobModel.user_id == user_id,
|
||||
TTSJobModel.status != "deleted",
|
||||
)
|
||||
query = self.session.query(TTSJobModel).filter(
|
||||
TTSJobModel.user_id == user_id,
|
||||
TTSJobModel.status != "deleted",
|
||||
)
|
||||
if status:
|
||||
query = query.filter(TTSJobModel.status == status)
|
||||
|
||||
@@ -52,11 +52,7 @@ class SQLAlchemyVoiceCloneProfileRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def update(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
|
||||
model = (
|
||||
self.session.query(VoiceCloneProfileModel)
|
||||
.filter(VoiceCloneProfileModel.id == profile.id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(VoiceCloneProfileModel).filter(VoiceCloneProfileModel.id == profile.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"VoiceCloneProfile {profile.id} not found")
|
||||
model.name = profile.name
|
||||
@@ -76,11 +72,7 @@ class SQLAlchemyVoiceCloneProfileRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def delete(self, profile_id: str) -> bool:
|
||||
model = (
|
||||
self.session.query(VoiceCloneProfileModel)
|
||||
.filter(VoiceCloneProfileModel.id == profile_id)
|
||||
.first()
|
||||
)
|
||||
model = self.session.query(VoiceCloneProfileModel).filter(VoiceCloneProfileModel.id == profile_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
model.status = "deleted"
|
||||
@@ -106,12 +98,9 @@ class SQLAlchemyVoiceCloneProfileRepository:
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def count_by_user(self, user_id: str, *, status: Optional[str] = None) -> int:
|
||||
query = (
|
||||
self.session.query(VoiceCloneProfileModel)
|
||||
.filter(
|
||||
VoiceCloneProfileModel.user_id == user_id,
|
||||
VoiceCloneProfileModel.status != "deleted",
|
||||
)
|
||||
query = self.session.query(VoiceCloneProfileModel).filter(
|
||||
VoiceCloneProfileModel.user_id == user_id,
|
||||
VoiceCloneProfileModel.status != "deleted",
|
||||
)
|
||||
if status:
|
||||
query = query.filter(VoiceCloneProfileModel.status == status)
|
||||
|
||||
@@ -111,12 +111,9 @@ class SQLAlchemyVoiceLibraryRepository:
|
||||
return True
|
||||
|
||||
def count_by_user(self, user_id: str, *, status: Optional[str] = None) -> int:
|
||||
query = (
|
||||
self.session.query(VoiceLibraryModel)
|
||||
.filter(
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
VoiceLibraryModel.status != "deleted",
|
||||
)
|
||||
query = self.session.query(VoiceLibraryModel).filter(
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
VoiceLibraryModel.status != "deleted",
|
||||
)
|
||||
if status:
|
||||
query = query.filter(VoiceLibraryModel.status == status)
|
||||
|
||||
@@ -191,6 +191,7 @@ class JWTService:
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# 全局实例(生产环境必须从配置读取有效的 secret_key)
|
||||
# jwt_service = JWTService() # 不再允许无参数实例化
|
||||
|
||||
|
||||
@@ -196,9 +196,7 @@ class CosyVoiceService:
|
||||
request_id = response.get("request_id", "")
|
||||
|
||||
if not task_id and not voice_id:
|
||||
raise CosyVoiceError(
|
||||
f"CosyVoice API 未返回 task_id 或 voice_id: {response}"
|
||||
)
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
@@ -331,9 +329,7 @@ class CosyVoiceService:
|
||||
request_id=response.get("request_id", ""),
|
||||
)
|
||||
else:
|
||||
raise CosyVoiceError(
|
||||
f"CosyVoice API 未返回 task_id 或 voice_id: {response}"
|
||||
)
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
|
||||
|
||||
def _poll_clone_task(self, task_id: str, timeout: float) -> dict:
|
||||
"""轮询音色克隆任务状态。
|
||||
@@ -355,9 +351,7 @@ class CosyVoiceService:
|
||||
while attempts < self.MAX_POLL_ATTEMPTS:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise CosyVoiceTimeoutError(
|
||||
f"音色克隆任务超时({timeout}秒): task_id={task_id}"
|
||||
)
|
||||
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): task_id={task_id}")
|
||||
|
||||
response = self._call_api(
|
||||
method="GET",
|
||||
@@ -371,9 +365,7 @@ class CosyVoiceService:
|
||||
if status == "SUCCEEDED":
|
||||
voice_id = output.get("voice_id", "")
|
||||
if not voice_id:
|
||||
raise CosyVoiceError(
|
||||
f"音色克隆任务成功但未返回 voice_id: {response}"
|
||||
)
|
||||
raise CosyVoiceError(f"音色克隆任务成功但未返回 voice_id: {response}")
|
||||
return {"voice_id": voice_id}
|
||||
elif status == "FAILED":
|
||||
error_msg = output.get("message", "未知错误")
|
||||
@@ -385,9 +377,7 @@ class CosyVoiceService:
|
||||
else:
|
||||
raise CosyVoiceError(f"未知的任务状态: {status}")
|
||||
|
||||
raise CosyVoiceTimeoutError(
|
||||
f"音色克隆任务轮询次数超限: task_id={task_id}"
|
||||
)
|
||||
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: task_id={task_id}")
|
||||
|
||||
# ── 语音合成 ─────────────────────────────────────────
|
||||
|
||||
@@ -455,9 +445,7 @@ class CosyVoiceService:
|
||||
request_id = response.get("request_id", "")
|
||||
|
||||
if not task_id and not audio_url:
|
||||
raise CosyVoiceError(
|
||||
f"CosyVoice API 未返回 task_id 或 audio_url: {response}"
|
||||
)
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 audio_url: {response}")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
@@ -571,9 +559,7 @@ class CosyVoiceService:
|
||||
request_id=response.get("request_id", ""),
|
||||
)
|
||||
else:
|
||||
raise CosyVoiceError(
|
||||
f"CosyVoice API 未返回 audio_url 或 task_id: {response}"
|
||||
)
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url 或 task_id: {response}")
|
||||
|
||||
def _poll_synthesize_task(self, task_id: str, timeout: float) -> dict:
|
||||
"""轮询语音合成任务状态。
|
||||
@@ -595,9 +581,7 @@ class CosyVoiceService:
|
||||
while attempts < self.MAX_POLL_ATTEMPTS:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise CosyVoiceTimeoutError(
|
||||
f"语音合成任务超时({timeout}秒): task_id={task_id}"
|
||||
)
|
||||
raise CosyVoiceTimeoutError(f"语音合成任务超时({timeout}秒): task_id={task_id}")
|
||||
|
||||
response = self._call_api(
|
||||
method="GET",
|
||||
@@ -611,9 +595,7 @@ class CosyVoiceService:
|
||||
if status == "SUCCEEDED":
|
||||
audio_url = output.get("audio_url", "")
|
||||
if not audio_url:
|
||||
raise CosyVoiceError(
|
||||
f"语音合成任务成功但未返回 audio_url: {response}"
|
||||
)
|
||||
raise CosyVoiceError(f"语音合成任务成功但未返回 audio_url: {response}")
|
||||
return {
|
||||
"audio_url": audio_url,
|
||||
"duration": output.get("duration", 0.0),
|
||||
@@ -629,9 +611,7 @@ class CosyVoiceService:
|
||||
else:
|
||||
raise CosyVoiceError(f"未知的任务状态: {status}")
|
||||
|
||||
raise CosyVoiceTimeoutError(
|
||||
f"语音合成任务轮询次数超限: task_id={task_id}"
|
||||
)
|
||||
raise CosyVoiceTimeoutError(f"语音合成任务轮询次数超限: task_id={task_id}")
|
||||
|
||||
# ── 内部方法 ─────────────────────────────────────────
|
||||
|
||||
@@ -682,35 +662,25 @@ class CosyVoiceService:
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code in (401, 403):
|
||||
raise CosyVoiceAuthError(
|
||||
f"CosyVoice API 认证失败: HTTP {response.status_code}"
|
||||
)
|
||||
raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}")
|
||||
elif response.status_code >= 500:
|
||||
# 服务端错误,可重试
|
||||
last_error = CosyVoiceError(
|
||||
f"CosyVoice API 服务端错误: HTTP {response.status_code}"
|
||||
)
|
||||
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
|
||||
logger.warning(
|
||||
f"CosyVoice API 失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): "
|
||||
f"HTTP {response.status_code}"
|
||||
f"CosyVoice API 失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): " f"HTTP {response.status_code}"
|
||||
)
|
||||
else:
|
||||
# 客户端错误,不重试
|
||||
raise CosyVoiceError(
|
||||
f"CosyVoice API 调用失败: HTTP {response.status_code}, "
|
||||
f"body={response.text}"
|
||||
f"CosyVoice API 调用失败: HTTP {response.status_code}, " f"body={response.text}"
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
last_error = CosyVoiceTimeoutError(f"请求超时: {e}")
|
||||
logger.warning(
|
||||
f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})"
|
||||
)
|
||||
logger.warning(f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})")
|
||||
except httpx.RequestError as e:
|
||||
last_error = CosyVoiceError(f"请求错误: {e}")
|
||||
logger.warning(
|
||||
f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}"
|
||||
)
|
||||
logger.warning(f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
|
||||
|
||||
# 指数退避
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
|
||||
@@ -80,7 +80,9 @@ class CreateJobUseCase:
|
||||
)
|
||||
logger.info(
|
||||
"创建任务: job_id=%s type=%s project=%s",
|
||||
job.id, job.job_type.value, job.project_id,
|
||||
job.id,
|
||||
job.job_type.value,
|
||||
job.project_id,
|
||||
)
|
||||
return self._job_repo.create(job)
|
||||
|
||||
@@ -223,13 +225,19 @@ class ListJobsUseCase:
|
||||
) -> list[Job]:
|
||||
if project_id:
|
||||
return self._job_repo.list_by_project(
|
||||
project_id, job_type=job_type, status=status,
|
||||
limit=limit, offset=offset,
|
||||
project_id,
|
||||
job_type=job_type,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
if user_id:
|
||||
return self._job_repo.list_by_user(
|
||||
user_id, job_type=job_type, status=status,
|
||||
limit=limit, offset=offset,
|
||||
user_id,
|
||||
job_type=job_type,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
raise ValueError("必须指定 project_id 或 user_id")
|
||||
|
||||
|
||||
@@ -64,9 +64,7 @@ class ListTTSJobsUseCase:
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[List[TTSJob], int]:
|
||||
items = self.repository.list_by_user(
|
||||
user_id, status=status, limit=limit, offset=skip
|
||||
)
|
||||
items = self.repository.list_by_user(user_id, status=status, limit=limit, offset=skip)
|
||||
total = self.repository.count_by_user(user_id, status=status)
|
||||
return items, total
|
||||
|
||||
|
||||
@@ -103,17 +103,12 @@ class TTSWorkflowService:
|
||||
)
|
||||
job.metadata = job_metadata
|
||||
job = self.repository.update(job)
|
||||
logger.info(
|
||||
f"TTS 合成同步完成: job_id={job.id}, audio_url={audio_url}"
|
||||
)
|
||||
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={audio_url}")
|
||||
return job
|
||||
|
||||
job.metadata = job_metadata
|
||||
job = self.repository.update(job)
|
||||
logger.info(
|
||||
f"TTS 合成任务已提交: job_id={job.id}, "
|
||||
f"task_id={submit_result.get('task_id')}"
|
||||
)
|
||||
logger.info(f"TTS 合成任务已提交: job_id={job.id}, " f"task_id={submit_result.get('task_id')}")
|
||||
|
||||
except (CosyVoiceError, CosyVoiceAuthError) as e:
|
||||
job.mark_failed(str(e))
|
||||
@@ -126,9 +121,7 @@ class TTSWorkflowService:
|
||||
|
||||
return job
|
||||
|
||||
def poll_and_process_synthesis(
|
||||
self, job_id: str, timeout: float = 120.0
|
||||
) -> TTSJob:
|
||||
def poll_and_process_synthesis(self, job_id: str, timeout: float = 120.0) -> TTSJob:
|
||||
"""轮询 CosyVoice 合成任务并处理结果。
|
||||
|
||||
从 job.metadata 获取 task_id,调用 CosyVoiceService.poll_synthesize_task()
|
||||
@@ -142,9 +135,7 @@ class TTSWorkflowService:
|
||||
|
||||
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if not task_id:
|
||||
raise ValueError(
|
||||
f"TTSJob {job_id} has no cosyvoice_task_id in metadata"
|
||||
)
|
||||
raise ValueError(f"TTSJob {job_id} has no cosyvoice_task_id in metadata")
|
||||
|
||||
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout)
|
||||
return self.process_synthesis_result(
|
||||
@@ -189,9 +180,7 @@ class TTSWorkflowService:
|
||||
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={audio_url}")
|
||||
return job
|
||||
|
||||
def process_synthesis_failure(
|
||||
self, job_id: str, error_message: str
|
||||
) -> TTSJob:
|
||||
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
|
||||
"""处理合成失败结果。
|
||||
|
||||
Args:
|
||||
|
||||
@@ -68,9 +68,7 @@ class ListVoiceClonesUseCase:
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[List[VoiceCloneProfile], int]:
|
||||
items = self.repository.list_by_user(
|
||||
user_id, status=status, limit=limit, offset=skip
|
||||
)
|
||||
items = self.repository.list_by_user(user_id, status=status, limit=limit, offset=skip)
|
||||
total = self.repository.count_by_user(user_id, status=status)
|
||||
return items, total
|
||||
|
||||
@@ -125,8 +123,6 @@ class RetryVoiceCloneUseCase:
|
||||
if profile is None or profile.user_id != user_id:
|
||||
raise VoiceCloneNotFoundError(f"Voice clone {clone_id} not found")
|
||||
if not profile.is_retryable:
|
||||
raise VoiceCloneNotRetryableError(
|
||||
f"Voice clone {clone_id} is not retryable (status={profile.status})"
|
||||
)
|
||||
raise VoiceCloneNotRetryableError(f"Voice clone {clone_id} is not retryable (status={profile.status})")
|
||||
profile.prepare_retry()
|
||||
return self.repository.update(profile)
|
||||
|
||||
@@ -118,9 +118,7 @@ class VoiceCloneWorkflowService:
|
||||
# 4. 保存 task_id / voice_id 到 metadata
|
||||
task_metadata = dict(profile.metadata)
|
||||
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
|
||||
task_metadata["cosyvoice_request_id"] = submit_result.get(
|
||||
"request_id", ""
|
||||
)
|
||||
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
|
||||
|
||||
# 如果 CosyVoice 同步返回了 voice_id,直接标记 ready
|
||||
voice_id = submit_result.get("voice_id", "")
|
||||
@@ -128,17 +126,12 @@ class VoiceCloneWorkflowService:
|
||||
profile.mark_ready(voice_id)
|
||||
profile.metadata = task_metadata
|
||||
profile = self.repository.update(profile)
|
||||
logger.info(
|
||||
f"音色克隆同步完成: profile_id={profile.id}, voice_id={voice_id}"
|
||||
)
|
||||
logger.info(f"音色克隆同步完成: profile_id={profile.id}, voice_id={voice_id}")
|
||||
return profile
|
||||
|
||||
profile.metadata = task_metadata
|
||||
profile = self.repository.update(profile)
|
||||
logger.info(
|
||||
f"音色克隆任务已提交: profile_id={profile.id}, "
|
||||
f"task_id={submit_result.get('task_id')}"
|
||||
)
|
||||
logger.info(f"音色克隆任务已提交: profile_id={profile.id}, " f"task_id={submit_result.get('task_id')}")
|
||||
|
||||
except (CosyVoiceError, CosyVoiceAuthError) as e:
|
||||
# CosyVoice 提交失败,标记为 failed
|
||||
@@ -153,15 +146,11 @@ class VoiceCloneWorkflowService:
|
||||
return profile
|
||||
else:
|
||||
# 没有音频 URL,保持 pending 状态等待用户上传
|
||||
logger.info(
|
||||
f"音色克隆已创建但无音频URL,保持pending: profile_id={profile.id}"
|
||||
)
|
||||
logger.info(f"音色克隆已创建但无音频URL,保持pending: profile_id={profile.id}")
|
||||
|
||||
return profile
|
||||
|
||||
def poll_and_process_clone(
|
||||
self, profile_id: str, timeout: float = 300.0
|
||||
) -> VoiceCloneProfile:
|
||||
def poll_and_process_clone(self, profile_id: str, timeout: float = 300.0) -> VoiceCloneProfile:
|
||||
"""轮询 CosyVoice 克隆任务并处理结果。
|
||||
|
||||
从 profile.metadata 获取 task_id,调用 CosyVoiceService.poll_clone_task()
|
||||
@@ -174,9 +163,7 @@ class VoiceCloneWorkflowService:
|
||||
raise VoiceCloneNotFoundError(f"Voice clone {profile_id} not found")
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if not task_id:
|
||||
raise ValueError(
|
||||
f"VoiceCloneProfile {profile_id} has no cosyvoice_task_id in metadata"
|
||||
)
|
||||
raise ValueError(f"VoiceCloneProfile {profile_id} has no cosyvoice_task_id in metadata")
|
||||
result = self.cosyvoice_service.poll_clone_task(task_id, timeout=timeout)
|
||||
return self.process_clone_result(profile_id, result["voice_id"])
|
||||
|
||||
@@ -202,9 +189,7 @@ class VoiceCloneWorkflowService:
|
||||
logger.info(f"音色克隆成功: profile_id={profile_id}, voice_id={voice_id}")
|
||||
return profile
|
||||
|
||||
def process_clone_failure(
|
||||
self, profile_id: str, error_message: str
|
||||
) -> VoiceCloneProfile:
|
||||
def process_clone_failure(self, profile_id: str, error_message: str) -> VoiceCloneProfile:
|
||||
"""处理克隆失败结果。
|
||||
|
||||
Args:
|
||||
@@ -264,9 +249,7 @@ class VoiceCloneWorkflowService:
|
||||
|
||||
task_metadata = dict(profile.metadata)
|
||||
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
|
||||
task_metadata["cosyvoice_request_id"] = submit_result.get(
|
||||
"request_id", ""
|
||||
)
|
||||
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
|
||||
|
||||
voice_id = submit_result.get("voice_id", "")
|
||||
if voice_id:
|
||||
|
||||
@@ -28,7 +28,9 @@ class ListVoiceLibraryUseCase:
|
||||
) -> tuple[List[VoiceLibraryItem], int]:
|
||||
"""返回 (items, total_count),避免调用方再单独查一次 count。"""
|
||||
items = self.repository.list_by_user(user_id, status=status, skip=skip, limit=limit)
|
||||
total = self.repository.count_by_user(user_id, status=status) if status else self.repository.count_by_user(user_id)
|
||||
total = (
|
||||
self.repository.count_by_user(user_id, status=status) if status else self.repository.count_by_user(user_id)
|
||||
)
|
||||
return items, total
|
||||
|
||||
|
||||
|
||||
@@ -85,9 +85,7 @@ class EditPlanClip:
|
||||
plan_id=plan_id.strip(),
|
||||
clip_type=clip_type.strip(),
|
||||
order=order,
|
||||
template_clip_config_id=template_clip_config_id.strip()
|
||||
if template_clip_config_id
|
||||
else "",
|
||||
template_clip_config_id=template_clip_config_id.strip() if template_clip_config_id else "",
|
||||
asset_id=asset_id.strip() if asset_id else "",
|
||||
text_content=text_content.strip(),
|
||||
start_time=start_time,
|
||||
@@ -107,27 +105,21 @@ class EditPlanClip:
|
||||
def mark_ready(self) -> None:
|
||||
"""标记为就绪"""
|
||||
if self.status != EditPlanClipStatus.PENDING:
|
||||
raise ValueError(
|
||||
f"只有 pending 状态的片段可以标记就绪,当前状态: {self.status}"
|
||||
)
|
||||
raise ValueError(f"只有 pending 状态的片段可以标记就绪,当前状态: {self.status}")
|
||||
self.status = EditPlanClipStatus.READY
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_rendered(self) -> None:
|
||||
"""标记为已渲染"""
|
||||
if self.status != EditPlanClipStatus.READY:
|
||||
raise ValueError(
|
||||
f"只有 ready 状态的片段可以标记已渲染,当前状态: {self.status}"
|
||||
)
|
||||
raise ValueError(f"只有 ready 状态的片段可以标记已渲染,当前状态: {self.status}")
|
||||
self.status = EditPlanClipStatus.RENDERED
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_failed(self) -> None:
|
||||
"""标记为失败"""
|
||||
if self.status != EditPlanClipStatus.READY:
|
||||
raise ValueError(
|
||||
f"只有 ready 状态的片段可以标记失败,当前状态: {self.status}"
|
||||
)
|
||||
raise ValueError(f"只有 ready 状态的片段可以标记失败,当前状态: {self.status}")
|
||||
self.status = EditPlanClipStatus.FAILED
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ _HAS_PG = False
|
||||
try:
|
||||
if os.environ.get("USE_IN_MEMORY_DB", "").lower() != "true":
|
||||
import psycopg
|
||||
|
||||
conn = psycopg.connect(
|
||||
os.environ.get(
|
||||
"DATABASE_URL",
|
||||
|
||||
@@ -16,6 +16,7 @@ _HAS_PG = False
|
||||
try:
|
||||
if os.environ.get("USE_IN_MEMORY_DB", "").lower() != "true":
|
||||
import psycopg
|
||||
|
||||
conn = psycopg.connect(
|
||||
os.environ.get(
|
||||
"DATABASE_URL",
|
||||
|
||||
@@ -24,7 +24,6 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 安装 mock 模块(复用 test_duplication_upload_error_handling 的模式)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -183,7 +182,9 @@ def _install_mocks():
|
||||
sys.modules[name] = types.ModuleType(name)
|
||||
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.user_repository"].SQLAlchemyUserRepository = MagicMock
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.duplication_repository"].SQLAlchemyDuplicationRecordRepository = MagicMock
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.duplication_repository"].SQLAlchemyDuplicationRecordRepository = (
|
||||
MagicMock
|
||||
)
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.session"].build_session_factory = MagicMock(
|
||||
return_value=(MagicMock(), MagicMock())
|
||||
)
|
||||
@@ -535,6 +536,7 @@ def client(repo):
|
||||
|
||||
def _override_storage():
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
return OSSStorageService()
|
||||
|
||||
app.dependency_overrides[duplication.get_current_user] = _override_current_user
|
||||
|
||||
@@ -334,7 +334,9 @@ def _install_mocks():
|
||||
sys.modules.setdefault("app.schemas", types.ModuleType("app.schemas"))
|
||||
sys.modules["app.schemas"].duplication = dup_schemas_mod
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in tests/integration/test_duplication_upload_error_handling.py: {e}", exc_info=True)
|
||||
logger.warning(
|
||||
f"Operation failed in tests/integration/test_duplication_upload_error_handling.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return User, AuthenticatedUser
|
||||
|
||||
|
||||
@@ -201,9 +201,7 @@ class TestForbidden403:
|
||||
f"/api/v1/projects/{project_id}",
|
||||
headers=other_auth_headers,
|
||||
)
|
||||
assert response.status_code in [403, 404], (
|
||||
f"访问他人项目应返回 403 或 404,实际: {response.status_code}"
|
||||
)
|
||||
assert response.status_code in [403, 404], f"访问他人项目应返回 403 或 404,实际: {response.status_code}"
|
||||
|
||||
def test_delete_other_user_project(self, auth_headers, other_auth_headers):
|
||||
"""删除他人项目应返回 403 或 404。"""
|
||||
@@ -448,9 +446,7 @@ class TestLargeDataRequests:
|
||||
headers=auth_headers,
|
||||
)
|
||||
# 应返回 422(超过长度限制)或 400
|
||||
assert response.status_code in [400, 413, 422], (
|
||||
f"超长名称应被拒绝,实际: {response.status_code}"
|
||||
)
|
||||
assert response.status_code in [400, 413, 422], f"超长名称应被拒绝,实际: {response.status_code}"
|
||||
|
||||
def test_create_project_with_large_description(self, auth_headers):
|
||||
"""超大描述应能处理(或拒绝)。"""
|
||||
@@ -461,9 +457,7 @@ class TestLargeDataRequests:
|
||||
headers=auth_headers,
|
||||
)
|
||||
# 可能被接受或被拒绝,但不应 500
|
||||
assert response.status_code < 500, (
|
||||
f"超大描述不应导致 500,实际: {response.status_code}"
|
||||
)
|
||||
assert response.status_code < 500, f"超大描述不应导致 500,实际: {response.status_code}"
|
||||
|
||||
def test_register_with_oversized_payload(self):
|
||||
"""超大注册请求体应返回 413 或 422,而非 500。"""
|
||||
@@ -477,9 +471,7 @@ class TestLargeDataRequests:
|
||||
"/api/v1/auth/register",
|
||||
json=huge_payload,
|
||||
)
|
||||
assert response.status_code < 500, (
|
||||
f"超大请求体不应导致 500,实际: {response.status_code}"
|
||||
)
|
||||
assert response.status_code < 500, f"超大请求体不应导致 500,实际: {response.status_code}"
|
||||
|
||||
def test_rapid_sequential_requests(self, auth_headers):
|
||||
"""快速连续请求不应触发限流导致 500。"""
|
||||
@@ -489,9 +481,7 @@ class TestLargeDataRequests:
|
||||
statuses.append(resp.status_code)
|
||||
|
||||
# 所有请求应返回正常状态码(200 或限流 429),不应 500
|
||||
assert all(s < 500 for s in statuses), (
|
||||
f"快速连续请求不应产生 500,状态码: {statuses}"
|
||||
)
|
||||
assert all(s < 500 for s in statuses), f"快速连续请求不应产生 500,状态码: {statuses}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -16,7 +16,6 @@ import pytest
|
||||
|
||||
from app.services.auto_clip_service import AutoClipService, ClipAssignDetail
|
||||
|
||||
|
||||
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -297,7 +296,8 @@ class TestParseMaterialRequirements:
|
||||
|
||||
def test_extracts_file_type(self) -> None:
|
||||
config = _StubConfig(
|
||||
id="c1", template_id="t1",
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
material_requirements={"type": "video"},
|
||||
)
|
||||
result = AutoClipService._parse_material_requirements(config)
|
||||
@@ -305,7 +305,8 @@ class TestParseMaterialRequirements:
|
||||
|
||||
def test_extracts_min_quality(self) -> None:
|
||||
config = _StubConfig(
|
||||
id="c1", template_id="t1",
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
material_requirements={"min_quality_score": 60},
|
||||
)
|
||||
result = AutoClipService._parse_material_requirements(config)
|
||||
@@ -313,7 +314,8 @@ class TestParseMaterialRequirements:
|
||||
|
||||
def test_extracts_category(self) -> None:
|
||||
config = _StubConfig(
|
||||
id="c1", template_id="t1",
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
material_requirements={"category": "scenic"},
|
||||
)
|
||||
result = AutoClipService._parse_material_requirements(config)
|
||||
@@ -321,7 +323,8 @@ class TestParseMaterialRequirements:
|
||||
|
||||
def test_invalid_category_ignored(self) -> None:
|
||||
config = _StubConfig(
|
||||
id="c1", template_id="t1",
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
material_requirements={"category": "nonexistent"},
|
||||
)
|
||||
result = AutoClipService._parse_material_requirements(config)
|
||||
@@ -329,8 +332,10 @@ class TestParseMaterialRequirements:
|
||||
|
||||
def test_duration_range(self) -> None:
|
||||
config = _StubConfig(
|
||||
id="c1", template_id="t1",
|
||||
min_duration=5.0, max_duration=15.0,
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
min_duration=5.0,
|
||||
max_duration=15.0,
|
||||
material_requirements={},
|
||||
)
|
||||
result = AutoClipService._parse_material_requirements(config)
|
||||
@@ -340,7 +345,8 @@ class TestParseMaterialRequirements:
|
||||
|
||||
def test_tags_extracted(self) -> None:
|
||||
config = _StubConfig(
|
||||
id="c1", template_id="t1",
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
material_requirements={"tags": ["outdoor", "sunset"]},
|
||||
)
|
||||
result = AutoClipService._parse_material_requirements(config)
|
||||
|
||||
@@ -223,9 +223,7 @@ class TestCloneVoice:
|
||||
def test_clone_client_error_no_retry(self) -> None:
|
||||
"""客户端错误(400)不重试。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
mock_client.request.return_value = _mock_response(
|
||||
status_code=400, text="Bad Request"
|
||||
)
|
||||
mock_client.request.return_value = _mock_response(status_code=400, text="Bad Request")
|
||||
|
||||
service = _make_service(http_client=mock_client)
|
||||
|
||||
@@ -265,9 +263,7 @@ class TestCloneVoice:
|
||||
def test_clone_with_voice_name(self) -> None:
|
||||
"""带 voice_name 参数。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
mock_client.request.return_value = _mock_response(
|
||||
json_data={"output": {"voice_id": "v-001"}}
|
||||
)
|
||||
mock_client.request.return_value = _mock_response(json_data={"output": {"voice_id": "v-001"}})
|
||||
|
||||
service = _make_service(http_client=mock_client)
|
||||
service.clone_voice(
|
||||
@@ -282,9 +278,7 @@ class TestCloneVoice:
|
||||
def test_clone_no_task_id_or_voice_id_raises(self) -> None:
|
||||
"""API 返回无效响应(无 task_id 也无 voice_id)。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
mock_client.request.return_value = _mock_response(
|
||||
json_data={"output": {}}
|
||||
)
|
||||
mock_client.request.return_value = _mock_response(json_data={"output": {}})
|
||||
|
||||
service = _make_service(http_client=mock_client)
|
||||
|
||||
@@ -355,9 +349,7 @@ class TestSubmitCloneTask:
|
||||
def test_submit_no_task_id_or_voice_id_raises(self) -> None:
|
||||
"""API 返回无效响应时抛出 CosyVoiceError。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
mock_client.request.return_value = _mock_response(
|
||||
json_data={"output": {}}
|
||||
)
|
||||
mock_client.request.return_value = _mock_response(json_data={"output": {}})
|
||||
|
||||
service = _make_service(http_client=mock_client)
|
||||
|
||||
@@ -367,9 +359,7 @@ class TestSubmitCloneTask:
|
||||
def test_submit_with_voice_name_in_payload(self) -> None:
|
||||
"""voice_name 参数包含在请求体中。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
mock_client.request.return_value = _mock_response(
|
||||
json_data={"output": {"task_id": "task-001"}}
|
||||
)
|
||||
mock_client.request.return_value = _mock_response(json_data={"output": {"task_id": "task-001"}})
|
||||
|
||||
service = _make_service(http_client=mock_client)
|
||||
service.submit_clone_task(
|
||||
@@ -452,9 +442,7 @@ class TestCheckTaskStatus:
|
||||
def test_check_uses_correct_path(self) -> None:
|
||||
"""请求路径包含 task_id。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
mock_client.request.return_value = _mock_response(
|
||||
json_data={"output": {"task_status": "PENDING"}}
|
||||
)
|
||||
mock_client.request.return_value = _mock_response(json_data={"output": {"task_status": "PENDING"}})
|
||||
|
||||
service = _make_service(http_client=mock_client)
|
||||
service.check_task_status("task-xyz-123")
|
||||
@@ -533,9 +521,7 @@ class TestSynthesizeSpeech:
|
||||
"""异步合成任务失败。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
|
||||
submit_response = _mock_response(
|
||||
json_data={"output": {"task_id": "task-tts-fail"}}
|
||||
)
|
||||
submit_response = _mock_response(json_data={"output": {"task_id": "task-tts-fail"}})
|
||||
failed_response = _mock_response(
|
||||
json_data={
|
||||
"output": {
|
||||
@@ -603,9 +589,7 @@ class TestSynthesizeSpeech:
|
||||
def test_synthesize_no_url_or_task_id_raises(self) -> None:
|
||||
"""API 返回无效响应(无 audio_url 也无 task_id)。"""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
mock_client.request.return_value = _mock_response(
|
||||
json_data={"output": {}}
|
||||
)
|
||||
mock_client.request.return_value = _mock_response(json_data={"output": {}})
|
||||
|
||||
service = _make_service(http_client=mock_client)
|
||||
|
||||
@@ -652,9 +636,7 @@ class TestRetryLogic:
|
||||
# 第一次:服务端错误
|
||||
error_response = _mock_response(status_code=500)
|
||||
# 第二次:成功
|
||||
success_response = _mock_response(
|
||||
json_data={"output": {"voice_id": "v-retry-ok"}}
|
||||
)
|
||||
success_response = _mock_response(json_data={"output": {"voice_id": "v-retry-ok"}})
|
||||
|
||||
mock_client.request.side_effect = [error_response, success_response]
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ _mock_if_absent("packages.adapters.sqlalchemy_impl.generated_video_repository")
|
||||
_HAS_CV2 = False
|
||||
try:
|
||||
import cv2 as _cv2
|
||||
|
||||
if not isinstance(_cv2, MagicMock):
|
||||
_HAS_CV2 = True
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
@@ -227,6 +228,7 @@ class TestVideoDeduplicatorCheckDuplicate:
|
||||
def _patch_repo(self, mock_repo):
|
||||
"""Patch SQLAlchemyGeneratedVideoRepository。"""
|
||||
import apps.worker.video_processing.dedup as dedup_module
|
||||
|
||||
original = dedup_module.SQLAlchemyGeneratedVideoRepository
|
||||
dedup_module.SQLAlchemyGeneratedVideoRepository = MagicMock(return_value=mock_repo)
|
||||
return original, dedup_module
|
||||
@@ -426,7 +428,8 @@ class TestVideoDeduplicatorCheckDuplicate:
|
||||
"""多帧 phash 使用平均最小距离。"""
|
||||
# 已有视频有 2 帧 phash
|
||||
existing = self._make_existing_video(
|
||||
"vid-1", "md5_a",
|
||||
"vid-1",
|
||||
"md5_a",
|
||||
phashes=["0000000000000000", "ffffffffffffffff"],
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
|
||||
@@ -272,4 +272,3 @@ class TestDuplicateSegmentCreate:
|
||||
matched_end=5.0,
|
||||
similarity=-1.0,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
|
||||
# ── Stub Repositories ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repositories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,7 +29,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repositories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -30,7 +30,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
|
||||
# ── Stub Repository ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -63,9 +62,7 @@ class StubEditTemplateRepository:
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditTemplate]:
|
||||
return self.list_all(
|
||||
template_type=template_type, status=EditTemplateStatus.ACTIVE, skip=skip, limit=limit
|
||||
)
|
||||
return self.list_all(template_type=template_type, status=EditTemplateStatus.ACTIVE, skip=skip, limit=limit)
|
||||
|
||||
def get(self, template_id: str) -> Optional[EditTemplate]:
|
||||
return self._store.get(template_id)
|
||||
@@ -339,9 +336,7 @@ class TestUpdateTemplate:
|
||||
resp = client.put("/api/v1/edit-templates/nonexistent", json={"name": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_update_preserves_others(
|
||||
self, client: TestClient, stub_repo: StubEditTemplateRepository
|
||||
) -> None:
|
||||
def test_partial_update_preserves_others(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||||
t = _make_template("原名", description="原描述", template_type="vlog")
|
||||
stub_repo.create(t)
|
||||
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名"})
|
||||
@@ -377,9 +372,7 @@ class TestDeleteTemplate:
|
||||
resp = client.delete("/api/v1/edit-templates/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_soft_delete_idempotent(
|
||||
self, client: TestClient, stub_repo: StubEditTemplateRepository
|
||||
) -> None:
|
||||
def test_soft_delete_idempotent(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||||
t = _make_template("模板")
|
||||
stub_repo.create(t)
|
||||
# 第一次删除
|
||||
@@ -389,9 +382,7 @@ class TestDeleteTemplate:
|
||||
resp2 = client.delete(f"/api/v1/edit-templates/{t.id}")
|
||||
assert resp2.status_code == 204
|
||||
|
||||
def test_deleted_not_in_active_list(
|
||||
self, client: TestClient, stub_repo: StubEditTemplateRepository
|
||||
) -> None:
|
||||
def test_deleted_not_in_active_list(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||||
t = _make_template("模板")
|
||||
stub_repo.create(t)
|
||||
client.delete(f"/api/v1/edit-templates/{t.id}")
|
||||
@@ -404,17 +395,22 @@ class TestDeleteTemplate:
|
||||
|
||||
|
||||
class TestResponseSchema:
|
||||
def test_response_has_all_fields(
|
||||
self, client: TestClient, stub_repo: StubEditTemplateRepository
|
||||
) -> None:
|
||||
def test_response_has_all_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||||
t = _make_template("模板", description="描述", template_type="vlog")
|
||||
stub_repo.create(t)
|
||||
resp = client.get(f"/api/v1/edit-templates/{t.id}")
|
||||
data = resp.json()
|
||||
expected_keys = {
|
||||
"id", "name", "description", "template_type",
|
||||
"config", "preview_url", "sort_weight", "status",
|
||||
"created_at", "updated_at",
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"template_type",
|
||||
"config",
|
||||
"preview_url",
|
||||
"sort_weight",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert set(data.keys()) == expected_keys
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ from packages.application.jobs import (
|
||||
UpdateJobProgressUseCase,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -59,7 +58,8 @@ class FakeJobRepo:
|
||||
|
||||
def list_by_project(self, project_id, *, job_type=None, status=None, limit=50, offset=0):
|
||||
results = [
|
||||
j for j in self._store.values()
|
||||
j
|
||||
for j in self._store.values()
|
||||
if j.project_id == project_id
|
||||
and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type))
|
||||
and (status is None or j.status == status or j.status == JobStatus(status))
|
||||
@@ -68,7 +68,8 @@ class FakeJobRepo:
|
||||
|
||||
def list_by_user(self, user_id, *, job_type=None, status=None, limit=50, offset=0):
|
||||
results = [
|
||||
j for j in self._store.values()
|
||||
j
|
||||
for j in self._store.values()
|
||||
if j.created_by_user_id == user_id
|
||||
and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type))
|
||||
and (status is None or j.status == status or j.status == JobStatus(status))
|
||||
@@ -76,16 +77,23 @@ class FakeJobRepo:
|
||||
return results[offset : offset + limit]
|
||||
|
||||
def count_by_project(self, project_id, *, status=None):
|
||||
return len([
|
||||
j for j in self._store.values()
|
||||
if j.project_id == project_id
|
||||
and (status is None or j.status == status or j.status == JobStatus(status))
|
||||
])
|
||||
return len(
|
||||
[
|
||||
j
|
||||
for j in self._store.values()
|
||||
if j.project_id == project_id
|
||||
and (status is None or j.status == status or j.status == JobStatus(status))
|
||||
]
|
||||
)
|
||||
|
||||
def find_active_by_source(self, source_id, job_type):
|
||||
jt = job_type.value if isinstance(job_type, JobType) else job_type
|
||||
for j in self._store.values():
|
||||
if j.source_id == source_id and j.job_type.value == jt and j.status in (JobStatus.PENDING, JobStatus.RUNNING):
|
||||
if (
|
||||
j.source_id == source_id
|
||||
and j.job_type.value == jt
|
||||
and j.status in (JobStatus.PENDING, JobStatus.RUNNING)
|
||||
):
|
||||
return j
|
||||
return None
|
||||
|
||||
@@ -289,9 +297,7 @@ class TestUpdateJobProgressUseCase:
|
||||
repo.update(job)
|
||||
|
||||
progress_uc = UpdateJobProgressUseCase(repo)
|
||||
updated = progress_uc.execute(
|
||||
UpdateJobProgressCommand(job_id=job.id, progress=75.0, current_stage="渲染中")
|
||||
)
|
||||
updated = progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=75.0, current_stage="渲染中"))
|
||||
assert updated.progress == 75.0
|
||||
assert updated.current_stage == "渲染中"
|
||||
|
||||
@@ -301,9 +307,7 @@ class TestUpdateJobProgressUseCase:
|
||||
|
||||
progress_uc = UpdateJobProgressUseCase(repo)
|
||||
with pytest.raises(ValueError, match="只有 running 状态"):
|
||||
progress_uc.execute(
|
||||
UpdateJobProgressCommand(job_id=job.id, progress=50.0)
|
||||
)
|
||||
progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=50.0))
|
||||
|
||||
|
||||
class TestCompleteJobUseCase:
|
||||
@@ -314,9 +318,7 @@ class TestCompleteJobUseCase:
|
||||
repo.update(job)
|
||||
|
||||
complete_uc = CompleteJobUseCase(repo)
|
||||
completed = complete_uc.execute(
|
||||
CompleteJobCommand(job_id=job.id, result={"url": "https://example.com/v.mp4"})
|
||||
)
|
||||
completed = complete_uc.execute(CompleteJobCommand(job_id=job.id, result={"url": "https://example.com/v.mp4"}))
|
||||
assert completed.status == JobStatus.SUCCESS
|
||||
assert completed.progress == 100.0
|
||||
assert completed.result == {"url": "https://example.com/v.mp4"}
|
||||
@@ -438,8 +440,12 @@ class TestListJobsUseCase:
|
||||
|
||||
def test_list_by_user(self, repo):
|
||||
create_uc = CreateJobUseCase(repo)
|
||||
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-1"))
|
||||
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-2"))
|
||||
create_uc.execute(
|
||||
CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-1")
|
||||
)
|
||||
create_uc.execute(
|
||||
CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-2")
|
||||
)
|
||||
|
||||
list_uc = ListJobsUseCase(repo)
|
||||
jobs = list_uc.execute(user_id="user-1")
|
||||
|
||||
@@ -12,7 +12,6 @@ from packages.domain.edit_plan_clip import (
|
||||
EditPlanClipStatus,
|
||||
)
|
||||
|
||||
|
||||
# ── TemplateClipConfig 领域实体测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -81,16 +80,12 @@ class TestTemplateClipConfig:
|
||||
def test_create_negative_min_duration_raises(self):
|
||||
"""负数 min_duration 报错"""
|
||||
with pytest.raises(ValueError, match="min_duration 不能为负数"):
|
||||
TemplateClipConfig.create(
|
||||
template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=-1.0
|
||||
)
|
||||
TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=-1.0)
|
||||
|
||||
def test_create_negative_max_duration_raises(self):
|
||||
"""负数 max_duration 报错"""
|
||||
with pytest.raises(ValueError, match="max_duration 不能为负数"):
|
||||
TemplateClipConfig.create(
|
||||
template_id="tpl_001", clip_type=ClipType.INTRO, order=0, max_duration=-1.0
|
||||
)
|
||||
TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, max_duration=-1.0)
|
||||
|
||||
def test_create_min_greater_than_max_raises(self):
|
||||
"""min_duration > max_duration 报错"""
|
||||
@@ -105,9 +100,7 @@ class TestTemplateClipConfig:
|
||||
|
||||
def test_has_duration_range(self):
|
||||
"""has_duration_range 属性"""
|
||||
config_no_range = TemplateClipConfig.create(
|
||||
template_id="tpl_001", clip_type=ClipType.INTRO, order=0
|
||||
)
|
||||
config_no_range = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
|
||||
assert config_no_range.has_duration_range is False
|
||||
|
||||
config_with_range = TemplateClipConfig.create(
|
||||
@@ -118,9 +111,7 @@ class TestTemplateClipConfig:
|
||||
def test_default_duration(self):
|
||||
"""default_duration 属性"""
|
||||
# 无时长范围
|
||||
config_no_range = TemplateClipConfig.create(
|
||||
template_id="tpl_001", clip_type=ClipType.INTRO, order=0
|
||||
)
|
||||
config_no_range = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
|
||||
assert config_no_range.default_duration == 0.0
|
||||
|
||||
# 只有 min
|
||||
@@ -161,9 +152,7 @@ class TestTemplateClipConfig:
|
||||
|
||||
def test_timestamps_auto_set(self):
|
||||
"""创建时自动设置时间戳"""
|
||||
config = TemplateClipConfig.create(
|
||||
template_id="tpl_001", clip_type=ClipType.INTRO, order=0
|
||||
)
|
||||
config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
|
||||
assert config.created_at is not None
|
||||
assert config.updated_at is not None
|
||||
|
||||
@@ -284,9 +273,7 @@ class TestEditPlanClip:
|
||||
|
||||
def test_end_time_property(self):
|
||||
"""end_time 属性"""
|
||||
clip = EditPlanClip.create(
|
||||
plan_id="plan_001", clip_type="main", order=0, start_time=5.0, duration=10.0
|
||||
)
|
||||
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, start_time=5.0, duration=10.0)
|
||||
assert clip.end_time == 15.0
|
||||
|
||||
def test_has_asset_property(self):
|
||||
@@ -294,9 +281,7 @@ class TestEditPlanClip:
|
||||
clip_no_asset = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
|
||||
assert clip_no_asset.has_asset is False
|
||||
|
||||
clip_with_asset = EditPlanClip.create(
|
||||
plan_id="plan_001", clip_type="main", order=0, asset_id="asset_001"
|
||||
)
|
||||
clip_with_asset = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, asset_id="asset_001")
|
||||
assert clip_with_asset.has_asset is True
|
||||
|
||||
def test_edit_plan_clip_status_enum(self):
|
||||
@@ -338,9 +323,7 @@ class TestTemplateClipConfigRepository:
|
||||
def test_create_and_get(self, db_session):
|
||||
"""创建并获取"""
|
||||
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
|
||||
config = TemplateClipConfig.create(
|
||||
template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=2.0
|
||||
)
|
||||
config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=2.0)
|
||||
created = repo.create(config)
|
||||
assert created.id == config.id
|
||||
|
||||
@@ -354,14 +337,8 @@ class TestTemplateClipConfigRepository:
|
||||
"""按模板列出"""
|
||||
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
|
||||
for i in range(3):
|
||||
repo.create(
|
||||
TemplateClipConfig.create(
|
||||
template_id="tpl_001", clip_type=ClipType.MAIN, order=i
|
||||
)
|
||||
)
|
||||
repo.create(
|
||||
TemplateClipConfig.create(template_id="tpl_002", clip_type=ClipType.INTRO, order=0)
|
||||
)
|
||||
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i))
|
||||
repo.create(TemplateClipConfig.create(template_id="tpl_002", clip_type=ClipType.INTRO, order=0))
|
||||
|
||||
results = repo.list_by_template("tpl_001")
|
||||
assert len(results) == 3
|
||||
@@ -409,9 +386,7 @@ class TestTemplateClipConfigRepository:
|
||||
"""按模板批量删除"""
|
||||
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
|
||||
for i in range(3):
|
||||
repo.create(
|
||||
TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i)
|
||||
)
|
||||
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i))
|
||||
deleted = repo.delete_by_template("tpl_001")
|
||||
assert deleted == 3
|
||||
assert repo.count(template_id="tpl_001") == 0
|
||||
@@ -433,9 +408,7 @@ class TestEditPlanClipRepository:
|
||||
def test_create_and_get(self, db_session):
|
||||
"""创建并获取"""
|
||||
repo = SQLAlchemyEditPlanClipRepository(db_session)
|
||||
clip = EditPlanClip.create(
|
||||
plan_id="plan_001", clip_type="main", order=0, duration=5.0
|
||||
)
|
||||
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, duration=5.0)
|
||||
created = repo.create(clip)
|
||||
assert created.id == clip.id
|
||||
|
||||
@@ -450,12 +423,8 @@ class TestEditPlanClipRepository:
|
||||
"""按计划列出"""
|
||||
repo = SQLAlchemyEditPlanClipRepository(db_session)
|
||||
for i in range(3):
|
||||
repo.create(
|
||||
EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i)
|
||||
)
|
||||
repo.create(
|
||||
EditPlanClip.create(plan_id="plan_002", clip_type="intro", order=0)
|
||||
)
|
||||
repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i))
|
||||
repo.create(EditPlanClip.create(plan_id="plan_002", clip_type="intro", order=0))
|
||||
|
||||
results = repo.list_by_plan("plan_001")
|
||||
assert len(results) == 3
|
||||
@@ -504,9 +473,7 @@ class TestEditPlanClipRepository:
|
||||
"""按计划批量删除"""
|
||||
repo = SQLAlchemyEditPlanClipRepository(db_session)
|
||||
for i in range(3):
|
||||
repo.create(
|
||||
EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i)
|
||||
)
|
||||
repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i))
|
||||
deleted = repo.delete_by_plan("plan_001")
|
||||
assert deleted == 3
|
||||
assert repo.count(plan_id="plan_001") == 0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user