feat(phase3): 下线/edit-plans/*旧路由 + 清理废弃代码 + 数据库冗余字段清理 #657
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""Phase 3 - 清理 EditPlan 表冗余字段
|
||||
|
||||
Revision ID: 048
|
||||
Revises: 047
|
||||
Create Date: 2026-07-21
|
||||
|
||||
Changes:
|
||||
1. 删除 edit_plans.result_count 字段(剪辑计划独立功能遗留,模板草稿不用,
|
||||
生成结果数由 generation_tasks.result_count 承载)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "048_cleanup_result_count"
|
||||
down_revision = "047_template_versioning"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 删除 result_count 字段(剪辑计划独立功能遗留字段)
|
||||
op.drop_column("edit_plans", "result_count")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚:恢复 result_count 字段,默认值 0
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column(
|
||||
"result_count",
|
||||
sa.Integer,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
@@ -5,7 +5,6 @@ from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
@@ -125,11 +124,6 @@ api_router.include_router(
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
)
|
||||
api_router.include_router(
|
||||
edit_plans_router,
|
||||
prefix="/edit-plans",
|
||||
tags=["EditPlan"],
|
||||
)
|
||||
api_router.include_router(
|
||||
tts_router,
|
||||
prefix="/tts",
|
||||
|
||||
@@ -139,26 +139,3 @@ def format_utc_datetime(dt: datetime | None) -> str:
|
||||
if dt.tzinfo is None:
|
||||
return dt.isoformat() + "Z"
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
# ── Deprecated API 标记 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
import logging as _logging
|
||||
|
||||
from fastapi import Request as _Request
|
||||
|
||||
_deprecated_logger = _logging.getLogger(__name__)
|
||||
|
||||
|
||||
def deprecated_edit_plans_api(request: _Request) -> None:
|
||||
"""标记 /edit-plans/* 系列 API 为废弃,打 warning 日志。
|
||||
|
||||
Phase 2 模板编辑器收敛后,所有剪辑计划 API 迁移到 /templates/{id}/editor/*。
|
||||
旧路径保留 2 个版本周期兼容,之后会下线。
|
||||
"""
|
||||
_deprecated_logger.warning(
|
||||
"Deprecated API called: %s %s. Use /templates/{template_id}/editor/* instead.",
|
||||
request.method,
|
||||
request.url.path,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
"""片段调整 API.
|
||||
|
||||
- PUT /clips/{clip_id}/speed 调速
|
||||
- PUT /clips/{clip_id}/volume 音量调节
|
||||
- PUT /clips/{clip_id}/trim 裁剪(trim in/out)
|
||||
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim)
|
||||
- POST /{plan_id}/clips/batch-speed 批量调速
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SpeedAdjustRequest(BaseModel):
|
||||
"""调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
|
||||
|
||||
|
||||
class VolumeAdjustRequest(BaseModel):
|
||||
"""音量调节请求"""
|
||||
|
||||
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
|
||||
|
||||
|
||||
class TrimAdjustRequest(BaseModel):
|
||||
"""裁剪请求"""
|
||||
|
||||
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
|
||||
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
|
||||
|
||||
|
||||
class ClipAdjustmentsRequest(BaseModel):
|
||||
"""统一调整请求"""
|
||||
|
||||
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
trim_start: Optional[float] = Field(default=None, ge=0.0)
|
||||
trim_end: Optional[float] = Field(default=None, ge=0.0)
|
||||
|
||||
|
||||
class BatchSpeedRequest(BaseModel):
|
||||
"""批量调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
|
||||
|
||||
|
||||
class ClipAdjustResponse(BaseModel):
|
||||
"""片段调整响应"""
|
||||
|
||||
clip_id: str
|
||||
speed: float
|
||||
volume: float
|
||||
trim_start: float
|
||||
trim_end: float
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchSpeedResponse(BaseModel):
|
||||
"""批量调速响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_clip_config(clip) -> dict:
|
||||
config = getattr(clip, "config", {}) or {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
return config
|
||||
|
||||
|
||||
def _get_volume(clip) -> float:
|
||||
config = _get_clip_config(clip)
|
||||
return float(config.get("volume", 1.0))
|
||||
|
||||
|
||||
def _get_trim(clip) -> tuple[float, float]:
|
||||
config = _get_clip_config(clip)
|
||||
trim_start = float(config.get("trim_start", 0.0))
|
||||
trim_end = float(config.get("trim_end", 0.0))
|
||||
return trim_start, trim_end
|
||||
|
||||
|
||||
def _build_response(clip) -> ClipAdjustResponse:
|
||||
trim_start, trim_end = _get_trim(clip)
|
||||
return ClipAdjustResponse(
|
||||
clip_id=clip.id,
|
||||
speed=clip.playback_speed,
|
||||
volume=_get_volume(clip),
|
||||
trim_start=trim_start,
|
||||
trim_end=trim_end,
|
||||
duration=clip.duration,
|
||||
)
|
||||
|
||||
|
||||
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
|
||||
"""验证裁剪时长不超过总时长"""
|
||||
if trim_start + trim_end >= total_duration:
|
||||
raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
|
||||
svc = EditPlanService(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = svc.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
return svc, plan, clip
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_speed(
|
||||
clip_id: str,
|
||||
body: SpeedAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段播放速度"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
updated = svc.update_clip(clip_id, playback_speed=body.speed)
|
||||
|
||||
logger.info(
|
||||
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_volume(
|
||||
clip_id: str,
|
||||
body: VolumeAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段音量"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 更新 config.volume
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["volume"] = body.volume
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_trim(
|
||||
clip_id: str,
|
||||
body: TrimAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""裁剪片段(trim in/out)"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 验证裁剪时长
|
||||
try:
|
||||
_validate_trim(body.trim_start, body.trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 更新 config
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["trim_start"] = body.trim_start
|
||||
config["trim_end"] = body.trim_end
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.trim_start,
|
||||
body.trim_end,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_all(
|
||||
clip_id: str,
|
||||
body: ClipAdjustmentsRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""统一调整片段的 speed / volume / trim"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
update_kwargs = {}
|
||||
config_updates = {}
|
||||
|
||||
if body.speed is not None:
|
||||
update_kwargs["playback_speed"] = body.speed
|
||||
|
||||
if body.volume is not None:
|
||||
config_updates["volume"] = body.volume
|
||||
|
||||
if body.trim_start is not None:
|
||||
config_updates["trim_start"] = body.trim_start
|
||||
|
||||
if body.trim_end is not None:
|
||||
config_updates["trim_end"] = body.trim_end
|
||||
|
||||
# 验证 trim
|
||||
current_trim_start, current_trim_end = _get_trim(clip)
|
||||
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
|
||||
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
|
||||
|
||||
if body.trim_start is not None or body.trim_end is not None:
|
||||
try:
|
||||
_validate_trim(new_trim_start, new_trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
if config_updates:
|
||||
config = dict(_get_clip_config(clip))
|
||||
config.update(config_updates)
|
||||
update_kwargs["config"] = config
|
||||
|
||||
if not update_kwargs:
|
||||
return _build_response(clip)
|
||||
|
||||
updated = svc.update_clip(clip_id, **update_kwargs)
|
||||
|
||||
logger.info(
|
||||
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse, deprecated=True)
|
||||
def batch_adjust_speed(
|
||||
plan_id: str,
|
||||
body: BatchSpeedRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchSpeedResponse:
|
||||
"""批量调整计划内所有片段的播放速度"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id, limit=500, skip=0)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
svc.update_clip(clip.id, playback_speed=body.speed)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
|
||||
plan_id,
|
||||
count,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
|
||||
@@ -1,205 +0,0 @@
|
||||
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
|
||||
|
||||
从 edit_plans.py 拆分,包含:
|
||||
- POST /{plan_id}/ai-recommend AI 推荐片段方案
|
||||
- POST /{plan_id}/generate-cover AI 生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.api.routes.edit_plans import (
|
||||
AIRecommendClipItem,
|
||||
AIRecommendRequest,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/ai-recommend",
|
||||
response_model=AIRecommendResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def ai_recommend_clips(
|
||||
plan_id: str,
|
||||
body: AIRecommendRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AIRecommendResponse:
|
||||
"""AI 推荐片段方案
|
||||
|
||||
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
|
||||
|
||||
流程:
|
||||
1. 验证计划存在且状态为 draft/editing
|
||||
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
|
||||
3. 清除计划现有片段,按推荐方案重新创建
|
||||
4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
||||
5. 返回推荐方案详情
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
try:
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status not in ("draft", "editing"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
||||
)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
asset_ids=body.asset_ids,
|
||||
editing_mode=body.editing_mode,
|
||||
target_duration=body.target_duration,
|
||||
)
|
||||
|
||||
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
|
||||
try:
|
||||
svc.delete_all_clips(plan_id)
|
||||
|
||||
for clip_data in result["clips"]:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_data["clip_type"],
|
||||
order=clip_data["order"],
|
||||
text_content=clip_data.get("text_content", ""),
|
||||
duration=clip_data["duration"],
|
||||
transition_effect=clip_data.get("transition_effect", "cut"),
|
||||
asset_id=clip_data.get("asset_id", ""),
|
||||
start_time=clip_data.get("start_time", 0.0),
|
||||
config=clip_data.get("config", {}),
|
||||
)
|
||||
|
||||
normalized_config = normalize_plan_config(result.get("config", {}))
|
||||
svc.update_plan(
|
||||
plan_id,
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception as rollback_err:
|
||||
logger.error(
|
||||
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
|
||||
plan_id,
|
||||
rollback_err,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI推荐结果保存失败,请稍后重试",
|
||||
) from _e
|
||||
|
||||
logger.info(
|
||||
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
||||
plan_id,
|
||||
len(result["clips"]),
|
||||
result["total_duration"],
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return AIRecommendResponse(
|
||||
plan_id=plan_id,
|
||||
clips=[
|
||||
AIRecommendClipItem(
|
||||
clip_type=c["clip_type"],
|
||||
order=c["order"],
|
||||
text_content=c.get("text_content", ""),
|
||||
duration=c["duration"],
|
||||
transition_effect=c.get("transition_effect", "cut"),
|
||||
asset_id=c.get("asset_id", ""),
|
||||
start_time=c.get("start_time", 0.0),
|
||||
config=c.get("config", {}),
|
||||
)
|
||||
for c in result["clips"]
|
||||
],
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
confidence=result["confidence"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/generate-cover",
|
||||
response_model=GenerateCoverResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def generate_cover(
|
||||
plan_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面
|
||||
|
||||
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
try:
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
|
||||
current_config = dict(plan.config)
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"AI 封面生成: plan_id=%s type=%s by user=%s",
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(
|
||||
plan_id=plan_id,
|
||||
cover=cover_data,
|
||||
)
|
||||
@@ -1,423 +0,0 @@
|
||||
"""剪辑计划片段(Clip)CRUD 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EditPlanClipResponse(BaseModel):
|
||||
"""剪辑片段响应体"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
asset_id: str = ""
|
||||
text_content: str = ""
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
status: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class EditPlanClipListResponse(BaseModel):
|
||||
"""剪辑片段列表响应体"""
|
||||
|
||||
items: List[EditPlanClipResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class EditPlanClipCreateRequest(BaseModel):
|
||||
"""创建剪辑片段请求体"""
|
||||
|
||||
clip_type: str = Field(
|
||||
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
|
||||
)
|
||||
order: int = Field(..., ge=0, description="排序序号")
|
||||
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
|
||||
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
|
||||
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
|
||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
class EditPlanClipUpdateRequest(BaseModel):
|
||||
"""更新剪辑片段请求体"""
|
||||
|
||||
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
|
||||
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
|
||||
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
|
||||
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
|
||||
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
|
||||
返回 plan 对象供后续使用,避免重复查询。
|
||||
"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, user_id, project_repository)
|
||||
return plan
|
||||
|
||||
|
||||
def _clip_to_response(clip) -> EditPlanClipResponse:
|
||||
"""将领域对象转换为响应体"""
|
||||
return EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id or "",
|
||||
text_content=clip.text_content or "",
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=clip.transition_duration or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
|
||||
config=clip.config or {},
|
||||
created_at=clip.created_at.isoformat() if clip.created_at else None,
|
||||
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
def _get_svc(db: Session):
|
||||
"""获取 EditPlanService 实例"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
return EditPlanService(db)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=EditPlanClipListResponse, deprecated=True)
|
||||
def list_clips(
|
||||
plan_id: str,
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
|
||||
skip: int = Query(0, ge=0, description="分页偏移"),
|
||||
limit: int = Query(100, ge=1, le=500, description="每页数量"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipListResponse:
|
||||
"""获取剪辑计划的片段列表"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
|
||||
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
|
||||
total = svc.count_clips(plan_id, status=status_enum)
|
||||
|
||||
return EditPlanClipListResponse(
|
||||
items=[_clip_to_response(c) for c in clips],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED, deprecated=True)
|
||||
def create_clip(
|
||||
plan_id: str,
|
||||
body: EditPlanClipCreateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""创建剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
try:
|
||||
clip = svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.get("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
|
||||
def get_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""获取剪辑片段详情"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.put("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
|
||||
def update_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: EditPlanClipUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""更新剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
try:
|
||||
updated = svc.update_clip(
|
||||
clip_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return _clip_to_response(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True
|
||||
)
|
||||
def delete_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
deleted = svc.delete_clip(clip_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return None
|
||||
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SplitClipRequest(BaseModel):
|
||||
"""分割片段请求体"""
|
||||
|
||||
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
|
||||
|
||||
|
||||
class MergeClipsRequest(BaseModel):
|
||||
"""合并片段请求体"""
|
||||
|
||||
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{clip_id}/split",
|
||||
response_model=dict[str, Any],
|
||||
summary="分割片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
def split_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: SplitClipRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将一个片段从指定时间点分割为两个片段。
|
||||
|
||||
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
|
||||
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = svc.split_clip(clip_id, body.split_time)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
|
||||
return {
|
||||
"left_clip": {
|
||||
"id": left.id,
|
||||
"plan_id": left.plan_id,
|
||||
"clip_type": left.clip_type,
|
||||
"order": left.order,
|
||||
"duration": left.duration,
|
||||
"start_time": left.start_time,
|
||||
},
|
||||
"right_clip": {
|
||||
"id": right.id,
|
||||
"plan_id": right.plan_id,
|
||||
"clip_type": right.clip_type,
|
||||
"order": right.order,
|
||||
"duration": right.duration,
|
||||
"start_time": right.start_time,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/merge",
|
||||
response_model=dict[str, Any],
|
||||
summary="合并多个连续片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
def merge_clips(
|
||||
plan_id: str,
|
||||
body: MergeClipsRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将多个连续的同类型片段合并为一个片段。
|
||||
|
||||
合并要求:
|
||||
- 至少 2 个片段
|
||||
- 属于同一剪辑计划
|
||||
- order 连续
|
||||
- 类型相同
|
||||
|
||||
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 校验所有片段都属于该 plan
|
||||
for cid in body.clip_ids:
|
||||
clip = svc.get_clip(cid)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {cid}",
|
||||
)
|
||||
|
||||
try:
|
||||
merged = svc.merge_clips(body.clip_ids)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"合并片段: plan_id=%s clip_count=%d by user=%s",
|
||||
plan_id,
|
||||
len(body.clip_ids),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": merged.id,
|
||||
"plan_id": merged.plan_id,
|
||||
"clip_type": merged.clip_type,
|
||||
"order": merged.order,
|
||||
"duration": merged.duration,
|
||||
"text_content": merged.text_content,
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
"""剪辑计划片段批量操作 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipReorderItem(BaseModel):
|
||||
"""重排序条目"""
|
||||
|
||||
clip_id: str
|
||||
new_order: int = Field(..., ge=0, description="新的排序序号")
|
||||
|
||||
|
||||
class ClipReorderRequest(BaseModel):
|
||||
"""片段重排序请求"""
|
||||
|
||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
|
||||
|
||||
class ClipReorderResponse(BaseModel):
|
||||
"""片段重排序响应"""
|
||||
|
||||
success: bool
|
||||
updated_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipBatchDeleteRequest(BaseModel):
|
||||
"""批量删除片段请求"""
|
||||
|
||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
|
||||
|
||||
class ClipBatchDeleteResponse(BaseModel):
|
||||
"""批量删除片段响应"""
|
||||
|
||||
success: bool
|
||||
deleted_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
"""从素材批量创建片段响应"""
|
||||
|
||||
success: bool
|
||||
created_count: int
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, user_id, project_repository)
|
||||
return plan
|
||||
|
||||
|
||||
def _get_svc(db: Session):
|
||||
"""获取 EditPlanService 实例"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
return EditPlanService(db)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/reorder", response_model=ClipReorderResponse, deprecated=True)
|
||||
def reorder_clips(
|
||||
plan_id: str,
|
||||
body: ClipReorderRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipReorderResponse:
|
||||
"""批量重排序片段
|
||||
|
||||
前端拖拽调整顺序后,一次性提交所有变更的 order。
|
||||
自动触发编辑状态回退(从 completed/failed 切回 editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 验证所有 clip 都属于该 plan
|
||||
clip_ids = [item.clip_id for item in body.items]
|
||||
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
|
||||
existing_ids = {c.id for c in existing_clips}
|
||||
|
||||
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
|
||||
if invalid_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
|
||||
)
|
||||
|
||||
# 执行重排序
|
||||
updated_count = 0
|
||||
for item in body.items:
|
||||
try:
|
||||
svc.update_clip(item.clip_id, order=item.new_order)
|
||||
updated_count += 1
|
||||
except ValueError as e:
|
||||
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
|
||||
|
||||
logger.info(
|
||||
"批量重排序片段: plan_id=%s count=%d by user=%s",
|
||||
plan_id,
|
||||
updated_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipReorderResponse(
|
||||
success=True,
|
||||
updated_count=updated_count,
|
||||
message=f"成功更新 {updated_count} 个片段的顺序",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse, deprecated=True)
|
||||
def batch_delete_clips(
|
||||
plan_id: str,
|
||||
body: ClipBatchDeleteRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipBatchDeleteResponse:
|
||||
"""批量删除片段
|
||||
|
||||
自动触发编辑状态回退(从 completed/failed 切回 editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 验证所有 clip 都属于该 plan
|
||||
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
|
||||
existing_ids = {c.id for c in existing_clips}
|
||||
|
||||
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
|
||||
skipped = len(body.clip_ids) - len(valid_ids)
|
||||
|
||||
# 执行删除
|
||||
deleted_count = 0
|
||||
for clip_id in valid_ids:
|
||||
if svc.delete_clip(clip_id):
|
||||
deleted_count += 1
|
||||
|
||||
message = f"成功删除 {deleted_count} 个片段"
|
||||
if skipped > 0:
|
||||
message += f",跳过 {skipped} 个不存在的片段"
|
||||
|
||||
logger.info(
|
||||
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
|
||||
plan_id,
|
||||
deleted_count,
|
||||
skipped,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipBatchDeleteResponse(
|
||||
success=True,
|
||||
deleted_count=deleted_count,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-assets", response_model=ClipsFromAssetsResponse, deprecated=True)
|
||||
def create_clips_from_assets(
|
||||
plan_id: str,
|
||||
body: ClipsFromAssetsRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段(追加到时间线末尾)
|
||||
|
||||
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
|
||||
自动触发编辑状态回退(completed/failed → editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
try:
|
||||
clips = svc.create_clips_from_assets(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
clip_type=body.clip_type,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
clip_ids = [c.id for c in clips]
|
||||
|
||||
logger.info(
|
||||
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
|
||||
plan_id,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipsFromAssetsResponse(
|
||||
success=True,
|
||||
created_count=len(clips),
|
||||
message=f"成功创建 {len(clips)} 个片段",
|
||||
clip_ids=clip_ids,
|
||||
)
|
||||
@@ -1,317 +0,0 @@
|
||||
"""封面管理 API.
|
||||
|
||||
- GET /{plan_id}/cover 获取封面配置
|
||||
- PUT /{plan_id}/cover 更新封面配置
|
||||
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
|
||||
- POST /{plan_id}/cover/smart 智能选帧生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.services import EditPlanService
|
||||
from app.services.cover_service import CoverService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
|
||||
def get_cover(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取封面配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
cover = CoverService.get_cover_config(plan.config or {})
|
||||
return CoverConfigResponse(**cover)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
|
||||
def update_cover(
|
||||
plan_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新封面配置
|
||||
|
||||
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current_cover = CoverService.get_cover_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_cover = {**current_cover, **updates}
|
||||
|
||||
# 验证 type 值
|
||||
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
|
||||
if "type" in updates and updates["type"] not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
|
||||
)
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = new_cover
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
result = CoverService.get_cover_config(updated_plan.config or {})
|
||||
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
|
||||
return CoverConfigResponse(**result)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse, deprecated=True)
|
||||
def extract_cover(
|
||||
plan_id: str,
|
||||
body: CoverExtractRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段的指定时间点抽帧生成封面"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 获取片段对应的素材
|
||||
clip = svc.get_clip(body.clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {body.clip_id}",
|
||||
)
|
||||
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材,无法抽帧",
|
||||
)
|
||||
|
||||
# 抽帧生成封面
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=clip.asset_id,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"封面抽帧失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
body.frame_time,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse, deprecated=True)
|
||||
def smart_cover(
|
||||
plan_id: str,
|
||||
body: CoverSmartRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面
|
||||
|
||||
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
|
||||
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 确定使用哪个片段
|
||||
clip_id = body.clip_id
|
||||
asset_id = ""
|
||||
|
||||
if clip_id:
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材",
|
||||
)
|
||||
asset_id = clip.asset_id
|
||||
else:
|
||||
# 找第一个有素材的视频片段
|
||||
clips = svc.list_clips(plan_id, limit=50, skip=0)
|
||||
for c in clips:
|
||||
if c.asset_id and c.clip_type == "video":
|
||||
asset_id = c.asset_id
|
||||
clip_id = c.id
|
||||
break
|
||||
|
||||
if not asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="没有找到可用的视频片段",
|
||||
)
|
||||
|
||||
# 智能选帧
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.generate_smart_cover(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"智能封面生成失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
|
||||
plan_id,
|
||||
clip_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
@@ -1,276 +0,0 @@
|
||||
"""导出设置 API.
|
||||
|
||||
- GET /{plan_id}/export 获取导出配置
|
||||
- PUT /{plan_id}/export 更新导出配置
|
||||
- GET /export-presets 导出预设列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── 导出预设 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
EXPORT_PRESETS = [
|
||||
{
|
||||
"id": "export_1080p_30",
|
||||
"name": "1080P 高清",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 8000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "balanced",
|
||||
"description": "竖屏高清,适合短视频平台",
|
||||
"size_hint": "约 10MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_60",
|
||||
"name": "1080P 高帧率",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 60,
|
||||
"video_bitrate": 12000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "high",
|
||||
"description": "60帧高帧率,流畅运动画面",
|
||||
"size_hint": "约 18MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_720p_30",
|
||||
"name": "720P 流畅",
|
||||
"resolution": "720x1280",
|
||||
"fps": 30,
|
||||
"video_bitrate": 4000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "fast",
|
||||
"description": "快速导出,文件较小",
|
||||
"size_hint": "约 5MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_4k_30",
|
||||
"name": "4K 超清",
|
||||
"resolution": "2160x3840",
|
||||
"fps": 30,
|
||||
"video_bitrate": 20000,
|
||||
"audio_bitrate": 192,
|
||||
"format": "mp4",
|
||||
"quality_preset": "best",
|
||||
"description": "4K超清画质,专业品质",
|
||||
"size_hint": "约 30MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_30_mov",
|
||||
"name": "1080P ProRes",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 15000,
|
||||
"audio_bitrate": 256,
|
||||
"format": "mov",
|
||||
"quality_preset": "high",
|
||||
"description": "MOV格式,适合后期剪辑",
|
||||
"size_hint": "约 25MB/分钟",
|
||||
},
|
||||
]
|
||||
|
||||
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
||||
VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ExportConfigResponse(BaseModel):
|
||||
"""导出配置响应"""
|
||||
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
watermark_enabled: bool
|
||||
watermark_text: str
|
||||
|
||||
|
||||
class ExportUpdateRequest(BaseModel):
|
||||
"""更新导出配置请求"""
|
||||
|
||||
resolution: Optional[str] = None
|
||||
fps: Optional[int] = Field(default=None, ge=15, le=60)
|
||||
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
|
||||
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
|
||||
format: Optional[str] = None
|
||||
quality_preset: Optional[str] = None
|
||||
watermark_enabled: Optional[bool] = None
|
||||
watermark_text: Optional[str] = None
|
||||
|
||||
@validator("resolution")
|
||||
def validate_resolution(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not RESOLUTION_PATTERN.match(v):
|
||||
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
|
||||
w, h = v.split("x")
|
||||
if int(w) < 100 or int(h) < 100:
|
||||
raise ValueError("分辨率数值过小")
|
||||
if int(w) > 4096 or int(h) > 4096:
|
||||
raise ValueError("分辨率数值过大,最大 4096x4096")
|
||||
return v
|
||||
|
||||
@validator("format")
|
||||
def validate_format(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_FORMATS:
|
||||
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
|
||||
return v
|
||||
|
||||
@validator("quality_preset")
|
||||
def validate_quality_preset(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_QUALITY_PRESETS:
|
||||
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
|
||||
return v
|
||||
|
||||
|
||||
class ExportPresetItem(BaseModel):
|
||||
"""导出预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
description: str
|
||||
size_hint: str
|
||||
|
||||
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_export_config(plan_config: dict) -> dict:
|
||||
e = plan_config.get("export", {})
|
||||
if not isinstance(e, dict):
|
||||
e = {}
|
||||
return {
|
||||
"resolution": e.get("resolution", "1080x1920"),
|
||||
"fps": e.get("fps", 30),
|
||||
"video_bitrate": e.get("video_bitrate", 8000),
|
||||
"audio_bitrate": e.get("audio_bitrate", 128),
|
||||
"format": e.get("format", "mp4"),
|
||||
"quality_preset": e.get("quality_preset", "balanced"),
|
||||
"watermark_enabled": e.get("watermark_enabled", False),
|
||||
"watermark_text": e.get("watermark_text", ""),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/export-presets", response_model=ExportPresetListResponse, deprecated=True)
|
||||
def list_export_presets(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ExportPresetListResponse:
|
||||
"""获取导出预设列表"""
|
||||
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
|
||||
return ExportPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
|
||||
def get_export_config(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""获取导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_export_config(plan.config or {})
|
||||
return ExportConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
|
||||
def update_export_config(
|
||||
plan_id: str,
|
||||
body: ExportUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""更新导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current = _get_export_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_export = {**current, **updates}
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["export"] = new_export
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
|
||||
|
||||
result = _get_export_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
|
||||
plan_id,
|
||||
result["resolution"],
|
||||
result["fps"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return ExportConfigResponse(**result)
|
||||
@@ -1,199 +0,0 @@
|
||||
"""滤镜调色 API.
|
||||
|
||||
- GET /filter-presets 滤镜预设列表
|
||||
- GET /{plan_id}/filter 获取全局滤镜配置
|
||||
- PUT /{plan_id}/filter 更新全局滤镜配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.filter_presets import (
|
||||
FilterPreset,
|
||||
get_filter_preset,
|
||||
list_filter_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FilterPresetResponse(BaseModel):
|
||||
"""滤镜预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
"""滤镜配置响应"""
|
||||
|
||||
enabled: bool
|
||||
preset_id: str
|
||||
intensity: int
|
||||
brightness: float
|
||||
contrast: float
|
||||
saturation: float
|
||||
warmth: float
|
||||
|
||||
|
||||
class FilterUpdateRequest(BaseModel):
|
||||
"""更新滤镜配置请求"""
|
||||
|
||||
enabled: Optional[bool] = None
|
||||
preset_id: Optional[str] = None
|
||||
intensity: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
|
||||
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
|
||||
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
|
||||
return FilterPresetResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
category=p.category,
|
||||
description=p.description,
|
||||
tags=list(p.tags),
|
||||
)
|
||||
|
||||
|
||||
def _get_filter_config(plan_config: dict) -> dict:
|
||||
"""从 plan.config 中提取滤镜配置"""
|
||||
f = plan_config.get("filter", {})
|
||||
if not isinstance(f, dict):
|
||||
f = {}
|
||||
return {
|
||||
"enabled": f.get("enabled", False),
|
||||
"preset_id": f.get("preset_id", "filter_none"),
|
||||
"intensity": f.get("intensity", 100),
|
||||
"brightness": f.get("brightness", 0.0),
|
||||
"contrast": f.get("contrast", 1.0),
|
||||
"saturation": f.get("saturation", 1.0),
|
||||
"warmth": f.get("warmth", 0.0),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/filter-presets", response_model=FilterPresetListResponse, deprecated=True)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> FilterPresetListResponse:
|
||||
"""获取滤镜预设列表"""
|
||||
presets = list_filter_presets(category=category, keyword=keyword)
|
||||
items = [_preset_to_response(p) for p in presets]
|
||||
return FilterPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
|
||||
def get_filter(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""获取剪辑计划的全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_filter_config(plan.config or {})
|
||||
return FilterConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
|
||||
def update_filter(
|
||||
plan_id: str,
|
||||
body: FilterUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""更新全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证 preset_id
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
if "preset_id" in updates:
|
||||
preset = get_filter_preset(updates["preset_id"])
|
||||
if preset is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的滤镜预设: {updates['preset_id']}",
|
||||
)
|
||||
|
||||
# 合并更新
|
||||
current = _get_filter_config(plan.config or {})
|
||||
new_filter = {**current, **updates}
|
||||
|
||||
# 如果设为原图 preset,自动关闭
|
||||
if new_filter["preset_id"] == "filter_none":
|
||||
new_filter["enabled"] = False
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["filter"] = new_filter
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
|
||||
|
||||
result = _get_filter_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
|
||||
plan_id,
|
||||
result["preset_id"],
|
||||
result["intensity"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return FilterConfigResponse(**result)
|
||||
@@ -1,418 +0,0 @@
|
||||
"""剪辑计划生成相关 API 端点。
|
||||
|
||||
从 edit_plans.py 拆分,包含:
|
||||
- POST /{plan_id}/generate 触发剪辑渲染生成
|
||||
- GET /{plan_id}/generation-status 查询生成进度
|
||||
- GET /{plan_id}/generations 查询关联的生成记录
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.api.routes.edit_plans import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
"""自动兜底 1: draft → editing"""
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
|
||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
logger.info(
|
||||
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||
plan_id,
|
||||
plan_check.template_id,
|
||||
)
|
||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
||||
else:
|
||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||
for seg in segments:
|
||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
duration=avg_duration,
|
||||
config={
|
||||
"material_type": seg.material_type or "",
|
||||
"template_segment_id": seg.id,
|
||||
},
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
||||
|
||||
|
||||
def _auto_fallback_assign_assets(
|
||||
svc: EditPlanService,
|
||||
plan_id: str,
|
||||
plan_check,
|
||||
) -> list:
|
||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||
clips_without_asset = []
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
|
||||
def _auto_fallback_auto_material_mode(
|
||||
svc: EditPlanService,
|
||||
plan_id: str,
|
||||
plan_check,
|
||||
clips_without_asset: list,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
) -> None:
|
||||
"""自动兜底 4: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段
|
||||
|
||||
注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto,
|
||||
导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段,
|
||||
就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。
|
||||
"""
|
||||
if not clips_without_asset:
|
||||
return
|
||||
if not plan_check.project_id:
|
||||
return
|
||||
|
||||
import random
|
||||
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
if ready_videos:
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
video_lib.name,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
||||
|
||||
|
||||
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||
"""队列限流预检查"""
|
||||
try:
|
||||
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total")
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse, deprecated=True)
|
||||
def generate_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发剪辑计划渲染生成
|
||||
|
||||
前置条件:计划状态必须为 editing,且至少有一个片段。
|
||||
流程:
|
||||
1. 验证计划状态为 editing
|
||||
2. 将 pending 片段标记为 ready
|
||||
3. 创建 GenerationTask
|
||||
4. 调度 Celery 任务 worker.render_edit_plan
|
||||
5. 将计划状态流转为 rendering
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan_check = svc.get_plan(plan_id)
|
||||
if plan_check is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan_check.project_id:
|
||||
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 自动兜底流程
|
||||
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
|
||||
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
|
||||
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
|
||||
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
|
||||
|
||||
# 检查是否可生成
|
||||
try:
|
||||
can_gen, reason = svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
|
||||
# 核心生成流程
|
||||
try:
|
||||
clip_count = svc.mark_clips_ready(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=plan.project_id or "",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
asset_ids=list(config_asset_ids) if config_asset_ids else [],
|
||||
)
|
||||
)
|
||||
|
||||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
|
||||
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||
|
||||
logger.info(
|
||||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as _e:
|
||||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||
try:
|
||||
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
except Exception:
|
||||
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
) from _e
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/generation-status",
|
||||
response_model=EditPlanGenerationStatusResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def get_generation_status(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> EditPlanGenerationStatusResponse:
|
||||
"""查询剪辑计划生成进度"""
|
||||
svc = EditPlanService(db)
|
||||
try:
|
||||
gen_status = svc.get_generation_status(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
clips = gen_status["clips"]
|
||||
|
||||
clip_items = [
|
||||
ClipStatusItem(
|
||||
clip_id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
asset_id=c.asset_id or "",
|
||||
text_content=c.text_content or "",
|
||||
duration=c.duration,
|
||||
)
|
||||
for c in clips
|
||||
]
|
||||
|
||||
# 从 plan.config 中取渲染结果 URL,转换为签名 URL
|
||||
raw_video_url = (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
try:
|
||||
video_url = storage_service.get_download_url(raw_video_url, expires_seconds=86400)
|
||||
except Exception as e:
|
||||
logger.warning("生成视频签名URL失败,返回原始URL: plan_id=%s error=%s", plan_id, e)
|
||||
video_url = raw_video_url
|
||||
# 从 gen_status 中取进度、错误信息、任务状态
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
gen_task_status = gen_status.get("generation_task_status")
|
||||
# 如果计划已完成但进度还是0,补100
|
||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status_val == "completed" and progress < 100:
|
||||
progress = 100.0
|
||||
|
||||
return EditPlanGenerationStatusResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=plan_status_val,
|
||||
generation_task_id=gen_status["generation_task_id"],
|
||||
generation_task_status=gen_task_status,
|
||||
progress=progress,
|
||||
video_url=video_url,
|
||||
error_message=error_message,
|
||||
clips=clip_items,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/generations",
|
||||
response_model=EditPlanGenerationsResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def list_plan_generations(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanGenerationsResponse:
|
||||
"""查询剪辑计划关联的所有生成记录"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
items = [
|
||||
GenerationTaskResponse(
|
||||
id=t.id,
|
||||
project_id=t.project_id,
|
||||
asset_library_id=t.asset_library_id,
|
||||
strategy_id=t.strategy_id,
|
||||
voice_library_id=t.voice_library_id,
|
||||
template_id=t.template_id,
|
||||
asset_ids=t.asset_ids,
|
||||
title_ids=t.title_ids,
|
||||
voice_ids=t.voice_ids,
|
||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||
progress=t.progress,
|
||||
result_count=t.result_count,
|
||||
error_message=t.error_message,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||
@@ -1,263 +0,0 @@
|
||||
"""剪辑计划时间线 & 模板生成 API 端点。
|
||||
|
||||
从 edit_plans.py 拆分,包含:
|
||||
- GET /{plan_id}/timeline 时间线场景数据
|
||||
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
from app.api.routes._helpers import auto_select_video_assets, check_project_access
|
||||
from app.api.routes.edit_plans import (
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
_PlanClipItem,
|
||||
_to_response,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.services import EditPlanService, PlanGeneratorService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Timeline Schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TimelineSceneResponse(BaseModel):
|
||||
"""时间线场景"""
|
||||
|
||||
scene: str = Field(..., description="场景描述")
|
||||
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")
|
||||
clip_type: str = Field(default="", description="片段类型")
|
||||
|
||||
|
||||
class TimelineResponse(BaseModel):
|
||||
"""时间线响应"""
|
||||
|
||||
plan_id: str
|
||||
total_duration: float
|
||||
scenes: List[TimelineSceneResponse]
|
||||
|
||||
|
||||
# clip_type → 颜色映射
|
||||
_CLIP_TYPE_COLORS = {
|
||||
"intro": "#6366f1",
|
||||
"title": "#6366f1",
|
||||
"product": "#818cf8",
|
||||
"showcase": "#10b981",
|
||||
"scene": "#10b981",
|
||||
"subtitle": "#f59e0b",
|
||||
"text": "#f59e0b",
|
||||
"cta": "#ef4444",
|
||||
"outro": "#ef4444",
|
||||
"voiceover": "#8b5cf6",
|
||||
"transition": "#64748b",
|
||||
}
|
||||
|
||||
_DEFAULT_COLOR = "#6366f1"
|
||||
|
||||
|
||||
def _format_time(seconds: float) -> str:
|
||||
"""将秒数格式化为 M:SS"""
|
||||
m = int(seconds) // 60
|
||||
s = int(seconds) % 60
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
||||
"""根据 clip_type 和 text_content 生成场景描述"""
|
||||
type_labels = {
|
||||
"intro": "开场",
|
||||
"title": "标题",
|
||||
"product": "产品展示",
|
||||
"showcase": "场景展示",
|
||||
"scene": "场景",
|
||||
"subtitle": "字幕",
|
||||
"text": "文字",
|
||||
"cta": "结尾 CTA",
|
||||
"outro": "结尾",
|
||||
"voiceover": "配音",
|
||||
"transition": "转场",
|
||||
}
|
||||
label = type_labels.get(clip_type, clip_type or "片段")
|
||||
if text_content:
|
||||
short = text_content[:20].strip()
|
||||
if short:
|
||||
return f"{label} - {short}"
|
||||
return label
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/timeline",
|
||||
response_model=TimelineResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def get_plan_timeline(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> TimelineResponse:
|
||||
"""获取剪辑计划的时间线场景数据"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
scenes: List[TimelineSceneResponse] = []
|
||||
current_time = 0.0
|
||||
|
||||
for clip in clips:
|
||||
start = current_time
|
||||
end = start + clip.duration
|
||||
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
|
||||
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
|
||||
|
||||
scenes.append(
|
||||
TimelineSceneResponse(
|
||||
scene=scene_label,
|
||||
time=f"{_format_time(start)} - {_format_time(end)}",
|
||||
duration=clip.duration,
|
||||
color=color,
|
||||
clip_id=clip.id,
|
||||
clip_type=clip.clip_type,
|
||||
)
|
||||
)
|
||||
current_time = end
|
||||
|
||||
total_duration = sum(s.duration for s in scenes) or plan.total_duration
|
||||
|
||||
return TimelineResponse(
|
||||
plan_id=plan_id,
|
||||
total_duration=total_duration,
|
||||
scenes=scenes,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-from-template",
|
||||
response_model=GenerateFromTemplateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
deprecated=True,
|
||||
)
|
||||
def generate_from_template(
|
||||
body: GenerateFromTemplateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> GenerateFromTemplateResponse:
|
||||
"""基于模板 + 素材自动生成剪辑计划"""
|
||||
from app.services import EditTemplateService
|
||||
|
||||
if body.project_id:
|
||||
check_project_access(body.project_id, current_user.user.id, project_repository)
|
||||
|
||||
template_svc = EditTemplateService(db)
|
||||
|
||||
try:
|
||||
template = template_svc.get_template_or_raise(body.template_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||||
|
||||
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
|
||||
resolved_asset_ids = list(body.asset_ids)
|
||||
if not resolved_asset_ids and body.project_id:
|
||||
auto_assets = auto_select_video_assets(
|
||||
project_id=body.project_id,
|
||||
asset_library_repo=asset_library_repository,
|
||||
asset_repo=asset_repository,
|
||||
logger=logger,
|
||||
)
|
||||
if auto_assets:
|
||||
resolved_asset_ids = auto_assets
|
||||
logger.info(
|
||||
"generate-from-template 自动选素材: project_id=%s count=%d",
|
||||
body.project_id,
|
||||
len(auto_assets),
|
||||
)
|
||||
|
||||
generator = PlanGeneratorService(db)
|
||||
result = generator.generate_from_template(
|
||||
template=template,
|
||||
clip_configs=clip_configs,
|
||||
asset_ids=resolved_asset_ids,
|
||||
project_id=body.project_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
name=body.name,
|
||||
)
|
||||
|
||||
plan = result["plan"]
|
||||
clips = result["clips"]
|
||||
|
||||
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
|
||||
if resolved_asset_ids:
|
||||
from app.services import EditPlanService
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
svc = EditPlanService(db)
|
||||
current_config = plan.config or {}
|
||||
if current_config.get("asset_ids") != resolved_asset_ids:
|
||||
current_config["asset_ids"] = resolved_asset_ids
|
||||
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
|
||||
|
||||
logger.info(
|
||||
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d",
|
||||
plan.id,
|
||||
body.template_id,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateFromTemplateResponse(
|
||||
plan=_to_response(plan),
|
||||
clips=[
|
||||
_PlanClipItem(
|
||||
id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
asset_id=c.asset_id,
|
||||
text_content=c.text_content,
|
||||
start_time=c.start_time,
|
||||
duration=c.duration,
|
||||
transition_effect=c.transition_effect,
|
||||
transition_duration=c.transition_duration,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
config=c.config,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
for c in clips
|
||||
],
|
||||
)
|
||||
@@ -1,274 +0,0 @@
|
||||
"""转场特效 API.
|
||||
|
||||
- GET /transition-presets 转场预设列表
|
||||
- PUT /clips/{clip_id}/transition 设置单个片段转场
|
||||
- POST /{plan_id}/transitions/batch 批量设置转场(所有片段)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.transition_presets import (
|
||||
TransitionPreset,
|
||||
get_transition_preset,
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionPresetResponse(BaseModel):
|
||||
"""转场预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
default_duration: float
|
||||
min_duration: float
|
||||
max_duration: float
|
||||
|
||||
|
||||
class TransitionUpdateRequest(BaseModel):
|
||||
"""更新转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
|
||||
|
||||
class BatchTransitionRequest(BaseModel):
|
||||
"""批量设置转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
apply_to: str = Field(
|
||||
default="all",
|
||||
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
|
||||
)
|
||||
|
||||
|
||||
class ClipTransitionResponse(BaseModel):
|
||||
"""片段转场信息响应"""
|
||||
|
||||
clip_id: str
|
||||
effect: str
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchTransitionResponse(BaseModel):
|
||||
"""批量转场响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
class TransitionPresetListResponse(BaseModel):
|
||||
"""转场预设列表响应"""
|
||||
|
||||
items: List[TransitionPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse:
|
||||
return TransitionPresetResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
category=p.category,
|
||||
description=p.description,
|
||||
tags=list(p.tags),
|
||||
default_duration=p.default_duration,
|
||||
min_duration=p.min_duration,
|
||||
max_duration=p.max_duration,
|
||||
)
|
||||
|
||||
|
||||
def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]:
|
||||
"""验证转场效果和时长,返回 (effect, duration)"""
|
||||
preset = get_transition_preset(effect)
|
||||
if preset is None:
|
||||
raise ValueError(f"无效的转场效果: {effect}")
|
||||
|
||||
# 硬切特殊处理,时长强制为0
|
||||
if effect == "transition_none" or preset.transition == "none":
|
||||
return "cut", 0.0
|
||||
|
||||
final_duration = duration if duration is not None else preset.default_duration
|
||||
if final_duration < preset.min_duration:
|
||||
final_duration = preset.min_duration
|
||||
if final_duration > preset.max_duration:
|
||||
final_duration = preset.max_duration
|
||||
|
||||
return preset.transition, round(final_duration, 3)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/transition-presets", response_model=TransitionPresetListResponse, deprecated=True)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> TransitionPresetListResponse:
|
||||
"""获取转场预设列表"""
|
||||
presets = list_transition_presets(category=category, keyword=keyword)
|
||||
items = [_preset_to_response(p) for p in presets]
|
||||
return TransitionPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse, deprecated=True)
|
||||
def update_clip_transition(
|
||||
clip_id: str,
|
||||
body: TransitionUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipTransitionResponse:
|
||||
"""设置单个片段的转场效果"""
|
||||
svc = EditPlanService(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
|
||||
plan = svc.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证转场参数
|
||||
try:
|
||||
effect, duration = _validate_transition(body.effect, body.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 更新片段
|
||||
updated_clip = svc.update_clip(
|
||||
clip_id,
|
||||
transition_effect=effect,
|
||||
transition_duration=duration,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s",
|
||||
clip_id,
|
||||
effect,
|
||||
duration,
|
||||
current_user.user.id,
|
||||
)
|
||||
return ClipTransitionResponse(
|
||||
clip_id=clip_id,
|
||||
effect=updated_clip.transition_effect,
|
||||
duration=updated_clip.transition_duration,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse, deprecated=True)
|
||||
def batch_update_transitions(
|
||||
plan_id: str,
|
||||
body: BatchTransitionRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchTransitionResponse:
|
||||
"""批量设置计划内所有片段的转场效果
|
||||
|
||||
apply_to 说明:
|
||||
- all: 所有片段
|
||||
- except_first: 除第一个片段外(第一个片段不需要前转场)
|
||||
- except_last: 除最后一个片段外
|
||||
- middle: 只设置中间片段(除首尾)
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证转场参数
|
||||
try:
|
||||
effect, duration = _validate_transition(body.effect, body.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 获取所有片段
|
||||
clips = svc.list_clips(plan_id, limit=500, skip=0)
|
||||
if not clips:
|
||||
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
|
||||
|
||||
# 确定应用范围
|
||||
total = len(clips)
|
||||
if total <= 1:
|
||||
# 只有一个片段时,只有 all 模式才应用
|
||||
if body.apply_to != "all":
|
||||
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
|
||||
|
||||
# 按 order 排序
|
||||
clips_sorted = sorted(clips, key=lambda c: c.order)
|
||||
indices_to_update = []
|
||||
|
||||
if body.apply_to == "all":
|
||||
indices_to_update = list(range(total))
|
||||
elif body.apply_to == "except_first":
|
||||
indices_to_update = list(range(1, total))
|
||||
elif body.apply_to == "except_last":
|
||||
indices_to_update = list(range(total - 1))
|
||||
elif body.apply_to == "middle":
|
||||
if total <= 2:
|
||||
indices_to_update = []
|
||||
else:
|
||||
indices_to_update = list(range(1, total - 1))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的 apply_to: {body.apply_to}",
|
||||
)
|
||||
|
||||
# 批量更新
|
||||
count = 0
|
||||
for idx in indices_to_update:
|
||||
clip = clips_sorted[idx]
|
||||
svc.update_clip(
|
||||
clip.id,
|
||||
transition_effect=effect,
|
||||
transition_duration=duration,
|
||||
)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s",
|
||||
plan_id,
|
||||
count,
|
||||
effect,
|
||||
body.apply_to,
|
||||
current_user.user.id,
|
||||
)
|
||||
return BatchTransitionResponse(updated_count=count, plan_id=plan_id)
|
||||
Executable → Regular
+454
-53
@@ -23,60 +23,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re as _re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.api.routes.edit_plans import (
|
||||
AIRecommendRequest,
|
||||
AIRecommendResponse,
|
||||
BGMConfigUpdateRequest,
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
from app.api.routes.edit_plans_adjustments import (
|
||||
BatchSpeedRequest,
|
||||
BatchSpeedResponse,
|
||||
ClipAdjustmentsRequest,
|
||||
ClipAdjustResponse,
|
||||
SpeedAdjustRequest,
|
||||
TrimAdjustRequest,
|
||||
VolumeAdjustRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_clips_batch import (
|
||||
ClipBatchDeleteRequest,
|
||||
ClipBatchDeleteResponse,
|
||||
ClipReorderRequest,
|
||||
ClipReorderResponse,
|
||||
ClipsFromAssetsRequest,
|
||||
ClipsFromAssetsResponse,
|
||||
)
|
||||
from app.api.routes.edit_plans_cover import (
|
||||
CoverConfigResponse,
|
||||
CoverExtractRequest,
|
||||
CoverGenerateResponse,
|
||||
CoverSmartRequest,
|
||||
CoverUpdateRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_export import (
|
||||
ExportConfigResponse,
|
||||
ExportPresetListResponse,
|
||||
ExportUpdateRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_filter import (
|
||||
FilterConfigResponse,
|
||||
FilterPresetListResponse,
|
||||
FilterUpdateRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_transitions import (
|
||||
BatchTransitionRequest,
|
||||
BatchTransitionResponse,
|
||||
ClipTransitionResponse,
|
||||
TransitionPresetListResponse,
|
||||
TransitionUpdateRequest,
|
||||
)
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
# ── Pydantic Schemas (migrated from edit_plans*) ────────────────────────────
|
||||
|
||||
|
||||
# ── From edit_plans.py ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipStatusItem(BaseModel):
|
||||
"""片段生成状态"""
|
||||
|
||||
clip_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
status: str
|
||||
asset_id: str
|
||||
text_content: str
|
||||
duration: float
|
||||
|
||||
|
||||
class EditPlanGenerationStatusResponse(BaseModel):
|
||||
"""剪辑计划生成进度响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: Optional[str] = None
|
||||
generation_task_status: Optional[str] = None
|
||||
progress: float = 0.0
|
||||
video_url: str = ""
|
||||
error_message: str = ""
|
||||
clips: List[ClipStatusItem]
|
||||
|
||||
|
||||
class EditPlanGenerateResponse(BaseModel):
|
||||
"""剪辑计划触发生成响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: str
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditPlanGenerationsResponse(BaseModel):
|
||||
"""剪辑计划关联的生成记录列表响应体"""
|
||||
|
||||
items: List[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
|
||||
|
||||
class AIRecommendClipItem(BaseModel):
|
||||
"""AI 推荐的单个片段"""
|
||||
|
||||
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
|
||||
order: int = Field(..., ge=0, description="片段顺序")
|
||||
text_content: str = Field(default="", description="文字内容")
|
||||
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
||||
transition_effect: str = Field(default="cut", description="转场效果")
|
||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
|
||||
asset_id: str = Field(default="", description="关联素材 ID")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
||||
|
||||
|
||||
class AIRecommendResponse(BaseModel):
|
||||
"""AI 推荐片段方案响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||
config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)")
|
||||
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
class BGMConfigUpdateRequest(BaseModel):
|
||||
"""更新BGM配置请求体"""
|
||||
|
||||
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
|
||||
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
|
||||
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
|
||||
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
|
||||
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
|
||||
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
|
||||
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
|
||||
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
|
||||
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
|
||||
|
||||
|
||||
# ── From edit_plans_adjustments.py ──────────────────────────────────────────
|
||||
|
||||
|
||||
class SpeedAdjustRequest(BaseModel):
|
||||
"""调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
|
||||
|
||||
|
||||
class VolumeAdjustRequest(BaseModel):
|
||||
"""音量调节请求"""
|
||||
|
||||
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
|
||||
|
||||
|
||||
class TrimAdjustRequest(BaseModel):
|
||||
"""裁剪请求"""
|
||||
|
||||
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
|
||||
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
|
||||
|
||||
|
||||
class ClipAdjustmentsRequest(BaseModel):
|
||||
"""统一调整请求"""
|
||||
|
||||
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
trim_start: Optional[float] = Field(default=None, ge=0.0)
|
||||
trim_end: Optional[float] = Field(default=None, ge=0.0)
|
||||
|
||||
|
||||
class BatchSpeedRequest(BaseModel):
|
||||
"""批量调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
|
||||
|
||||
|
||||
class ClipAdjustResponse(BaseModel):
|
||||
"""片段调整响应"""
|
||||
|
||||
clip_id: str
|
||||
speed: float
|
||||
volume: float
|
||||
trim_start: float
|
||||
trim_end: float
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchSpeedResponse(BaseModel):
|
||||
"""批量调速响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
# ── From edit_plans_clips_batch.py ──────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipReorderItem(BaseModel):
|
||||
"""重排序条目"""
|
||||
|
||||
clip_id: str
|
||||
new_order: int = Field(..., ge=0, description="新的排序序号")
|
||||
|
||||
|
||||
class ClipReorderRequest(BaseModel):
|
||||
"""片段重排序请求"""
|
||||
|
||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
|
||||
|
||||
class ClipReorderResponse(BaseModel):
|
||||
"""片段重排序响应"""
|
||||
|
||||
success: bool
|
||||
updated_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipBatchDeleteRequest(BaseModel):
|
||||
"""批量删除片段请求"""
|
||||
|
||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
|
||||
|
||||
class ClipBatchDeleteResponse(BaseModel):
|
||||
"""批量删除片段响应"""
|
||||
|
||||
success: bool
|
||||
deleted_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
"""从素材批量创建片段响应"""
|
||||
|
||||
success: bool
|
||||
created_count: int
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
|
||||
|
||||
# ── From edit_plans_cover.py ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── From edit_plans_export.py ───────────────────────────────────────────────
|
||||
|
||||
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
|
||||
_EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
||||
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
|
||||
class ExportConfigResponse(BaseModel):
|
||||
"""导出配置响应"""
|
||||
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
watermark_enabled: bool
|
||||
watermark_text: str
|
||||
|
||||
|
||||
class ExportUpdateRequest(BaseModel):
|
||||
"""更新导出配置请求"""
|
||||
|
||||
resolution: Optional[str] = None
|
||||
fps: Optional[int] = Field(default=None, ge=15, le=60)
|
||||
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
|
||||
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
|
||||
format: Optional[str] = None
|
||||
quality_preset: Optional[str] = None
|
||||
watermark_enabled: Optional[bool] = None
|
||||
watermark_text: Optional[str] = None
|
||||
|
||||
@validator("resolution")
|
||||
def validate_resolution(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not _EXPORT_RESOLUTION_PATTERN.match(v):
|
||||
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
|
||||
w, h = v.split("x")
|
||||
if int(w) < 100 or int(h) < 100:
|
||||
raise ValueError("分辨率数值过小")
|
||||
if int(w) > 4096 or int(h) > 4096:
|
||||
raise ValueError("分辨率数值过大,最大 4096x4096")
|
||||
return v
|
||||
|
||||
@validator("format")
|
||||
def validate_format(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in _EXPORT_VALID_FORMATS:
|
||||
raise ValueError(f"无效格式: {v},支持: {_EXPORT_VALID_FORMATS}")
|
||||
return v
|
||||
|
||||
@validator("quality_preset")
|
||||
def validate_quality_preset(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in _EXPORT_VALID_QUALITY_PRESETS:
|
||||
raise ValueError(f"无效质量预设: {v},支持: {_EXPORT_VALID_QUALITY_PRESETS}")
|
||||
return v
|
||||
|
||||
|
||||
class ExportPresetItem(BaseModel):
|
||||
"""导出预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
description: str
|
||||
size_hint: str
|
||||
|
||||
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ── From edit_plans_filter.py ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class FilterPresetResponse(BaseModel):
|
||||
"""滤镜预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
"""滤镜配置响应"""
|
||||
|
||||
enabled: bool
|
||||
preset_id: str
|
||||
intensity: int
|
||||
brightness: float
|
||||
contrast: float
|
||||
saturation: float
|
||||
warmth: float
|
||||
|
||||
|
||||
class FilterUpdateRequest(BaseModel):
|
||||
"""更新滤镜配置请求"""
|
||||
|
||||
enabled: Optional[bool] = None
|
||||
preset_id: Optional[str] = None
|
||||
intensity: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
|
||||
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
|
||||
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── From edit_plans_transitions.py ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionPresetResponse(BaseModel):
|
||||
"""转场预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
default_duration: float
|
||||
min_duration: float
|
||||
max_duration: float
|
||||
|
||||
|
||||
class TransitionUpdateRequest(BaseModel):
|
||||
"""更新转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
|
||||
|
||||
class BatchTransitionRequest(BaseModel):
|
||||
"""批量设置转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
apply_to: str = Field(
|
||||
default="all",
|
||||
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
|
||||
)
|
||||
|
||||
|
||||
class ClipTransitionResponse(BaseModel):
|
||||
"""片段转场信息响应"""
|
||||
|
||||
clip_id: str
|
||||
effect: str
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchTransitionResponse(BaseModel):
|
||||
"""批量转场响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
class TransitionPresetListResponse(BaseModel):
|
||||
"""转场预设列表响应"""
|
||||
|
||||
items: List[TransitionPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
@@ -89,7 +491,6 @@ from app.dependencies import (
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
|
||||
Regular → Executable
+2
-464
@@ -39,70 +39,6 @@ class EditPlanService:
|
||||
|
||||
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
def list_plans(
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[EditPlan]:
|
||||
"""列出剪辑计划
|
||||
|
||||
Args:
|
||||
template_id: 按模板 ID 筛选
|
||||
project_id: 按项目 ID 筛选
|
||||
status: 按状态筛选
|
||||
skip: 分页偏移
|
||||
limit: 每页数量
|
||||
"""
|
||||
if project_id:
|
||||
return self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
if template_id:
|
||||
return self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
|
||||
|
||||
def count_plans(
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
) -> int:
|
||||
"""统计计划数量
|
||||
|
||||
Note:
|
||||
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
|
||||
"""
|
||||
if project_id:
|
||||
all_matching = self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=0,
|
||||
limit=10000,
|
||||
)
|
||||
return len(all_matching)
|
||||
if template_id:
|
||||
all_matching = self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
status=status,
|
||||
skip=0,
|
||||
limit=10000,
|
||||
)
|
||||
return len(all_matching)
|
||||
return self._plan_repo.count(status=status)
|
||||
|
||||
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
|
||||
"""获取计划详情"""
|
||||
return self._plan_repo.get(plan_id)
|
||||
@@ -124,7 +60,7 @@ class EditPlanService:
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
) -> EditPlan:
|
||||
"""创建剪辑计划
|
||||
"""创建剪辑计划(基础 CRUD,供内部测试与脚本使用)
|
||||
|
||||
Raises:
|
||||
ValueError: 参数校验失败
|
||||
@@ -190,23 +126,6 @@ class EditPlanService:
|
||||
logger.info("更新剪辑计划: id=%s", plan_id)
|
||||
return result
|
||||
|
||||
def delete_plan(self, plan_id: str) -> bool:
|
||||
"""删除剪辑计划及其所有片段
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
existing = self._plan_repo.get(plan_id)
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
# 先删除所有片段
|
||||
self._clip_repo.delete_by_plan(plan_id)
|
||||
# 再删除计划
|
||||
self._plan_repo.delete(plan_id)
|
||||
logger.info("删除剪辑计划: id=%s", plan_id)
|
||||
return True
|
||||
|
||||
# ── 状态机流转 ──────────────────────────────────────────────────────────
|
||||
|
||||
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
|
||||
@@ -447,63 +366,6 @@ class EditPlanService:
|
||||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
||||
return count
|
||||
|
||||
def create_clips_from_assets(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_ids: list[str],
|
||||
*,
|
||||
clip_type: str = "main",
|
||||
) -> list[EditPlanClip]:
|
||||
"""从素材批量创建片段(追加到时间线末尾)。
|
||||
|
||||
Args:
|
||||
plan_id: 计划 ID
|
||||
asset_ids: 素材 ID 列表(按顺序追加)
|
||||
clip_type: 片段类型
|
||||
|
||||
Returns:
|
||||
list[EditPlanClip]: 创建的片段列表
|
||||
"""
|
||||
if not asset_ids:
|
||||
return []
|
||||
|
||||
# 确保计划存在 + 自动回退状态
|
||||
self.get_plan_or_raise(plan_id)
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
# 查询素材信息(取 duration)
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
session = self._clip_repo.session # type: ignore[attr-defined]
|
||||
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
asset_map = {a.id: a for a in assets}
|
||||
|
||||
# 从现有片段数量开始追加
|
||||
existing_count = self._clip_repo.count(plan_id=plan_id)
|
||||
|
||||
# 批量创建片段
|
||||
created: list[EditPlanClip] = []
|
||||
for i, asset_id in enumerate(asset_ids):
|
||||
asset = asset_map.get(asset_id)
|
||||
duration = asset.duration if asset and asset.duration else 0.0
|
||||
|
||||
clip = self.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=existing_count + i,
|
||||
asset_id=asset_id,
|
||||
duration=duration,
|
||||
)
|
||||
created.append(clip)
|
||||
|
||||
logger.info(
|
||||
"从素材批量创建片段: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(created),
|
||||
)
|
||||
return created
|
||||
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
@@ -674,249 +536,7 @@ class EditPlanService:
|
||||
|
||||
return merged_clip
|
||||
|
||||
# ── 字幕管理 ──────────────────────────────────────────────────────────
|
||||
|
||||
def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]:
|
||||
"""获取片段的所有字幕
|
||||
|
||||
Returns:
|
||||
List[dict]: 字幕列表,按 start 时间排序
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = clip.config or {}
|
||||
subtitles = config.get("subtitles", [])
|
||||
# 按开始时间排序
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
return subtitles
|
||||
|
||||
def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取单条字幕"""
|
||||
subtitles = self.list_subtitles(clip_id)
|
||||
for s in subtitles:
|
||||
if s.get("id") == subtitle_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
def add_subtitle(
|
||||
self,
|
||||
clip_id: str,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
*,
|
||||
style: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""添加一条字幕
|
||||
|
||||
Args:
|
||||
clip_id: 片段 ID
|
||||
start: 开始时间(秒,相对于片段)
|
||||
end: 结束时间(秒)
|
||||
text: 字幕文本
|
||||
style: 样式配置(字体、大小、颜色、位置等)
|
||||
|
||||
Returns:
|
||||
dict: 新增的字幕条目
|
||||
|
||||
Raises:
|
||||
ValueError: 时间非法或文本为空
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||||
if not text.strip():
|
||||
raise ValueError("字幕文本不能为空")
|
||||
if end > clip.duration + 0.001:
|
||||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}")
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
subtitle = {
|
||||
"id": uuid4().hex,
|
||||
"start": round(start, 3),
|
||||
"end": round(end, 3),
|
||||
"text": text.strip(),
|
||||
"style": style or {},
|
||||
}
|
||||
subtitles.append(subtitle)
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
|
||||
config["subtitles"] = subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info(
|
||||
"添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs",
|
||||
clip_id,
|
||||
subtitle["id"],
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
return subtitle
|
||||
|
||||
def update_subtitle(
|
||||
self,
|
||||
clip_id: str,
|
||||
subtitle_id: str,
|
||||
*,
|
||||
start: Optional[float] = None,
|
||||
end: Optional[float] = None,
|
||||
text: Optional[str] = None,
|
||||
style: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""更新一条字幕
|
||||
|
||||
Returns:
|
||||
dict: 更新后的字幕条目
|
||||
|
||||
Raises:
|
||||
ValueError: 字幕不存在或参数非法
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
found = False
|
||||
for i, s in enumerate(subtitles):
|
||||
if s.get("id") == subtitle_id:
|
||||
# 更新字段
|
||||
updated_s = dict(s)
|
||||
if start is not None:
|
||||
updated_s["start"] = round(start, 3)
|
||||
if end is not None:
|
||||
updated_s["end"] = round(end, 3)
|
||||
if text is not None:
|
||||
if not text.strip():
|
||||
raise ValueError("字幕文本不能为空")
|
||||
updated_s["text"] = text.strip()
|
||||
if style is not None:
|
||||
updated_s["style"] = style
|
||||
|
||||
# 校验时间
|
||||
if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]:
|
||||
raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}")
|
||||
if updated_s["end"] > clip.duration + 0.001:
|
||||
raise ValueError("字幕结束时间不能超过片段时长")
|
||||
|
||||
subtitles[i] = updated_s
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise ValueError(f"字幕不存在: {subtitle_id}")
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
config["subtitles"] = subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||||
|
||||
return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)]
|
||||
|
||||
def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool:
|
||||
"""删除一条字幕
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
|
||||
if len(new_subtitles) == len(subtitles):
|
||||
return False
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
config["subtitles"] = new_subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||||
return True
|
||||
|
||||
def batch_update_subtitles(
|
||||
self,
|
||||
clip_id: str,
|
||||
subtitles: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""批量更新字幕(全量替换,用于批量编辑或导入)
|
||||
|
||||
Args:
|
||||
clip_id: 片段 ID
|
||||
subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留
|
||||
|
||||
Returns:
|
||||
List[dict]: 更新后的字幕列表
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
validated = []
|
||||
for s in subtitles:
|
||||
start = float(s.get("start", 0))
|
||||
end = float(s.get("end", 0))
|
||||
text = str(s.get("text", ""))
|
||||
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||||
if not text.strip():
|
||||
continue # 跳过空字幕
|
||||
if end > clip.duration + 0.001:
|
||||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end}")
|
||||
|
||||
subtitle_id = s.get("id") or uuid4().hex
|
||||
validated.append(
|
||||
{
|
||||
"id": subtitle_id,
|
||||
"start": round(start, 3),
|
||||
"end": round(end, 3),
|
||||
"text": text.strip(),
|
||||
"style": s.get("style", {}),
|
||||
}
|
||||
)
|
||||
|
||||
validated.sort(key=lambda s: s["start"])
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
config["subtitles"] = validated
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info(
|
||||
"批量更新字幕: clip_id=%s count=%d",
|
||||
clip_id,
|
||||
len(validated),
|
||||
)
|
||||
|
||||
return validated
|
||||
|
||||
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取计划及其所有片段
|
||||
|
||||
Returns:
|
||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||
"""
|
||||
plan = self.get_plan_or_raise(plan_id)
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
return {
|
||||
"plan": plan,
|
||||
"clips": clips,
|
||||
}
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
|
||||
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取渲染进度状态
|
||||
@@ -1026,85 +646,3 @@ class EditPlanService:
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
return self._plan_repo.update(updated)
|
||||
|
||||
# ── 复制计划 ────────────────────────────────────────────────────────────
|
||||
|
||||
def copy_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
new_name: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> EditPlan:
|
||||
"""复制一个剪辑计划(含所有片段配置)。
|
||||
|
||||
新计划状态为 editing,不含生成任务和结果记录。
|
||||
|
||||
Args:
|
||||
plan_id: 源计划 ID
|
||||
new_name: 新计划名称,不传则为「原名 - 副本」
|
||||
project_id: 新计划的项目 ID,不传则复用源计划
|
||||
|
||||
Returns:
|
||||
EditPlan: 新创建的计划
|
||||
|
||||
Raises:
|
||||
ValueError: 源计划不存在
|
||||
"""
|
||||
source = self.get_plan_or_raise(plan_id)
|
||||
source_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
|
||||
# 新计划名称
|
||||
name = new_name or f"{source.name} - 副本"
|
||||
new_project_id = project_id if project_id is not None else source.project_id
|
||||
|
||||
# 复制 plan 配置(去除渲染结果相关字段)
|
||||
new_config = dict(source.config)
|
||||
new_config.pop("rendered_url", None)
|
||||
new_config.pop("rendered_storage_key", None)
|
||||
new_config.pop("generation_task_id", None)
|
||||
|
||||
# 创建新计划
|
||||
new_plan = EditPlan.create(
|
||||
template_id=source.template_id,
|
||||
name=name,
|
||||
config=new_config,
|
||||
total_duration=source.total_duration,
|
||||
project_id=new_project_id,
|
||||
created_by_user_id=source.created_by_user_id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
# 强制切到 editing 状态
|
||||
if new_plan.status != EditPlanStatus.EDITING:
|
||||
try:
|
||||
new_plan.start_editing()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
created_plan = self._plan_repo.create(new_plan)
|
||||
logger.info(
|
||||
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
|
||||
plan_id,
|
||||
created_plan.id,
|
||||
name,
|
||||
len(source_clips),
|
||||
)
|
||||
|
||||
# 复制所有片段
|
||||
for clip in source_clips:
|
||||
new_clip = self.create_clip(
|
||||
plan_id=created_plan.id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id or "",
|
||||
text_content=clip.text_content or "",
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=clip.transition_duration or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
config=dict(clip.config) if clip.config else None,
|
||||
)
|
||||
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
|
||||
|
||||
return self.get_plan_or_raise(created_plan.id)
|
||||
|
||||
@@ -754,8 +754,8 @@ class EditTemplateService:
|
||||
template.bump_version() # 版本号 +1
|
||||
updated_template = self._template_repo.update(template)
|
||||
|
||||
# 批量删除旧的片段配置(走 repository,保证测试 stub 和真实行为一致)
|
||||
self._clip_config_repo.delete_by_template(template_id)
|
||||
# 批量删除旧的片段配置(外层事务统一提交)
|
||||
self._clip_config_repo.delete_by_template(template_id, commit=False)
|
||||
|
||||
# 创建新的片段配置
|
||||
created_configs: list[TemplateClipConfig] = []
|
||||
|
||||
@@ -142,8 +142,6 @@ def _finalize_render_success(
|
||||
plan.config["rendered_storage_key"] = storage_key
|
||||
if hasattr(plan, "total_duration") and duration > 0:
|
||||
plan.total_duration = duration
|
||||
if hasattr(plan, "result_count"):
|
||||
plan.result_count = 1
|
||||
plan.mark_completed()
|
||||
plan_repo.update(plan)
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ class SQLAlchemyEditPlanRepository:
|
||||
name=plan.name,
|
||||
status=plan.status,
|
||||
total_duration=plan.total_duration,
|
||||
result_count=plan.result_count,
|
||||
source_edit_plan_id=plan.source_edit_plan_id or None,
|
||||
project_id=plan.project_id or "",
|
||||
created_by_user_id=plan.created_by_user_id or "",
|
||||
@@ -120,7 +119,6 @@ class SQLAlchemyEditPlanRepository:
|
||||
model.name = plan.name
|
||||
model.status = plan.status
|
||||
model.total_duration = plan.total_duration
|
||||
model.result_count = plan.result_count
|
||||
model.source_edit_plan_id = plan.source_edit_plan_id or None
|
||||
model.project_id = plan.project_id or ""
|
||||
model.created_by_user_id = plan.created_by_user_id or ""
|
||||
@@ -154,7 +152,6 @@ class SQLAlchemyEditPlanRepository:
|
||||
name=model.name,
|
||||
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
|
||||
total_duration=model.total_duration or 0.0,
|
||||
result_count=int(model.result_count or 0),
|
||||
source_edit_plan_id=model.source_edit_plan_id or "",
|
||||
project_id=model.project_id or "",
|
||||
created_by_user_id=model.created_by_user_id or "",
|
||||
|
||||
@@ -172,7 +172,6 @@ class EditPlanModel(Base):
|
||||
name = Column(String(200), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="draft", index=True)
|
||||
total_duration = Column(Float, nullable=False, default=0.0)
|
||||
result_count = Column(Integer, nullable=False, default=0)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
|
||||
Regular → Executable
+9
-3
@@ -92,14 +92,20 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def delete_by_template(self, template_id: str) -> int:
|
||||
"""删除模板下所有片段配置,返回删除数量"""
|
||||
def delete_by_template(self, template_id: str, *, commit: bool = True) -> int:
|
||||
"""删除模板下所有片段配置,返回删除数量
|
||||
|
||||
Args:
|
||||
template_id: 模板ID
|
||||
commit: 是否提交事务,默认True。外层有事务控制时传False。
|
||||
"""
|
||||
count = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.delete()
|
||||
)
|
||||
self.session.commit()
|
||||
if commit:
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def count(self, *, template_id: Optional[str] = None) -> int:
|
||||
|
||||
@@ -42,7 +42,6 @@ class EditPlan:
|
||||
name: str
|
||||
status: EditPlanStatus = EditPlanStatus.DRAFT
|
||||
total_duration: float = 0.0
|
||||
result_count: int = 0
|
||||
source_edit_plan_id: str = ""
|
||||
project_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
@@ -58,7 +57,6 @@ class EditPlan:
|
||||
*,
|
||||
config: dict[str, Any] | None = None,
|
||||
total_duration: float = 0.0,
|
||||
result_count: int = 0,
|
||||
source_edit_plan_id: str = "",
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
@@ -75,7 +73,6 @@ class EditPlan:
|
||||
name=clean_name,
|
||||
status=EditPlanStatus.DRAFT,
|
||||
total_duration=total_duration,
|
||||
result_count=result_count,
|
||||
source_edit_plan_id=source_edit_plan_id.strip(),
|
||||
project_id=project_id.strip(),
|
||||
created_by_user_id=created_by_user_id.strip(),
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
- config_schemas: normalize_plan_config / normalize_template_config 默认值填充、部分覆盖、非标准字段保留
|
||||
- config_schemas: Pydantic 枚举校验(CoverType / TextPosition / BGMSource)
|
||||
- ai_tasks: run_ai_recommend / run_generate_cover stub 返回结构
|
||||
- edit_plans API: POST /{plan_id}/ai-recommend 正常/404/400
|
||||
- edit_plans API: POST /{plan_id}/generate-cover 正常/404
|
||||
- edit_plans API: create_plan config 标准化
|
||||
- edit_plans API: update_plan config 标准化
|
||||
- edit_templates API: create_template / update_template config 标准化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +12,6 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
@@ -271,270 +265,3 @@ class TestAIRunTasks:
|
||||
cover_type="upload",
|
||||
)
|
||||
assert result["type"] == "upload"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API 端点测试 — AI 推荐 & 封面生成
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
"""内存中的 EditPlan 仓储 stub(支持 clips)"""
|
||||
|
||||
def __init__(self):
|
||||
self._plans: dict[str, Any] = {}
|
||||
self._clips: dict[str, list] = {} # plan_id → [clip]
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-{self._counter:03d}"
|
||||
|
||||
def get(self, plan_id: str):
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan):
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan):
|
||||
if plan.id not in self._plans:
|
||||
raise ValueError(f"EditPlan {plan.id} not found")
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str):
|
||||
if plan_id not in self._plans:
|
||||
return False
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status:
|
||||
items = [p for p in items if p.status == status]
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, *, template_id=None, status=None):
|
||||
return len(list(self._plans.values()))
|
||||
|
||||
def delete_by_plan(self, plan_id: str):
|
||||
self._clips.pop(plan_id, None)
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
from app.auth import AuthenticatedUser
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
user = User(id="user-001", email="test@example.com", display_name="测试用户")
|
||||
return AuthenticatedUser(user=user)
|
||||
|
||||
|
||||
def _create_ai_test_app():
|
||||
"""创建带 stub 注入的测试 FastAPI 应用(支持 AI 端点)"""
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes import edit_plans as edit_plans_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
stub_repo = StubEditPlanRepository()
|
||||
|
||||
# Mock service methods that interact with DB
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: stub_repo
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user
|
||||
app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
|
||||
return app, stub_repo, cleanup
|
||||
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_client():
|
||||
app, stub_repo, cleanup = _create_ai_test_app()
|
||||
yield TestClient(app), stub_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
class TestAIRecommendEndpoint:
|
||||
def test_ai_recommend_success(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "测试计划")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.post(
|
||||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||||
json={"asset_ids": ["asset-1", "asset-2"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_id"] == plan.id
|
||||
assert "clips" in data
|
||||
assert len(data["clips"]) >= 2
|
||||
assert "config" in data
|
||||
assert data["total_duration"] > 0
|
||||
assert "confidence" in data
|
||||
|
||||
def test_ai_recommend_not_found(self, ai_client):
|
||||
c, repo = ai_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/nonexistent/ai-recommend",
|
||||
json={"asset_ids": ["asset-1"]},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_ai_recommend_rejects_rendering_status(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "渲染中计划")
|
||||
plan.start_editing()
|
||||
plan.start_rendering()
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.post(
|
||||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||||
json={"asset_ids": ["asset-1"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "当前计划状态" in resp.json()["detail"] or "编辑计划" in resp.json()["detail"]
|
||||
|
||||
def test_ai_recommend_with_custom_params(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "自定义参数计划")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.post(
|
||||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||||
json={
|
||||
"asset_ids": ["asset-1"],
|
||||
"editing_mode": "pip",
|
||||
"target_duration": 15.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_ai_recommend_invalid_duration(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "测试计划")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.post(
|
||||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||||
json={"asset_ids": ["asset-1"], "target_duration": -5.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestGenerateCoverEndpoint:
|
||||
def test_generate_cover_ai_frame(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "封面测试计划")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.post(
|
||||
f"/api/v1/edit-plans/{plan.id}/generate-cover",
|
||||
json={"asset_ids": ["asset-1"], "cover_type": "ai_frame"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_id"] == plan.id
|
||||
assert "cover" in data
|
||||
assert data["cover"]["type"] == "ai_frame"
|
||||
|
||||
def test_generate_cover_manual(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "手动封面计划")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.post(
|
||||
f"/api/v1/edit-plans/{plan.id}/generate-cover",
|
||||
json={"asset_ids": ["asset-1"], "cover_type": "manual", "frame_time": 3.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cover"]["type"] == "manual"
|
||||
assert data["cover"]["frame_time"] == 3.5
|
||||
|
||||
def test_generate_cover_not_found(self, ai_client):
|
||||
c, repo = ai_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/nonexistent/generate-cover",
|
||||
json={"asset_ids": []},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_generate_cover_default_type(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "默认封面计划")
|
||||
repo.create(plan)
|
||||
|
||||
# 不传 cover_type,默认 ai_frame
|
||||
resp = c.post(
|
||||
f"/api/v1/edit-plans/{plan.id}/generate-cover",
|
||||
json={"asset_ids": ["asset-1"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["cover"]["type"] == "ai_frame"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config 标准化集成测试(create/update plan & template)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigNormalizationInAPI:
|
||||
"""验证 create/update 端点自动标准化 config"""
|
||||
|
||||
def test_create_plan_normalizes_config(self, ai_client):
|
||||
c, repo = ai_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={
|
||||
"template_id": "tpl-001",
|
||||
"name": "标准化测试",
|
||||
"config": {"title": {"text": "自定义标题"}},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
config = resp.json()["config"]
|
||||
# 传入的 title.text 被保留
|
||||
assert config["title"]["text"] == "自定义标题"
|
||||
# 未传入的 title 字段填充默认值
|
||||
assert config["title"]["font"] == "思源黑体"
|
||||
# cover/bgm/subtitle 全部填充默认值
|
||||
assert config["cover"]["type"] == "ai_frame"
|
||||
assert config["bgm"]["volume"] == 0.3
|
||||
assert config["subtitle"]["position"] == "bottom"
|
||||
|
||||
def test_update_plan_normalizes_config(self, ai_client):
|
||||
c, repo = ai_client
|
||||
plan = EditPlan.create("tpl-001", "更新标准化测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}",
|
||||
json={"config": {"bgm": {"volume": 0.9}}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
config = resp.json()["config"]
|
||||
assert config["bgm"]["volume"] == 0.9
|
||||
assert config["bgm"]["source"] == "library"
|
||||
assert config["cover"]["type"] == "ai_frame"
|
||||
assert config["title"]["enabled"] is True
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
"""
|
||||
片段调整 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- PUT /clips/{clip_id}/speed - 调速
|
||||
- PUT /clips/{clip_id}/volume - 音量调节
|
||||
- PUT /clips/{clip_id}/trim - 裁剪
|
||||
- PUT /clips/{clip_id}/adjustments - 统一调整
|
||||
- POST /{plan_id}/clips/batch-speed - 批量调速
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
return list(self._plans.values())[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit]
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
return self._plans.pop(plan_id, None) is not None
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
return len(self._plans)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return len([c for c in self._clips.values() if c.plan_id == plan_id])
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return self._clips.pop(clip_id, None) is not None
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
before = len(self._clips)
|
||||
self._clips = {k: v for k, v in self._clips.items() if v.plan_id != plan_id}
|
||||
return before - len(self._clips)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_clip(clip_id, plan_id="plan-001", order=0, duration=10.0, speed=1.0):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type="video",
|
||||
order=order,
|
||||
asset_id="asset-001",
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=duration,
|
||||
transition_effect="cut",
|
||||
transition_duration=0.0,
|
||||
playback_speed=speed,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
clips = {
|
||||
"clip-001": _make_clip("clip-001", order=0, duration=10.0),
|
||||
"clip-002": _make_clip("clip-002", order=1, duration=15.0),
|
||||
"clip-003": _make_clip("clip-003", order=2, duration=20.0),
|
||||
}
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository(clips)
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
import app.api.routes.edit_plans_adjustments as adj_module
|
||||
|
||||
app.dependency_overrides[adj_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[adj_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[adj_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adj_client():
|
||||
app, plan_repo, clip_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 调速测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustSpeed:
|
||||
def test_speed_up(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 2.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 2.0
|
||||
assert data["clip_id"] == "clip-001"
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 2.0
|
||||
|
||||
def test_slow_down(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 0.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 0.5
|
||||
|
||||
def test_speed_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/speed",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_speed_out_of_range_low(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 0.1},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_speed_out_of_range_high(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 5.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_speed_default_value(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
# 验证默认 speed
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 音量调节测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustVolume:
|
||||
def test_set_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 0.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["volume"] == 0.5
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.config["volume"] == 0.5
|
||||
|
||||
def test_mute(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 0.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["volume"] == 0.0
|
||||
|
||||
def test_boost_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 1.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["volume"] == 1.5
|
||||
|
||||
def test_volume_out_of_range(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 3.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_volume_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/volume",
|
||||
json={"volume": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_volume(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 1.0},
|
||||
)
|
||||
data = resp.json()
|
||||
# 默认音量应该是 1.0
|
||||
assert data["volume"] == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 裁剪测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustTrim:
|
||||
def test_trim_start(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 2.0, "trim_end": 0.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 2.0
|
||||
assert data["trim_end"] == 0.0
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.config["trim_start"] == 2.0
|
||||
|
||||
def test_trim_both_ends(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 1.5, "trim_end": 2.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 1.5
|
||||
assert data["trim_end"] == 2.5
|
||||
|
||||
def test_trim_exceeds_duration(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
# 片段时长 10 秒,裁剪 8+3 = 11 > 10
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 8.0, "trim_end": 3.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "不能大于等于片段总时长" in resp.json()["detail"]
|
||||
|
||||
def test_trim_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/trim",
|
||||
json={"trim_start": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_trim_zero(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 1.0},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 0.0
|
||||
assert data["trim_end"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 统一调整测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustAll:
|
||||
def test_adjust_speed_and_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"speed": 1.5, "volume": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 1.5
|
||||
assert data["volume"] == 0.8
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 1.5
|
||||
assert clip.config["volume"] == 0.8
|
||||
|
||||
def test_adjust_all_four(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"speed": 2.0, "volume": 0.5, "trim_start": 1.0, "trim_end": 1.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 2.0
|
||||
assert data["volume"] == 0.5
|
||||
assert data["trim_start"] == 1.0
|
||||
assert data["trim_end"] == 1.0
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 2.0
|
||||
assert clip.config["volume"] == 0.5
|
||||
assert clip.config["trim_start"] == 1.0
|
||||
assert clip.config["trim_end"] == 1.0
|
||||
|
||||
def test_adjust_empty_body(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 保持默认值
|
||||
assert data["speed"] == 1.0
|
||||
assert data["volume"] == 1.0
|
||||
|
||||
def test_adjust_trim_exceeds(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"trim_start": 9.0, "trim_end": 2.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_adjust_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/adjustments",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 批量调速测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchSpeed:
|
||||
def test_batch_speed_all(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/clips/batch-speed",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 3
|
||||
assert data["plan_id"] == "plan-001"
|
||||
|
||||
for cid in ["clip-001", "clip-002", "clip-003"]:
|
||||
clip = clip_repo.get(cid)
|
||||
assert clip.playback_speed == 1.5
|
||||
|
||||
def test_batch_speed_plan_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/clips/batch-speed",
|
||||
json={"speed": 2.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_batch_speed_invalid(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/clips/batch-speed",
|
||||
json={"speed": 10.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -1,559 +0,0 @@
|
||||
"""
|
||||
封面管理 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /{plan_id}/cover - 获取封面配置
|
||||
- PUT /{plan_id}/cover - 更新封面配置
|
||||
- POST /{plan_id}/cover/extract - 从片段抽帧
|
||||
- POST /{plan_id}/cover/smart - 智能选帧
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
self._counter = 100
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-{self._counter:03d}"
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
if not plan.id:
|
||||
plan.id = self._next_id()
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
self._counter = 200
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-{self._counter:03d}"
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
return len(items)
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip.id = self._next_id()
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
if clip_id in self._clips:
|
||||
del self._clips[clip_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
||||
for cid in to_delete:
|
||||
del self._clips[cid]
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
def __init__(self, assets: dict | None = None):
|
||||
self._assets = assets or {}
|
||||
|
||||
def get(self, asset_id: str):
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
|
||||
class StubStorageService:
|
||||
def __init__(self):
|
||||
self.uploaded = {}
|
||||
self.downloaded = {}
|
||||
|
||||
def upload_file(self, file_or_path, storage_key, content_type="application/octet-stream"):
|
||||
self.uploaded[storage_key] = file_or_path
|
||||
return f"https://oss.example.com/{storage_key}"
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
return f"https://oss.example.com/{storage_key}"
|
||||
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
self.downloaded[storage_key] = local_path
|
||||
# 创建一个假文件(空文件也可以,因为抽帧会被 mock 掉)
|
||||
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(b"fake video data for testing")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001", config=None):
|
||||
if config is None:
|
||||
config = normalize_plan_config({})
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=config,
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_sample_clip(clip_id="clip-001", plan_id="plan-001", asset_id="asset-001", clip_type="video"):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=0,
|
||||
asset_id=asset_id,
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=10.0,
|
||||
transition_effect="none",
|
||||
transition_duration=0.0,
|
||||
playback_speed=1.0,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_cover as cover_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
# 创建 stub
|
||||
plan = _make_sample_plan()
|
||||
clip = _make_sample_clip()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository({clip.id: clip})
|
||||
|
||||
# 替换服务模块中的 Repository 类
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 覆盖依赖
|
||||
app.dependency_overrides[cover_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[cover_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[cover_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
# Mock storage 和 asset repo
|
||||
stub_storage = StubStorageService()
|
||||
stub_asset_repo = StubAssetRepository(
|
||||
{
|
||||
"asset-001": MagicMock(
|
||||
storage_key="videos/test.mp4",
|
||||
mime_type="video/mp4",
|
||||
),
|
||||
"asset-img": MagicMock(
|
||||
storage_key="images/test.jpg",
|
||||
mime_type="image/jpeg",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
app.dependency_overrides[cover_module.get_storage_service] = lambda: stub_storage
|
||||
app.dependency_overrides[cover_module.get_asset_repository] = lambda: stub_asset_repo
|
||||
|
||||
# 也需要覆盖 edit_plans 主模块的 auth(用于其他路由)
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, stub_storage, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cover_client():
|
||||
app, plan_repo, clip_repo, storage, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo, storage
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/cover 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCover:
|
||||
def test_get_default_cover(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/cover")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
assert data["image_url"] == ""
|
||||
assert data["frame_time"] is None
|
||||
|
||||
def test_get_cover_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/cover")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_cover_with_custom_config(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
# 更新 plan 的 cover 配置
|
||||
plan = plan_repo.get("plan-001")
|
||||
new_config = dict(plan.config)
|
||||
new_config["cover"] = {"type": "manual", "image_url": "https://example.com/cover.jpg", "frame_time": 5.5}
|
||||
plan.config = new_config
|
||||
plan_repo.update(plan)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/cover")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["image_url"] == "https://example.com/cover.jpg"
|
||||
assert data["frame_time"] == 5.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/cover 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCover:
|
||||
def test_update_cover_type_and_url(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "upload", "image_url": "https://example.com/uploaded.jpg"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "upload"
|
||||
assert data["image_url"] == "https://example.com/uploaded.jpg"
|
||||
|
||||
# 验证存储
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "upload"
|
||||
assert plan.config["cover"]["image_url"] == "https://example.com/uploaded.jpg"
|
||||
|
||||
def test_update_cover_frame_time(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "manual", "frame_time": 3.14},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["frame_time"] == 3.14
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["frame_time"] == 3.14
|
||||
|
||||
def test_update_cover_invalid_type(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "invalid_type"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_cover_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover",
|
||||
json={"type": "upload", "image_url": "test.jpg"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_cover_partial(self, cover_client):
|
||||
"""只更新 image_url,type 保持不变"""
|
||||
c, plan_repo, _, _ = cover_client
|
||||
# 先设置一个类型
|
||||
c.put("/api/v1/edit-plans/plan-001/cover", json={"type": "manual", "frame_time": 2.0})
|
||||
|
||||
# 只更新 image_url
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"image_url": "https://example.com/new.jpg"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual" # 保持不变
|
||||
assert data["image_url"] == "https://example.com/new.jpg"
|
||||
assert data["frame_time"] == 2.0 # 保持不变
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/cover/extract 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractCover:
|
||||
def test_extract_success(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
# mock ffmpeg 抽帧,直接创建输出文件
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": 2.5},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["frame_time"] == 2.5
|
||||
assert data["image_url"].startswith("https://oss.example.com/covers/")
|
||||
|
||||
# 验证 plan.config 已更新
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "manual"
|
||||
assert plan.config["cover"]["frame_time"] == 2.5
|
||||
|
||||
def test_extract_clip_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-nonexist", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_extract_plan_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_extract_clip_no_asset(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 创建一个没有 asset 的片段
|
||||
empty_clip = _make_sample_clip(clip_id="clip-empty", asset_id="")
|
||||
clip_repo.create(empty_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-empty", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "没有关联素材" in resp.json()["detail"]
|
||||
|
||||
def test_extract_clip_not_in_plan(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 创建属于另一个 plan 的片段
|
||||
other_clip = _make_sample_clip(clip_id="clip-other", plan_id="plan-other")
|
||||
clip_repo.create(other_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-other", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "不属于该剪辑计划" in resp.json()["detail"]
|
||||
|
||||
def test_extract_negative_frame_time(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": -1.0},
|
||||
)
|
||||
assert resp.status_code == 422 # pydantic 校验失败
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/cover/smart 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSmartCover:
|
||||
def test_smart_cover_with_clip_id(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={"clip_id": "clip-001"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
assert data["image_url"].startswith("https://oss.example.com/covers/")
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "ai_frame"
|
||||
|
||||
def test_smart_cover_auto_pick_first_video(self, cover_client):
|
||||
c, plan_repo, clip_repo, _ = cover_client
|
||||
# 添加多个片段,第一个视频应该被选中
|
||||
clip2 = _make_sample_clip(clip_id="clip-002", clip_type="audio", asset_id="asset-audio")
|
||||
clip2.order = 1
|
||||
clip_repo.create(clip2)
|
||||
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
|
||||
def test_smart_cover_clip_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={"clip_id": "clip-nonexist"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_smart_cover_no_video_clips(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 删除原有片段,添加纯音频片段
|
||||
clip_repo.delete("clip-001")
|
||||
audio_clip = _make_sample_clip(clip_id="clip-audio", clip_type="audio", asset_id="asset-001")
|
||||
clip_repo.create(audio_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "没有找到可用的视频片段" in resp.json()["detail"]
|
||||
|
||||
def test_smart_cover_plan_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover/smart",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -1,350 +0,0 @@
|
||||
"""
|
||||
导出设置 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /export-presets - 导出预设列表
|
||||
- GET /{plan_id}/export - 获取导出配置
|
||||
- PUT /{plan_id}/export - 更新导出配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
return list(self._plans.values())[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit]
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
return self._plans.pop(plan_id, None) is not None
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
return len(self._plans)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
return []
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return 0
|
||||
|
||||
def get(self, clip_id: str):
|
||||
return None
|
||||
|
||||
def create(self, clip):
|
||||
return clip
|
||||
|
||||
def update(self, clip):
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository()
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
import app.api.routes.edit_plans_export as export_module
|
||||
|
||||
app.dependency_overrides[export_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[export_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[export_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def export_client():
|
||||
app, plan_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportPresets:
|
||||
def test_list_all_presets(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/export-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 5
|
||||
assert len(data["items"]) == data["total"]
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "resolution" in first
|
||||
assert "fps" in first
|
||||
assert "video_bitrate" in first
|
||||
assert "format" in first
|
||||
assert "description" in first
|
||||
assert "size_hint" in first
|
||||
|
||||
def test_preset_has_valid_resolution(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/export-presets")
|
||||
data = resp.json()
|
||||
for item in data["items"]:
|
||||
assert "x" in item["resolution"]
|
||||
assert item["fps"] >= 15
|
||||
assert item["fps"] <= 60
|
||||
assert item["format"] in ("mp4", "mov")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/export 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetExportConfig:
|
||||
def test_default_export_config(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/export")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["resolution"] == "1080x1920"
|
||||
assert data["fps"] == 30
|
||||
assert data["video_bitrate"] == 8000
|
||||
assert data["audio_bitrate"] == 128
|
||||
assert data["format"] == "mp4"
|
||||
assert data["quality_preset"] == "balanced"
|
||||
assert data["watermark_enabled"] is False
|
||||
assert data["watermark_text"] == ""
|
||||
|
||||
def test_export_not_found(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/export")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/export 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateExportConfig:
|
||||
def test_update_resolution_and_fps(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "720x1280", "fps": 60},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["resolution"] == "720x1280"
|
||||
assert data["fps"] == 60
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["export"]["resolution"] == "720x1280"
|
||||
assert plan.config["export"]["fps"] == 60
|
||||
|
||||
def test_update_bitrate(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"video_bitrate": 12000, "audio_bitrate": 192},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["video_bitrate"] == 12000
|
||||
assert data["audio_bitrate"] == 192
|
||||
|
||||
def test_update_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"format": "mov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["format"] == "mov"
|
||||
|
||||
def test_invalid_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"format": "avi"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_quality_preset(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"quality_preset": "best"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["quality_preset"] == "best"
|
||||
|
||||
def test_invalid_quality_preset(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"quality_preset": "ultimate"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_watermark(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"watermark_enabled": True, "watermark_text": "我的视频"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["watermark_enabled"] is True
|
||||
assert data["watermark_text"] == "我的视频"
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["export"]["watermark_enabled"] is True
|
||||
assert plan.config["export"]["watermark_text"] == "我的视频"
|
||||
|
||||
def test_invalid_resolution_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "1080*1920"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_resolution_too_large(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "8000x8000"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_fps_out_of_range(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"fps": 120},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_export_not_found(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/export",
|
||||
json={"fps": 30},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_update_preserves_other_fields(self, export_client):
|
||||
c, _ = export_client
|
||||
# 先修改一个
|
||||
c.put("/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280"})
|
||||
# 再修改另一个
|
||||
resp = c.put("/api/v1/edit-plans/plan-001/export", json={"fps": 60})
|
||||
data = resp.json()
|
||||
# 分辨率应该保持
|
||||
assert data["resolution"] == "720x1280"
|
||||
# fps 更新了
|
||||
assert data["fps"] == 60
|
||||
# 其他默认值不变
|
||||
assert data["format"] == "mp4"
|
||||
assert data["video_bitrate"] == 8000
|
||||
@@ -1,476 +0,0 @@
|
||||
"""
|
||||
滤镜调色 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /filter-presets - 滤镜预设列表
|
||||
- GET /{plan_id}/filter - 获取滤镜配置
|
||||
- PUT /{plan_id}/filter - 更新滤镜配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.filter_presets import FILTER_PRESET_LIBRARY, build_ffmpeg_filter
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
return []
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return 0
|
||||
|
||||
def get(self, clip_id: str):
|
||||
return None
|
||||
|
||||
def create(self, clip):
|
||||
return clip
|
||||
|
||||
def update(self, clip):
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001", config=None):
|
||||
if config is None:
|
||||
config = normalize_plan_config({})
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=config,
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_filter as filter_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository()
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 主路由的依赖覆盖
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
# 滤镜路由的依赖覆盖
|
||||
app.dependency_overrides[filter_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[filter_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[filter_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def filter_client():
|
||||
app, plan_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFilterPresets:
|
||||
def test_list_all_presets(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == len(FILTER_PRESET_LIBRARY)
|
||||
assert data["total"] > 10
|
||||
assert len(data["items"]) == data["total"]
|
||||
# 验证字段
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "category" in first
|
||||
assert "description" in first
|
||||
assert "tags" in first
|
||||
|
||||
def test_filter_by_category_basic(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=basic")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "basic"
|
||||
|
||||
def test_filter_by_category_bw(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=bw")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "bw"
|
||||
|
||||
def test_filter_by_keyword(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=电影")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
# 至少包含电影感滤镜
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert any("电影" in n for n in names)
|
||||
|
||||
def test_filter_by_keyword_japanese(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=日系")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert data["items"][0]["name"] == "日系"
|
||||
|
||||
def test_filter_empty_result(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=不存在的滤镜")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
def test_filter_invalid_category(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=nonexistent")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/filter 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFilter:
|
||||
def test_get_default_filter(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/filter")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is False
|
||||
assert data["preset_id"] == "filter_none"
|
||||
assert data["intensity"] == 100
|
||||
assert data["brightness"] == 0.0
|
||||
assert data["contrast"] == 1.0
|
||||
assert data["saturation"] == 1.0
|
||||
assert data["warmth"] == 0.0
|
||||
|
||||
def test_get_filter_not_found(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/filter")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_filter_with_custom_config(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
plan = plan_repo.get("plan-001")
|
||||
new_config = dict(plan.config)
|
||||
new_config["filter"] = {
|
||||
"enabled": True,
|
||||
"preset_id": "filter_cinematic",
|
||||
"intensity": 80,
|
||||
"brightness": 0.1,
|
||||
"contrast": 1.2,
|
||||
"saturation": 0.9,
|
||||
"warmth": 0.3,
|
||||
}
|
||||
plan.config = new_config
|
||||
plan_repo.update(plan)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/filter")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["preset_id"] == "filter_cinematic"
|
||||
assert data["intensity"] == 80
|
||||
assert data["brightness"] == 0.1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/filter 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateFilter:
|
||||
def test_enable_filter(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["preset_id"] == "filter_cinematic"
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["filter"]["enabled"] is True
|
||||
assert plan.config["filter"]["preset_id"] == "filter_cinematic"
|
||||
|
||||
def test_adjust_intensity(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic", "intensity": 50},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["intensity"] == 50
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["filter"]["intensity"] == 50
|
||||
|
||||
def test_invalid_intensity_returns_422(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"intensity": 150},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_invalid_preset_returns_400(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"preset_id": "nonexistent_filter"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无效的滤镜预设" in resp.json()["detail"]
|
||||
|
||||
def test_filter_not_found(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/filter",
|
||||
json={"enabled": True},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_set_none_preset_disables_filter(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
# 先启用一个滤镜
|
||||
c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic"},
|
||||
)
|
||||
|
||||
# 再设为原图
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"preset_id": "filter_none"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["preset_id"] == "filter_none"
|
||||
assert data["enabled"] is False # 原图自动关闭
|
||||
|
||||
def test_partial_update(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
# 先设置完整配置
|
||||
c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "filter_warm",
|
||||
"intensity": 70,
|
||||
"brightness": 0.05,
|
||||
},
|
||||
)
|
||||
|
||||
# 只修改强度,其他保持不变
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"intensity": 90},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["intensity"] == 90
|
||||
assert data["preset_id"] == "filter_warm" # 保持不变
|
||||
assert data["enabled"] is True # 保持不变
|
||||
assert data["brightness"] == 0.05 # 保持不变
|
||||
|
||||
def test_custom_adjustments(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "filter_cinematic",
|
||||
"brightness": 0.1,
|
||||
"contrast": 1.3,
|
||||
"saturation": 1.2,
|
||||
"warmth": 0.2,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["brightness"] == 0.1
|
||||
assert data["contrast"] == 1.3
|
||||
assert data["saturation"] == 1.2
|
||||
assert data["warmth"] == 0.2
|
||||
|
||||
def test_invalid_brightness_returns_422(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"brightness": 2.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FFmpeg 滤镜生成测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildFFmpegFilter:
|
||||
def test_no_filter(self):
|
||||
assert build_ffmpeg_filter("filter_none", 100) == ""
|
||||
|
||||
def test_zero_intensity(self):
|
||||
assert build_ffmpeg_filter("filter_cinematic", 0) == ""
|
||||
|
||||
def test_invalid_preset(self):
|
||||
assert build_ffmpeg_filter("nonexistent", 100) == ""
|
||||
|
||||
def test_cinematic_full(self):
|
||||
result = build_ffmpeg_filter("filter_cinematic", 100)
|
||||
assert result.startswith("eq=")
|
||||
assert "contrast=" in result
|
||||
assert "saturation=" in result
|
||||
assert "gamma_r=" in result
|
||||
|
||||
def test_cinematic_half(self):
|
||||
full = build_ffmpeg_filter("filter_cinematic", 100)
|
||||
half = build_ffmpeg_filter("filter_cinematic", 50)
|
||||
assert full != half
|
||||
# 50% 强度的参数应该更接近原值
|
||||
assert "eq=" in half
|
||||
|
||||
def test_bw_filter(self):
|
||||
result = build_ffmpeg_filter("filter_bw", 100)
|
||||
assert "saturation=0" in result
|
||||
|
||||
def test_warm_filter(self):
|
||||
result = build_ffmpeg_filter("filter_warm", 100)
|
||||
assert "gamma_r=" in result
|
||||
assert "gamma_b=" in result
|
||||
@@ -1,754 +0,0 @@
|
||||
"""剪辑计划生成 API 单元测试 — Phase 8 任务 2.05.
|
||||
|
||||
覆盖 2 个新端点:
|
||||
POST /api/v1/edit-plans/{id}/generate — 触发剪辑渲染生成
|
||||
GET /api/v1/edit-plans/{id}/generation-status — 查询生成进度
|
||||
|
||||
使用 FastAPI TestClient + Stub Repository + dependency_overrides.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ── Stub Repositories ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
"""内存中模拟 EditPlan 仓储"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, EditPlan] = {}
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
*,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
items = list(self._store.values())
|
||||
if status:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(
|
||||
self,
|
||||
template_id: str,
|
||||
*,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
items = [p for p in self._store.values() if p.template_id == template_id]
|
||||
if status:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._store.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._store[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
if plan.id not in self._store:
|
||||
raise ValueError(f"EditPlan {plan.id} not found")
|
||||
self._store[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._store:
|
||||
del self._store[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status: Optional[EditPlanStatus] = None) -> int:
|
||||
items = list(self._store.values())
|
||||
if status:
|
||||
items = [p for p in items if p.status == status]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
"""内存中模拟 EditPlanClip 仓储"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, EditPlanClip] = {}
|
||||
|
||||
def list_by_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
status: Optional[EditPlanClipStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[EditPlanClip]:
|
||||
items = [c for c in self._store.values() if c.plan_id == plan_id]
|
||||
if status:
|
||||
items = [c for c in items if c.status == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._store.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._store[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if clip.id not in self._store:
|
||||
raise ValueError(f"EditPlanClip {clip.id} not found")
|
||||
self._store[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
if clip_id in self._store:
|
||||
del self._store[clip_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
to_delete = [c.id for c in self._store.values() if c.plan_id == plan_id]
|
||||
for cid in to_delete:
|
||||
del self._store[cid]
|
||||
return len(to_delete)
|
||||
|
||||
def count(
|
||||
self,
|
||||
plan_id: Optional[str] = None,
|
||||
*,
|
||||
status: Optional[EditPlanClipStatus] = None,
|
||||
) -> int:
|
||||
items = list(self._store.values())
|
||||
if plan_id:
|
||||
items = [c for c in items if c.plan_id == plan_id]
|
||||
if status:
|
||||
items = [c for c in items if c.status == status]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
"""内存中模拟 GenerationTask 仓储"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, Any] = {}
|
||||
|
||||
def create(self, task: Any) -> Any:
|
||||
self._store[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> Optional[Any]:
|
||||
return self._store.get(task_id)
|
||||
|
||||
def update(self, task: Any) -> Any:
|
||||
if task.id not in self._store:
|
||||
raise ValueError(f"GenerationTask {task.id} not found")
|
||||
self._store[task.id] = task
|
||||
return task
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if t.project_id == project_id]
|
||||
|
||||
def list_by_user(self, user_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return len(
|
||||
[
|
||||
t
|
||||
for t in self._store.values()
|
||||
if t.created_by_user_id == user_id and getattr(t, "status", "") == "pending"
|
||||
]
|
||||
)
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return len([t for t in self._store.values() if getattr(t, "status", "") == "pending"])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
|
||||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def list_by_user_filtered(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list:
|
||||
"""按用户+状态筛选任务列表(stub实现)。"""
|
||||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
if status:
|
||||
items = [t for t in items if str(t.status) == status]
|
||||
# 按创建时间倒序
|
||||
items.sort(key=lambda t: t.created_at or "", reverse=True)
|
||||
if offset:
|
||||
items = items[offset:]
|
||||
if limit is not None:
|
||||
items = items[:limit]
|
||||
return items
|
||||
|
||||
def count_by_user_filtered(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
) -> int:
|
||||
"""按用户+状态筛选计数(stub实现)。"""
|
||||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
if status:
|
||||
items = [t for t in items if str(t.status) == status]
|
||||
return len(items)
|
||||
|
||||
def list_by_project_filtered(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list:
|
||||
"""按项目+状态筛选任务列表(stub实现)。"""
|
||||
items = [t for t in self._store.values() if t.project_id == project_id]
|
||||
if status:
|
||||
items = [t for t in items if str(t.status) == status]
|
||||
# 按创建时间倒序
|
||||
items.sort(key=lambda t: t.created_at or "", reverse=True)
|
||||
if offset:
|
||||
items = items[offset:]
|
||||
if limit is not None:
|
||||
items = items[:limit]
|
||||
return items
|
||||
|
||||
def count_by_project_filtered(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
) -> int:
|
||||
"""按项目+状态筛选计数(stub实现)。"""
|
||||
items = [t for t in self._store.values() if t.project_id == project_id]
|
||||
if status:
|
||||
items = [t for t in items if str(t.status) == status]
|
||||
return len(items)
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUser:
|
||||
id: str = "user-001"
|
||||
email: str = "test@example.com"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAuthenticatedUser:
|
||||
user: FakeUser = field(default_factory=FakeUser)
|
||||
session_id: str | None = None
|
||||
token_type: str | None = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_repo() -> StubEditPlanRepository:
|
||||
return StubEditPlanRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clip_repo() -> StubEditPlanClipRepository:
|
||||
return StubEditPlanClipRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_task_repo() -> StubGenerationTaskRepository:
|
||||
return StubGenerationTaskRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> FastAPI:
|
||||
"""构建测试 FastAPI 应用,注入 Stub Repository"""
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
# 替换 Repository 类
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda session: plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda session: clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda session: gen_task_repo
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
def override_get_db_session():
|
||||
yield MagicMock()
|
||||
|
||||
def override_get_current_user():
|
||||
return FakeAuthenticatedUser()
|
||||
|
||||
test_app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
yield test_app
|
||||
|
||||
# 恢复
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app: FastAPI) -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_plan(
|
||||
name: str = "测试计划",
|
||||
template_id: str = "tmpl-001",
|
||||
status: EditPlanStatus = EditPlanStatus.DRAFT,
|
||||
**kwargs: Any,
|
||||
) -> EditPlan:
|
||||
plan = EditPlan.create(template_id=template_id, name=name, **kwargs)
|
||||
plan.status = status
|
||||
return plan
|
||||
|
||||
|
||||
def _make_clip(
|
||||
plan_id: str,
|
||||
clip_type: str = "MAIN",
|
||||
order: int = 1,
|
||||
asset_id: str = "assets/video.mp4",
|
||||
status: EditPlanClipStatus = EditPlanClipStatus.PENDING,
|
||||
**kwargs: Any,
|
||||
) -> EditPlanClip:
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=order,
|
||||
asset_id=asset_id,
|
||||
**kwargs,
|
||||
)
|
||||
clip.status = status
|
||||
return clip
|
||||
|
||||
|
||||
# ── POST /api/v1/edit-plans/{id}/generate ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestGeneratePlan:
|
||||
def test_generate_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""editing 状态 + pending 片段 → 触发成功"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_id"] == plan.id
|
||||
assert data["plan_status"] == "rendering"
|
||||
assert data["clip_count"] == 1
|
||||
assert "generation_task_id" in data
|
||||
|
||||
# 验证计划状态已更新
|
||||
updated = plan_repo.get(plan.id)
|
||||
assert updated.status == EditPlanStatus.RENDERING
|
||||
|
||||
# 验证片段状态已更新为 ready
|
||||
updated_clip = clip_repo.get(clip.id)
|
||||
assert updated_clip.status == EditPlanClipStatus.READY
|
||||
|
||||
def test_generate_not_found(self, client: TestClient) -> None:
|
||||
"""计划不存在 → 404"""
|
||||
resp = client.post("/api/v1/edit-plans/nonexistent/generate")
|
||||
assert resp.status_code == 404
|
||||
assert "不存在" in resp.json()["detail"]
|
||||
|
||||
def test_generate_draft_auto_transition_to_editing(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
) -> None:
|
||||
"""draft 状态自动转 editing(自动兜底),然后因 0 片段报错"""
|
||||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||||
plan_repo.create(plan)
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
# draft 自动转 editing,但没有片段所以还是 400
|
||||
assert resp.status_code == 400
|
||||
assert "请先添加片段后再生成视频" in resp.json()["detail"]
|
||||
# 验证状态已自动转为 editing
|
||||
updated_plan = plan_repo.get(plan.id)
|
||||
assert updated_plan is not None
|
||||
assert updated_plan.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_generate_wrong_status_rendering(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
) -> None:
|
||||
"""rendering 状态 → 400"""
|
||||
plan = _make_plan(status=EditPlanStatus.RENDERING)
|
||||
plan_repo.create(plan)
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_generate_wrong_status_completed(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
) -> None:
|
||||
"""completed 状态 → 400"""
|
||||
plan = _make_plan(status=EditPlanStatus.COMPLETED)
|
||||
plan_repo.create(plan)
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_generate_no_clips(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
) -> None:
|
||||
"""editing 状态但没有片段 → 400"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
assert resp.status_code == 400
|
||||
assert "片段" in resp.json()["detail"]
|
||||
|
||||
def test_generate_with_ready_clips(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""已有 ready 状态的片段也可以触发生成"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.READY)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_status"] == "rendering"
|
||||
|
||||
def test_generate_multiple_clips(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""多个片段全部从 pending 转为 ready"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
for i in range(3):
|
||||
clip = _make_clip(plan.id, order=i + 1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["clip_count"] == 3
|
||||
|
||||
# 验证所有片段都变为 ready
|
||||
clips = clip_repo.list_by_plan(plan.id)
|
||||
assert all(c.status == EditPlanClipStatus.READY for c in clips)
|
||||
|
||||
|
||||
# ── GET /api/v1/edit-plans/{id}/generation-status ─────────────────────────────
|
||||
|
||||
|
||||
class TestGetGenerationStatus:
|
||||
def test_status_not_found(self, client: TestClient) -> None:
|
||||
"""计划不存在 → 404"""
|
||||
resp = client.get("/api/v1/edit-plans/nonexistent/generation-status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_status_draft_plan(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""draft 状态计划的生成状态"""
|
||||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_id"] == plan.id
|
||||
assert data["plan_status"] == "draft"
|
||||
assert data["generation_task_id"] is None
|
||||
assert len(data["clips"]) == 1
|
||||
assert data["clips"][0]["status"] == "pending"
|
||||
|
||||
def test_status_rendering_plan(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""rendering 状态计划的生成状态"""
|
||||
plan = _make_plan(status=EditPlanStatus.RENDERING)
|
||||
plan.config["generation_task_id"] = "gen-task-001"
|
||||
plan_repo.create(plan)
|
||||
|
||||
clip1 = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
|
||||
clip2 = _make_clip(plan.id, order=2, status=EditPlanClipStatus.READY)
|
||||
clip_repo.create(clip1)
|
||||
clip_repo.create(clip2)
|
||||
|
||||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_status"] == "rendering"
|
||||
assert data["generation_task_id"] == "gen-task-001"
|
||||
assert len(data["clips"]) == 2
|
||||
statuses = {c["status"] for c in data["clips"]}
|
||||
assert "rendered" in statuses
|
||||
assert "ready" in statuses
|
||||
|
||||
def test_status_completed_plan(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""completed 状态计划的生成状态"""
|
||||
plan = _make_plan(status=EditPlanStatus.COMPLETED)
|
||||
plan.config["generation_task_id"] = "gen-task-002"
|
||||
plan.config["rendered_url"] = "https://oss.example.com/rendered/output.mp4"
|
||||
plan_repo.create(plan)
|
||||
|
||||
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
|
||||
clip_repo.create(clip)
|
||||
|
||||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_status"] == "completed"
|
||||
assert data["generation_task_id"] == "gen-task-002"
|
||||
assert len(data["clips"]) == 1
|
||||
assert data["clips"][0]["status"] == "rendered"
|
||||
|
||||
def test_status_clip_fields(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""验证片段状态返回的字段完整性"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(
|
||||
plan.id,
|
||||
clip_type="INTRO",
|
||||
order=1,
|
||||
asset_id="assets/intro.mp4",
|
||||
duration=5.0,
|
||||
)
|
||||
clip.text_content = "欢迎观看"
|
||||
clip_repo.create(clip)
|
||||
|
||||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
clip_data = data["clips"][0]
|
||||
assert clip_data["clip_id"] == clip.id
|
||||
assert clip_data["clip_type"] == "INTRO"
|
||||
assert clip_data["order"] == 1
|
||||
assert clip_data["asset_id"] == "assets/intro.mp4"
|
||||
assert clip_data["text_content"] == "欢迎观看"
|
||||
assert clip_data["duration"] == 5.0
|
||||
|
||||
def test_status_no_clips(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
) -> None:
|
||||
"""没有片段的计划也能查询状态"""
|
||||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||||
plan_repo.create(plan)
|
||||
|
||||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["clips"] == []
|
||||
|
||||
|
||||
# ── Response Schema 验证 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResponseSchema:
|
||||
def test_generate_response_structure(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""验证 generate 端点响应结构"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clip_count"}
|
||||
assert set(data.keys()) == expected_keys
|
||||
|
||||
def test_generation_status_response_structure(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
) -> None:
|
||||
"""验证 generation-status 端点响应结构"""
|
||||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||||
plan_repo.create(plan)
|
||||
|
||||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
expected_keys = {
|
||||
"plan_id",
|
||||
"plan_status",
|
||||
"generation_task_id",
|
||||
"generation_task_status",
|
||||
"progress",
|
||||
"video_url",
|
||||
"error_message",
|
||||
"clips",
|
||||
}
|
||||
assert set(data.keys()) == expected_keys
|
||||
|
||||
|
||||
# ── P0-1: 生成接口错误处理 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGeneratePlanErrorHandling:
|
||||
"""P0-1: generate 端点异常时返回明确错误信息,不裸 500"""
|
||||
|
||||
def test_generate_internal_error_returns_clear_message(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""核心流程抛异常 → 500 + 用户友好的错误信息(不暴露技术细节)"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
# 模拟 Celery 调度失败
|
||||
mock_celery.send_task.side_effect = RuntimeError("Redis 连接超时")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 500
|
||||
data = resp.json()
|
||||
# 验证返回了用户友好的错误信息,不暴露技术细节
|
||||
assert "生成失败" in data["detail"]
|
||||
assert "RuntimeError" not in data["detail"]
|
||||
assert "Redis" not in data["detail"]
|
||||
|
||||
def test_generate_error_rolls_back_plan_status(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""异常时将计划标记为 failed(RENDERING → FAILED 是合法流转)"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task.side_effect = RuntimeError("调度失败")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 500
|
||||
# 计划状态应变为 failed
|
||||
updated = plan_repo.get(plan.id)
|
||||
assert updated.status == EditPlanStatus.FAILED
|
||||
|
||||
def test_generate_error_detail_is_user_friendly(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""错误信息对用户友好,不暴露技术细节(异常类型、内部错误信息)"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task.side_effect = ConnectionError("Broker 不可达")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 500
|
||||
detail = resp.json()["detail"]
|
||||
# 验证不暴露技术细节
|
||||
assert "ConnectionError" not in detail
|
||||
assert "Broker 不可达" not in detail
|
||||
# 验证返回了用户友好的提示
|
||||
assert "生成失败" in detail
|
||||
@@ -320,77 +320,10 @@ class TestEditPlanServiceCRUD:
|
||||
with pytest.raises(ValueError, match="剪辑计划不存在"):
|
||||
svc.get_plan_or_raise("nonexistent")
|
||||
|
||||
def test_list_plans(self):
|
||||
svc = _make_service()
|
||||
svc.create_plan("tpl-001", "计划1")
|
||||
svc.create_plan("tpl-001", "计划2")
|
||||
result = svc.list_plans()
|
||||
assert len(result) == 2
|
||||
|
||||
def test_list_plans_by_template(self):
|
||||
svc = _make_service()
|
||||
svc.create_plan("tpl-001", "计划1")
|
||||
svc.create_plan("tpl-002", "计划2")
|
||||
result = svc.list_plans(template_id="tpl-001")
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "计划1"
|
||||
|
||||
def test_list_plans_by_status(self):
|
||||
svc = _make_service()
|
||||
p1 = svc.create_plan("tpl-001", "计划1")
|
||||
svc.create_plan("tpl-001", "计划2")
|
||||
svc.transition_status(p1.id, EditPlanStatus.EDITING)
|
||||
result = svc.list_plans(status=EditPlanStatus.EDITING)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_count_plans(self):
|
||||
svc = _make_service()
|
||||
svc.create_plan("tpl-001", "计划1")
|
||||
svc.create_plan("tpl-001", "计划2")
|
||||
assert svc.count_plans() == 2
|
||||
|
||||
def test_update_plan_name(self):
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "原名")
|
||||
updated = svc.update_plan(p.id, name="新名")
|
||||
assert updated.name == "新名"
|
||||
|
||||
def test_update_plan_not_found_raises(self):
|
||||
svc = _make_service()
|
||||
with pytest.raises(ValueError, match="剪辑计划不存在"):
|
||||
svc.update_plan("nonexistent", name="新名")
|
||||
|
||||
def test_delete_plan(self):
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "要删除")
|
||||
assert svc.delete_plan(p.id) is True
|
||||
assert svc.get_plan(p.id) is None
|
||||
|
||||
def test_delete_plan_not_found(self):
|
||||
svc = _make_service()
|
||||
assert svc.delete_plan("nonexistent") is False
|
||||
|
||||
def test_delete_plan_also_deletes_clips(self):
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "带片段")
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
svc.create_clip(p.id, "main", 1)
|
||||
assert svc.count_clips(p.id) == 2
|
||||
svc.delete_plan(p.id)
|
||||
# 片段应被一并删除
|
||||
assert svc.count_clips(p.id) == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 状态机流转测试
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestStatusTransitions:
|
||||
"""状态机流转测试"""
|
||||
|
||||
def test_transition_draft_to_editing(self):
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
result = svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
assert result.status == EditPlanStatus.EDITING
|
||||
@@ -600,22 +533,6 @@ class TestGenerationWorkflow:
|
||||
clips = svc.list_clips(p.id)
|
||||
for c in clips:
|
||||
assert c.status == EditPlanClipStatus.READY
|
||||
|
||||
def test_get_plan_with_clips(self):
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
svc.create_clip(p.id, "main", 1)
|
||||
result = svc.get_plan_with_clips(p.id)
|
||||
assert result["plan"].id == p.id
|
||||
assert len(result["clips"]) == 2
|
||||
|
||||
def test_get_plan_with_clips_not_found(self):
|
||||
svc = _make_service()
|
||||
with pytest.raises(ValueError, match="剪辑计划不存在"):
|
||||
svc.get_plan_with_clips("nonexistent")
|
||||
|
||||
def test_get_generation_status(self):
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
@@ -849,234 +766,6 @@ class TestClipSplit:
|
||||
assert right.asset_id == "asset-001"
|
||||
|
||||
|
||||
class TestSubtitleManagement:
|
||||
"""字幕管理测试"""
|
||||
|
||||
def test_add_subtitle_basic(self):
|
||||
"""基础:添加一条字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
subtitle = svc.add_subtitle(clip.id, start=1.0, end=3.0, text="大家好")
|
||||
|
||||
assert subtitle["text"] == "大家好"
|
||||
assert subtitle["start"] == 1.0
|
||||
assert subtitle["end"] == 3.0
|
||||
assert "id" in subtitle
|
||||
assert len(subtitle["id"]) > 0
|
||||
|
||||
def test_add_subtitle_with_style(self):
|
||||
"""添加带样式的字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
style = {"font_size": 24, "color": "#ffffff", "position": "bottom"}
|
||||
subtitle = svc.add_subtitle(clip.id, start=0.0, end=2.0, text="测试", style=style)
|
||||
|
||||
assert subtitle["style"]["font_size"] == 24
|
||||
assert subtitle["style"]["color"] == "#ffffff"
|
||||
|
||||
def test_list_subtitles_sorted_by_time(self):
|
||||
"""字幕列表按时间排序"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
svc.add_subtitle(clip.id, start=5.0, end=6.0, text="第二")
|
||||
svc.add_subtitle(clip.id, start=1.0, end=2.0, text="第一")
|
||||
svc.add_subtitle(clip.id, start=8.0, end=9.0, text="第三")
|
||||
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert len(subtitles) == 3
|
||||
assert subtitles[0]["text"] == "第一"
|
||||
assert subtitles[1]["text"] == "第二"
|
||||
assert subtitles[2]["text"] == "第三"
|
||||
|
||||
def test_add_subtitle_invalid_time_raises(self):
|
||||
"""非法时间报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
# 开始时间为负
|
||||
with pytest.raises(ValueError, match="时间非法"):
|
||||
svc.add_subtitle(clip.id, start=-1.0, end=2.0, text="test")
|
||||
|
||||
# 结束时间 <= 开始时间
|
||||
with pytest.raises(ValueError, match="时间非法"):
|
||||
svc.add_subtitle(clip.id, start=5.0, end=3.0, text="test")
|
||||
|
||||
# 超过片段时长
|
||||
with pytest.raises(ValueError, match="不能超过片段时长"):
|
||||
svc.add_subtitle(clip.id, start=8.0, end=15.0, text="test")
|
||||
|
||||
def test_add_subtitle_empty_text_raises(self):
|
||||
"""空文本报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
svc.add_subtitle(clip.id, start=1.0, end=2.0, text=" ")
|
||||
|
||||
def test_get_subtitle(self):
|
||||
"""获取单条字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试")
|
||||
found = svc.get_subtitle(clip.id, sub["id"])
|
||||
|
||||
assert found is not None
|
||||
assert found["text"] == "测试"
|
||||
|
||||
# 不存在的返回 None
|
||||
assert svc.get_subtitle(clip.id, "nonexistent") is None
|
||||
|
||||
def test_update_subtitle_text(self):
|
||||
"""更新字幕文本"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原文")
|
||||
updated = svc.update_subtitle(clip.id, sub["id"], text="修改后")
|
||||
|
||||
assert updated["text"] == "修改后"
|
||||
assert updated["start"] == 1.0 # 时间不变
|
||||
|
||||
def test_update_subtitle_time(self):
|
||||
"""更新字幕时间"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试")
|
||||
updated = svc.update_subtitle(clip.id, sub["id"], start=3.0, end=5.0)
|
||||
|
||||
assert updated["start"] == 3.0
|
||||
assert updated["end"] == 5.0
|
||||
|
||||
def test_update_subtitle_not_found_raises(self):
|
||||
"""更新不存在的字幕报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="字幕不存在"):
|
||||
svc.update_subtitle(clip.id, "fake-id", text="test")
|
||||
|
||||
def test_delete_subtitle(self):
|
||||
"""删除字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="要删的")
|
||||
assert svc.count_clips(p.id) == 1 # 片段还在
|
||||
|
||||
deleted = svc.delete_subtitle(clip.id, sub["id"])
|
||||
assert deleted is True
|
||||
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert len(subtitles) == 0
|
||||
|
||||
def test_delete_subtitle_not_found(self):
|
||||
"""删除不存在的字幕返回 False"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
deleted = svc.delete_subtitle(clip.id, "nonexistent")
|
||||
assert deleted is False
|
||||
|
||||
def test_batch_update_subtitles(self):
|
||||
"""批量更新字幕(全量替换)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=20.0)
|
||||
|
||||
# 先加一条
|
||||
svc.add_subtitle(clip.id, start=1.0, end=2.0, text="旧字幕")
|
||||
|
||||
# 全量替换为 3 条
|
||||
new_subs = [
|
||||
{"start": 0.0, "end": 3.0, "text": "第一条"},
|
||||
{"start": 4.0, "end": 7.0, "text": "第二条"},
|
||||
{"start": 8.0, "end": 12.0, "text": "第三条"},
|
||||
]
|
||||
result = svc.batch_update_subtitles(clip.id, new_subs)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0]["text"] == "第一条"
|
||||
# 都有 id
|
||||
assert all("id" in s for s in result)
|
||||
# 旧字幕没了
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert len(subtitles) == 3
|
||||
|
||||
def test_batch_update_preserves_existing_ids(self):
|
||||
"""批量更新时已有 id 的字幕保留原 id"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原字幕")
|
||||
original_id = sub["id"]
|
||||
|
||||
# 带 id 批量更新,修改文本
|
||||
updated_list = svc.batch_update_subtitles(
|
||||
clip.id,
|
||||
[{"id": original_id, "start": 1.0, "end": 3.0, "text": "修改了"}],
|
||||
)
|
||||
|
||||
assert len(updated_list) == 1
|
||||
assert updated_list[0]["id"] == original_id
|
||||
assert updated_list[0]["text"] == "修改了"
|
||||
|
||||
def test_batch_update_skips_empty_text(self):
|
||||
"""批量更新时空文本自动跳过"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
subs = [
|
||||
{"start": 0.0, "end": 1.0, "text": "有效"},
|
||||
{"start": 2.0, "end": 3.0, "text": " "}, # 空白,跳过
|
||||
{"start": 4.0, "end": 5.0, "text": "也有效"},
|
||||
]
|
||||
result = svc.batch_update_subtitles(clip.id, subs)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_empty_clip_returns_empty_list(self):
|
||||
"""没有字幕的片段返回空列表"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert subtitles == []
|
||||
|
||||
|
||||
class TestClipMerge:
|
||||
"""片段合并测试"""
|
||||
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
"""
|
||||
转场特效 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /transition-presets - 转场预设列表
|
||||
- PUT /clips/{clip_id}/transition - 设置单个片段转场
|
||||
- POST /{plan_id}/transitions/batch - 批量设置转场
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.transition_presets import TRANSITION_PRESET_LIBRARY
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-new{self._counter}"
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
return len(items)
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip.id = self._next_id()
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
if clip_id in self._clips:
|
||||
del self._clips[clip_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
||||
for cid in to_delete:
|
||||
del self._clips[cid]
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_clip(clip_id, plan_id="plan-001", order=0, transition_effect="cut", transition_duration=0.0):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type="video",
|
||||
order=order,
|
||||
asset_id="asset-001",
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=10.0,
|
||||
transition_effect=transition_effect,
|
||||
transition_duration=transition_duration,
|
||||
playback_speed=1.0,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_transitions as transitions_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
clips = {
|
||||
"clip-001": _make_clip("clip-001", order=0),
|
||||
"clip-002": _make_clip("clip-002", order=1),
|
||||
"clip-003": _make_clip("clip-003", order=2),
|
||||
"clip-004": _make_clip("clip-004", order=3),
|
||||
}
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository(clips)
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 覆盖依赖
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
app.dependency_overrides[transitions_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[transitions_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[transitions_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transition_client():
|
||||
app, plan_repo, clip_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transition Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransitionPresets:
|
||||
def test_list_all_presets(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == len(TRANSITION_PRESET_LIBRARY)
|
||||
assert data["total"] > 10
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "category" in first
|
||||
assert "default_duration" in first
|
||||
assert "min_duration" in first
|
||||
assert "max_duration" in first
|
||||
|
||||
def test_filter_by_category_fade(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?category=fade")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "fade"
|
||||
|
||||
def test_filter_by_category_slide(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?category=slide")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 4
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "slide"
|
||||
|
||||
def test_filter_by_keyword(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?keyword=模糊")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert any("模糊" in n for n in names)
|
||||
|
||||
def test_filter_empty_result(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?keyword=不存在的转场")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_contains_none_transition(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?category=basic")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
ids = [item["id"] for item in data["items"]]
|
||||
assert "transition_none" in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /clips/{clip_id}/transition 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateClipTransition:
|
||||
def test_set_fade_transition(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade", "duration": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["clip_id"] == "clip-001"
|
||||
assert data["effect"] == "fade"
|
||||
assert data["duration"] == 0.8
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.transition_effect == "fade"
|
||||
assert clip.transition_duration == 0.8
|
||||
|
||||
def test_set_none_transition(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_none"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["effect"] == "cut"
|
||||
assert data["duration"] == 0.0
|
||||
|
||||
def test_use_default_duration(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
# 不传 duration,使用预设默认值
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["effect"] == "fade"
|
||||
assert data["duration"] > 0 # 使用默认值
|
||||
|
||||
def test_invalid_effect(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "invalid_effect"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无效的转场效果" in resp.json()["detail"]
|
||||
|
||||
def test_clip_not_found(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/transition",
|
||||
json={"effect": "transition_fade"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_negative_duration_422(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade", "duration": -0.5},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_duration_clamped_to_max(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
# 传一个超过最大值的时长,应该被钳制
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade", "duration": 10.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# fade 最大 2.0s
|
||||
assert data["duration"] <= 2.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/transitions/batch 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchUpdateTransitions:
|
||||
def test_batch_all(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "duration": 0.5, "apply_to": "all"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 4 # 4个片段
|
||||
|
||||
for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]:
|
||||
clip = clip_repo.get(cid)
|
||||
assert clip.transition_effect == "fade"
|
||||
assert clip.transition_duration == 0.5
|
||||
|
||||
def test_batch_except_first(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "apply_to": "except_first"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 3
|
||||
|
||||
# 第一个不变
|
||||
assert clip_repo.get("clip-001").transition_effect == "cut"
|
||||
# 其余三个被更新
|
||||
for cid in ["clip-002", "clip-003", "clip-004"]:
|
||||
assert clip_repo.get(cid).transition_effect == "fade"
|
||||
|
||||
def test_batch_except_last(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_slideleft", "apply_to": "except_last"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 3
|
||||
|
||||
# 最后一个不变
|
||||
assert clip_repo.get("clip-004").transition_effect == "cut"
|
||||
# 前三个被更新
|
||||
for cid in ["clip-001", "clip-002", "clip-003"]:
|
||||
assert clip_repo.get(cid).transition_effect == "slideleft"
|
||||
|
||||
def test_batch_middle(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_dissolve", "apply_to": "middle"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 2 # 4个片段,中间2个
|
||||
|
||||
# 首尾不变
|
||||
assert clip_repo.get("clip-001").transition_effect == "cut"
|
||||
assert clip_repo.get("clip-004").transition_effect == "cut"
|
||||
# 中间被更新
|
||||
assert clip_repo.get("clip-002").transition_effect == "dissolve"
|
||||
assert clip_repo.get("clip-003").transition_effect == "dissolve"
|
||||
|
||||
def test_batch_invalid_effect(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "invalid"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_batch_plan_not_found(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/transitions/batch",
|
||||
json={"effect": "transition_fade"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_batch_invalid_apply_to(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "apply_to": "invalid"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_batch_none_transition(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
# 先设一个转场
|
||||
c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "apply_to": "all"},
|
||||
)
|
||||
# 再全部设为无
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_none", "apply_to": "all"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["updated_count"] == 4
|
||||
|
||||
for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]:
|
||||
clip = clip_repo.get(cid)
|
||||
assert clip.transition_effect == "cut"
|
||||
assert clip.transition_duration == 0.0
|
||||
@@ -1,906 +0,0 @@
|
||||
"""
|
||||
edit_plans.py 剪辑计划 API 端点单元测试
|
||||
|
||||
覆盖(25+ 测试用例):
|
||||
- 创建:正常创建、空名称 400、空 template_id 422
|
||||
- 列表:默认分页、按状态筛选、按模板筛选、无效状态 400
|
||||
- 详情:正常获取、不存在 404
|
||||
- 更新:基础字段更新、状态机合法流转、状态机非法流转 400、不存在 404、无效状态值 400
|
||||
- 删除:正常删除、不存在 404
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
"""内存中的 EditPlan 仓储 stub"""
|
||||
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-{self._counter:03d}"
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
*,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(
|
||||
self,
|
||||
template_id: str,
|
||||
*,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
if plan.id not in self._plans:
|
||||
raise ValueError(f"EditPlan {plan.id} not found")
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id not in self._plans:
|
||||
return False
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> None:
|
||||
"""按 plan_id 删除关联片段(stub 实现:无操作)。"""
|
||||
pass
|
||||
|
||||
def count(
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
) -> int:
|
||||
items = list(self._plans.values())
|
||||
if template_id:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
return len(items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
"""构造 AuthenticatedUser mock"""
|
||||
from app.auth import AuthenticatedUser
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
user = User(
|
||||
id="user-001",
|
||||
email="test@example.com",
|
||||
display_name="测试用户",
|
||||
)
|
||||
return AuthenticatedUser(user=user)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
"""创建带 stub 注入的测试 FastAPI 应用"""
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes import edit_plans as edit_plans_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
stub_repo = StubEditPlanRepository()
|
||||
|
||||
# 替换服务模块中的 Repository 类
|
||||
original_plan_repo_class = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo_class = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_generation_task_repo_class = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: stub_repo
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# 覆盖认证依赖
|
||||
app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user
|
||||
app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo_class
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo_class
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_generation_task_repo_class
|
||||
|
||||
return app, stub_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app, stub_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), stub_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 创建测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreatePlan:
|
||||
"""创建剪辑计划测试。
|
||||
|
||||
注意:创建计划时会从模板生成 clips,这里 mock 掉模板服务和生成器,
|
||||
专注验证 API 层参数传递和响应格式。
|
||||
"""
|
||||
|
||||
def _make_test_plan(self, plan_id="plan-001", template_id="tpl-001", name="测试计划"):
|
||||
"""构造一个测试用 EditPlan"""
|
||||
return EditPlan.create(
|
||||
template_id=template_id,
|
||||
name=name,
|
||||
config=normalize_plan_config({}),
|
||||
total_duration=15.0,
|
||||
)
|
||||
|
||||
@patch("app.api.routes.edit_plans.EditTemplateService")
|
||||
@patch("app.services.PlanGeneratorService")
|
||||
def test_create_success(self, mock_generator_cls, mock_template_svc_cls, client):
|
||||
c, repo = client
|
||||
|
||||
# Setup mock 模板服务
|
||||
mock_template_svc = MagicMock()
|
||||
mock_template_svc.get_template_or_raise.return_value = MagicMock(
|
||||
id="tpl-001",
|
||||
name="测试模板",
|
||||
config={},
|
||||
)
|
||||
mock_template_svc.list_clip_configs.return_value = []
|
||||
mock_template_svc_cls.return_value = mock_template_svc
|
||||
|
||||
# Setup mock 生成器
|
||||
mock_gen = MagicMock()
|
||||
test_plan = self._make_test_plan(name="我的剪辑计划")
|
||||
test_plan.status = EditPlanStatus.EDITING
|
||||
# 把 plan 存到 stub repo,这样后续 update_plan 能找到
|
||||
repo.create(test_plan)
|
||||
mock_gen.generate_from_template.return_value = {
|
||||
"plan": test_plan,
|
||||
"clips": [],
|
||||
}
|
||||
mock_generator_cls.return_value = mock_gen
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={
|
||||
"template_id": "tpl-001",
|
||||
"name": "我的剪辑计划",
|
||||
"config": {"bgm": {"enabled": True}},
|
||||
"total_duration": 60.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "我的剪辑计划"
|
||||
assert data["template_id"] == "tpl-001"
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
# 验证调用了生成器
|
||||
mock_gen.generate_from_template.assert_called_once()
|
||||
call_kwargs = mock_gen.generate_from_template.call_args[1]
|
||||
assert call_kwargs["template"].id == "tpl-001"
|
||||
assert call_kwargs["name"] == "我的剪辑计划"
|
||||
assert call_kwargs["created_by_user_id"] == "user-001"
|
||||
|
||||
@patch("app.api.routes.edit_plans.EditTemplateService")
|
||||
@patch("app.services.PlanGeneratorService")
|
||||
def test_create_with_asset_ids(self, mock_generator_cls, mock_template_svc_cls, client):
|
||||
"""创建计划时传入 asset_ids,应传递给生成器并写入 plan.config"""
|
||||
c, repo = client
|
||||
|
||||
mock_template_svc = MagicMock()
|
||||
mock_template_svc.get_template_or_raise.return_value = MagicMock(
|
||||
id="tpl-001",
|
||||
name="测试模板",
|
||||
config={},
|
||||
)
|
||||
mock_template_svc.list_clip_configs.return_value = []
|
||||
mock_template_svc_cls.return_value = mock_template_svc
|
||||
|
||||
mock_gen = MagicMock()
|
||||
test_plan = self._make_test_plan(name="带素材计划")
|
||||
test_plan.status = EditPlanStatus.EDITING
|
||||
repo.create(test_plan)
|
||||
mock_gen.generate_from_template.return_value = {
|
||||
"plan": test_plan,
|
||||
"clips": [],
|
||||
}
|
||||
mock_generator_cls.return_value = mock_gen
|
||||
|
||||
asset_ids = ["asset-001", "asset-002", "asset-003"]
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={
|
||||
"template_id": "tpl-001",
|
||||
"name": "带素材计划",
|
||||
"asset_ids": asset_ids,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
# 验证 asset_ids 传递给了生成器
|
||||
mock_gen.generate_from_template.assert_called_once()
|
||||
call_kwargs = mock_gen.generate_from_template.call_args[1]
|
||||
assert call_kwargs["asset_ids"] == asset_ids
|
||||
|
||||
@patch("app.api.routes.edit_plans.EditTemplateService")
|
||||
@patch("app.services.PlanGeneratorService")
|
||||
def test_create_minimal(self, mock_generator_cls, mock_template_svc_cls, client):
|
||||
c, repo = client
|
||||
|
||||
mock_template_svc = MagicMock()
|
||||
mock_template_svc.get_template_or_raise.return_value = MagicMock(
|
||||
id="tpl-001",
|
||||
name="测试模板",
|
||||
config={},
|
||||
)
|
||||
mock_template_svc.list_clip_configs.return_value = []
|
||||
mock_template_svc_cls.return_value = mock_template_svc
|
||||
|
||||
mock_gen = MagicMock()
|
||||
test_plan = self._make_test_plan()
|
||||
test_plan.status = EditPlanStatus.EDITING
|
||||
# 把 plan 存到 stub repo
|
||||
repo.create(test_plan)
|
||||
mock_gen.generate_from_template.return_value = {
|
||||
"plan": test_plan,
|
||||
"clips": [],
|
||||
}
|
||||
mock_generator_cls.return_value = mock_gen
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={"template_id": "tpl-001", "name": "最小计划"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
|
||||
# 验证生成器被调用
|
||||
mock_gen.generate_from_template.assert_called_once()
|
||||
|
||||
@patch("app.api.routes.edit_plans.EditTemplateService")
|
||||
def test_create_template_not_found_falls_back_empty(self, mock_template_svc_cls, client):
|
||||
"""模板不存在时降级为空计划(向后兼容)"""
|
||||
c, repo = client
|
||||
|
||||
mock_template_svc = MagicMock()
|
||||
mock_template_svc.get_template_or_raise.side_effect = ValueError("模板不存在")
|
||||
mock_template_svc_cls.return_value = mock_template_svc
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={"template_id": "nonexistent", "name": "测试"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "测试"
|
||||
assert data["template_id"] == "nonexistent"
|
||||
# 空计划没有片段
|
||||
assert "clips" not in data or len(data.get("clips", [])) == 0
|
||||
|
||||
def test_create_empty_name_returns_422(self, client):
|
||||
c, repo = client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={"template_id": "tpl-001", "name": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_missing_template_id_returns_422(self, client):
|
||||
c, repo = client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={"name": "没有模板的计划"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_negative_duration_returns_422(self, client):
|
||||
c, repo = client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans",
|
||||
json={"template_id": "tpl-001", "name": "test", "total_duration": -1.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 列表测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListPlans:
|
||||
def _seed_plans(self, repo, count=3, template_id="tpl-001"):
|
||||
for i in range(count):
|
||||
plan = EditPlan.create(
|
||||
template_id=template_id,
|
||||
name=f"计划{i+1}",
|
||||
config={"index": i},
|
||||
)
|
||||
repo.create(plan)
|
||||
return plan
|
||||
|
||||
def test_list_empty(self, client):
|
||||
c, repo = client
|
||||
resp = c.get("/api/v1/edit-plans")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
|
||||
def test_list_with_items(self, client):
|
||||
c, repo = client
|
||||
self._seed_plans(repo, count=3)
|
||||
resp = c.get("/api/v1/edit-plans")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["total"] == 3
|
||||
|
||||
def test_list_pagination(self, client):
|
||||
c, repo = client
|
||||
self._seed_plans(repo, count=5)
|
||||
resp = c.get("/api/v1/edit-plans?page=1&page_size=2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total"] == 5
|
||||
assert data["page"] == 1
|
||||
|
||||
resp2 = c.get("/api/v1/edit-plans?page=3&page_size=2")
|
||||
data2 = resp2.json()
|
||||
assert len(data2["items"]) == 1
|
||||
|
||||
def test_list_filter_by_status(self, client):
|
||||
c, repo = client
|
||||
p1 = EditPlan.create("tpl-001", "计划A")
|
||||
repo.create(p1)
|
||||
p2 = EditPlan.create("tpl-001", "计划B")
|
||||
repo.create(p2)
|
||||
p2.start_editing()
|
||||
repo.update(p2)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans?status=draft")
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["name"] == "计划A"
|
||||
|
||||
resp2 = c.get("/api/v1/edit-plans?status=editing")
|
||||
data2 = resp2.json()
|
||||
assert data2["total"] == 1
|
||||
assert data2["items"][0]["name"] == "计划B"
|
||||
|
||||
def test_list_filter_by_template_id(self, client):
|
||||
c, repo = client
|
||||
p1 = EditPlan.create("tpl-001", "模板1计划")
|
||||
repo.create(p1)
|
||||
p2 = EditPlan.create("tpl-002", "模板2计划")
|
||||
repo.create(p2)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans?template_id=tpl-001")
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["name"] == "模板1计划"
|
||||
|
||||
def test_list_filter_by_template_and_status(self, client):
|
||||
c, repo = client
|
||||
p1 = EditPlan.create("tpl-001", "模板1草稿")
|
||||
repo.create(p1)
|
||||
p2 = EditPlan.create("tpl-001", "模板1编辑中")
|
||||
repo.create(p2)
|
||||
p2.start_editing()
|
||||
repo.update(p2)
|
||||
p3 = EditPlan.create("tpl-002", "模板2草稿")
|
||||
repo.create(p3)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans?template_id=tpl-001&status=draft")
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["name"] == "模板1草稿"
|
||||
|
||||
def test_list_invalid_status_returns_400(self, client):
|
||||
c, repo = client
|
||||
resp = c.get("/api/v1/edit-plans?status=invalid_status")
|
||||
assert resp.status_code == 400
|
||||
assert "无效" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 详情测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetPlan:
|
||||
def test_get_success(self, client):
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试计划", config={"key": "val"})
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.get(f"/api/v1/edit-plans/{plan.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == plan.id
|
||||
assert data["name"] == "测试计划"
|
||||
assert data["config"] == {"key": "val"}
|
||||
|
||||
def test_get_not_found_returns_404(self, client):
|
||||
c, repo = client
|
||||
resp = c.get("/api/v1/edit-plans/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
assert "剪辑计划不存在" in resp.json()["detail"]
|
||||
|
||||
def test_get_plan_rendered_url_is_signed(self, client):
|
||||
"""plan详情接口返回的rendered_url应该是签名URL,不是裸OSS URL"""
|
||||
c, repo = client
|
||||
raw_url = "https://bucket.oss-cn-hangzhou.aliyuncs.com/rendered/plan-001/output.mp4"
|
||||
signed_url = raw_url + "?OSSAccessKeyId=xxx&Expires=123456&Signature=yyy"
|
||||
plan = EditPlan.create("tpl-001", "测试计划", config={"rendered_url": raw_url, "other": "val"})
|
||||
plan.status = EditPlanStatus.COMPLETED
|
||||
repo.create(plan)
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = signed_url
|
||||
with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage):
|
||||
resp = c.get(f"/api/v1/edit-plans/{plan.id}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["config"]["rendered_url"] == signed_url
|
||||
assert data["config"]["other"] == "val"
|
||||
mock_storage.get_download_url.assert_called_once_with(raw_url, expires_seconds=86400)
|
||||
|
||||
def test_list_plans_rendered_url_is_signed(self, client):
|
||||
"""plan列表接口返回的rendered_url也应该是签名URL"""
|
||||
c, repo = client
|
||||
raw_url = "https://bucket.oss-cn-hangzhou.aliyuncs.com/rendered/plan-list/output.mp4"
|
||||
signed_url = raw_url + "?OSSAccessKeyId=xxx&Expires=123456&Signature=yyy"
|
||||
plan = EditPlan.create("tpl-001", "测试计划", config={"rendered_url": raw_url})
|
||||
plan.status = EditPlanStatus.COMPLETED
|
||||
repo.create(plan)
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = signed_url
|
||||
with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage):
|
||||
resp = c.get("/api/v1/edit-plans")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"][0]["config"]["rendered_url"] == signed_url
|
||||
|
||||
def test_get_plan_no_rendered_url_skips_signing(self, client):
|
||||
"""没有rendered_url的计划不调用签名服务"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试计划", config={"key": "val"})
|
||||
repo.create(plan)
|
||||
|
||||
mock_storage = MagicMock()
|
||||
with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage):
|
||||
resp = c.get(f"/api/v1/edit-plans/{plan.id}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["config"] == {"key": "val"}
|
||||
mock_storage.get_download_url.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 更新测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdatePlan:
|
||||
def _seed_plan(self, repo, name="原计划", template_id="tpl-001"):
|
||||
plan = EditPlan.create(template_id, name)
|
||||
repo.create(plan)
|
||||
return plan
|
||||
|
||||
def test_update_name(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"name": "新名称"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "新名称"
|
||||
|
||||
def test_update_config(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}",
|
||||
json={"config": {"bgm": "sad", "transition": "fade"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["config"] == normalize_plan_config({"bgm": "sad", "transition": "fade"})
|
||||
|
||||
def test_update_total_duration(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"total_duration": 120.5})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total_duration"] == 120.5
|
||||
|
||||
def test_update_status_draft_to_editing(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "editing"
|
||||
|
||||
def test_update_status_full_happy_path(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
# draft → editing
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"})
|
||||
assert resp.json()["status"] == "editing"
|
||||
|
||||
# editing → rendering
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"})
|
||||
assert resp.json()["status"] == "rendering"
|
||||
|
||||
# rendering → completed
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"})
|
||||
assert resp.json()["status"] == "completed"
|
||||
|
||||
def test_update_status_failure_and_reset(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
# draft → editing → rendering → failed
|
||||
c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"})
|
||||
c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"})
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "failed"})
|
||||
assert resp.json()["status"] == "failed"
|
||||
|
||||
# failed → draft (reset)
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"})
|
||||
assert resp.json()["status"] == "draft"
|
||||
|
||||
def test_update_invalid_transition_returns_400(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
# draft → rendering 不合法
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_draft_to_completed_returns_400(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
# draft → completed 不合法
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_invalid_status_value_returns_400(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "bogus"})
|
||||
assert resp.status_code == 400
|
||||
assert "无效" in resp.json()["detail"]
|
||||
|
||||
def test_update_same_status_is_noop(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "draft"
|
||||
|
||||
def test_update_not_found_returns_404(self, client):
|
||||
c, repo = client
|
||||
resp = c.put("/api/v1/edit-plans/nonexistent", json={"name": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_combined_fields_and_status(self, client):
|
||||
c, repo = client
|
||||
plan = self._seed_plan(repo)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}",
|
||||
json={"name": "新名称", "status": "editing", "total_duration": 90.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "新名称"
|
||||
assert data["status"] == "editing"
|
||||
assert data["total_duration"] == 90.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 删除测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeletePlan:
|
||||
def test_delete_success(self, client):
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "待删除")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.delete(f"/api/v1/edit-plans/{plan.id}")
|
||||
assert resp.status_code == 204
|
||||
assert repo.get(plan.id) is None
|
||||
|
||||
def test_delete_not_found_returns_404(self, client):
|
||||
c, repo = client
|
||||
resp = c.delete("/api/v1/edit-plans/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
assert "剪辑计划不存在" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BGM 配置测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBGMConfig:
|
||||
"""BGM 配置 API 测试"""
|
||||
|
||||
def test_get_bgm_default_empty(self, client):
|
||||
"""新计划 BGM 默认为空"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.get(f"/api/v1/edit-plans/{plan.id}/bgm")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_id"] == plan.id
|
||||
assert data["bgm"] == {}
|
||||
|
||||
def test_update_bgm_volume(self, client):
|
||||
"""更新 BGM 音量"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"volume": 0.5, "fade_in": 2.0, "fade_out": 3.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["volume"] == 0.5
|
||||
assert data["bgm"]["fade_in"] == 2.0
|
||||
assert data["bgm"]["fade_out"] == 3.0
|
||||
|
||||
def test_enable_bgm_with_preset(self, client):
|
||||
"""启用 BGM 并指定 preset_id"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"source": "library",
|
||||
"preset_id": "bgm_upbeat_001",
|
||||
"volume": 0.3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["enabled"] is True
|
||||
assert data["bgm"]["preset_id"] == "bgm_upbeat_001"
|
||||
|
||||
def test_enable_bgm_without_source_returns_400(self, client):
|
||||
"""启用 BGM 但不指定来源,返回 400"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"enabled": True, "volume": 0.3},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "素材来源" in resp.json()["detail"]
|
||||
|
||||
def test_enable_bgm_with_asset_id(self, client):
|
||||
"""启用 BGM 并指定 asset_id"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"source": "upload",
|
||||
"asset_id": "asset-audio-001",
|
||||
"loop_enabled": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["enabled"] is True
|
||||
assert data["bgm"]["asset_id"] == "asset-audio-001"
|
||||
assert data["bgm"]["loop_enabled"] is True
|
||||
|
||||
def test_update_bgm_not_found(self, client):
|
||||
"""不存在的计划返回 404"""
|
||||
c, _ = client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/nonexistent/bgm",
|
||||
json={"volume": 0.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_bgm_not_found(self, client):
|
||||
"""不存在的计划返回 404"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/nonexistent/bgm")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_update_preserves_existing(self, client):
|
||||
"""部分更新保留原有配置"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
plan.config = {"bgm": {"volume": 0.5, "fade_in": 1.0}}
|
||||
repo.create(plan)
|
||||
|
||||
# 只改音量
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"volume": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["volume"] == 0.8
|
||||
assert data["bgm"]["fade_in"] == 1.0 # 保留
|
||||
|
||||
def test_sidechain_config(self, client):
|
||||
"""人声闪避配置更新"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "bgm_relax_001",
|
||||
"sidechain_enabled": True,
|
||||
"sidechain_ratio": 0.4,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["sidechain_enabled"] is True
|
||||
assert data["bgm"]["sidechain_ratio"] == 0.4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BGM 预设库测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBGMPresets:
|
||||
"""BGM 预设列表 API 测试"""
|
||||
|
||||
def test_list_all_presets(self, client):
|
||||
"""获取所有预设 BGM"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "styles" in data
|
||||
assert data["total"] >= 10 # 至少有 10 首预设
|
||||
assert len(data["items"]) == data["total"]
|
||||
|
||||
def test_filter_by_style(self, client):
|
||||
"""按风格筛选"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?style=upbeat")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["style"] == "upbeat"
|
||||
|
||||
def test_search_by_keyword(self, client):
|
||||
"""关键词搜索"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?keyword=钢琴")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
for item in data["items"]:
|
||||
has_piano = (
|
||||
"钢琴" in item["name"] or "钢琴" in item["description"] or any("钢琴" in tag for tag in item["tags"])
|
||||
)
|
||||
assert has_piano
|
||||
|
||||
def test_pagination(self, client):
|
||||
"""分页功能"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?skip=0&limit=3")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["skip"] == 0
|
||||
assert data["limit"] == 3
|
||||
|
||||
def test_preset_structure(self, client):
|
||||
"""预设条目字段完整"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?limit=1")
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
|
||||
assert "id" in item
|
||||
assert "name" in item
|
||||
assert "style" in item
|
||||
assert "style_label" in item
|
||||
assert "duration" in item
|
||||
assert "artist" in item
|
||||
assert "description" in item
|
||||
assert "tags" in item
|
||||
assert isinstance(item["tags"], list)
|
||||
@@ -167,7 +167,7 @@ class StubTemplateClipConfigRepository:
|
||||
def delete(self, config_id: str) -> bool:
|
||||
return self._configs.pop(config_id, None) is not None
|
||||
|
||||
def delete_by_template(self, template_id: str) -> int:
|
||||
def delete_by_template(self, template_id: str, *, commit: bool = True) -> int:
|
||||
ids = [cid for cid, c in self._configs.items() if c.template_id == template_id]
|
||||
for cid in ids:
|
||||
del self._configs[cid]
|
||||
|
||||
Reference in New Issue
Block a user