feat(phase3): 下线/edit-plans/*旧路由 + 清理废弃代码 + 数据库冗余字段清理 #657

Merged
auto-approve-bot merged 6 commits from feat/phase3-backend-cleanup into develop 2026-07-20 23:48:36 +08:00
32 changed files with 505 additions and 9252 deletions
+37
View File
@@ -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",
),
)
-6
View File
@@ -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",
-23
View File
@@ -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.01.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)
-205
View File
@@ -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. 更新计划 configcover/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,
)
-423
View File
@@ -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,
)
-317
View File
@@ -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)
+454 -53
View File
@@ -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 configcover/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.01.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 (
+2 -464
View File
@@ -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)
+9 -3
View File
@@ -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:
-3
View File
@@ -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
-559
View File
@@ -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_urltype 保持不变"""
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
-350
View File
@@ -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
-476
View File
@@ -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
-754
View File
@@ -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:
"""异常时将计划标记为 failedRENDERING → 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
-311
View File
@@ -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
-906
View File
@@ -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)
+1 -1
View File
@@ -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]