feat(phase2): 模板编辑器子路由迁移第二批(AI/字幕/调整/封面/批量/分割合并)
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 17s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 38s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m22s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m10s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m0s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 17s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 38s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m22s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m10s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m0s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m6s
- 新增22个端点:AI推荐、封面生成、字幕CRUD(5)、片段调整(5)、封面管理(4)、片段批量(3)、片段详情、分割、合并 - 全部复用 EditPlanService,零业务逻辑复制 - 模板编辑器总端点达到45个
This commit is contained in:
@@ -26,11 +26,39 @@ import logging
|
||||
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,
|
||||
@@ -400,6 +428,119 @@ def delete_draft_clip(
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/clips/{clip_id}", response_model=EditorClipResponse)
|
||||
def get_draft_clip_detail(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""获取草稿中的片段详情"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
return EditorClipResponse(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type.value if hasattr(clip.clip_type, "value") else str(clip.clip_type),
|
||||
order=clip.order,
|
||||
duration=clip.duration,
|
||||
text_content=clip.text_content or "",
|
||||
transition_effect=(
|
||||
clip.transition_effect.value if hasattr(clip.transition_effect, "value") else str(clip.transition_effect)
|
||||
),
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
config=clip.config or {},
|
||||
)
|
||||
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
||||
def split_draft_clip(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: SplitClipRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""将一个片段从指定时间点分割为两个片段"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
try:
|
||||
result = plan_svc.split_clip(clip_id, body.split_time)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
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("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
||||
def merge_draft_clips(
|
||||
template_id: str,
|
||||
body: MergeClipsRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""将多个连续的同类型片段合并为一个片段"""
|
||||
_, plan_svc = services
|
||||
for cid in body.clip_ids:
|
||||
clip = plan_svc.get_clip(cid)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {cid}")
|
||||
try:
|
||||
merged = plan_svc.merge_clips(body.clip_ids)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ── BGM 模块 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1186,3 +1327,679 @@ def update_editor_export(
|
||||
format=current_export.get("format", "mp4"),
|
||||
preset_id=current_export.get("preset_id", ""),
|
||||
)
|
||||
|
||||
|
||||
# ── AI 推荐 & 封面生成 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/ai-recommend", response_model=AIRecommendResponse)
|
||||
def editor_ai_recommend(
|
||||
template_id: str,
|
||||
body: AIRecommendRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> AIRecommendResponse:
|
||||
"""AI 推荐片段方案"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
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:
|
||||
plan_svc.delete_all_clips(plan_id)
|
||||
|
||||
for clip_data in result["clips"]:
|
||||
plan_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", {}))
|
||||
plan_svc.update_plan(
|
||||
plan_id,
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.exception("模板编辑器AI推荐写入失败: template_id=%s plan_id=%s", template_id, plan_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI推荐结果保存失败,请稍后重试",
|
||||
) from _e
|
||||
|
||||
logger.info(
|
||||
"模板编辑器AI推荐: template_id=%s plan_id=%s clips=%d duration=%.1f by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
len(result["clips"]),
|
||||
result["total_duration"],
|
||||
current_user.user_id,
|
||||
)
|
||||
|
||||
return AIRecommendResponse(
|
||||
plan_id=plan_id,
|
||||
clips=[
|
||||
{
|
||||
"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("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def editor_generate_cover(
|
||||
template_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
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) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user_id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
|
||||
# ── 字幕模块 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]])
|
||||
def get_editor_clip_subtitles(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""获取片段的字幕列表"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
config = clip.config or {}
|
||||
subtitles = config.get("subtitles", [])
|
||||
if not isinstance(subtitles, list):
|
||||
subtitles = []
|
||||
return subtitles
|
||||
|
||||
|
||||
@router.post("/clips/{clip_id}/subtitles", response_model=dict[str, Any])
|
||||
def create_editor_clip_subtitle(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: dict[str, Any],
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""新增片段字幕"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = config.get("subtitles", [])
|
||||
if not isinstance(subtitles, list):
|
||||
subtitles = []
|
||||
|
||||
new_id = f"sub_{len(subtitles) + 1}"
|
||||
new_subtitle = {
|
||||
"id": body.get("id", new_id),
|
||||
"start_time": body.get("start_time", 0.0),
|
||||
"end_time": body.get("end_time", 0.0),
|
||||
"text": body.get("text", ""),
|
||||
"style": body.get("style", {}),
|
||||
}
|
||||
subtitles.append(new_subtitle)
|
||||
config["subtitles"] = subtitles
|
||||
|
||||
plan_svc.update_clip(clip_id, config=config)
|
||||
return new_subtitle
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/subtitles/{subtitle_id}", response_model=dict[str, Any])
|
||||
def update_editor_clip_subtitle(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
subtitle_id: str,
|
||||
body: dict[str, Any],
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""更新片段字幕"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = config.get("subtitles", [])
|
||||
if not isinstance(subtitles, list):
|
||||
subtitles = []
|
||||
|
||||
found = False
|
||||
for i, sub in enumerate(subtitles):
|
||||
if sub.get("id") == subtitle_id:
|
||||
subtitles[i].update(body)
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="字幕不存在")
|
||||
|
||||
config["subtitles"] = subtitles
|
||||
plan_svc.update_clip(clip_id, config=config)
|
||||
return subtitles[i]
|
||||
|
||||
|
||||
@router.delete("/clips/{clip_id}/subtitles/{subtitle_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_editor_clip_subtitle(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
subtitle_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""删除片段字幕"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = config.get("subtitles", [])
|
||||
if not isinstance(subtitles, list):
|
||||
subtitles = []
|
||||
|
||||
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
|
||||
if len(new_subtitles) == len(subtitles):
|
||||
raise HTTPException(status_code=404, detail="字幕不存在")
|
||||
|
||||
config["subtitles"] = new_subtitles
|
||||
plan_svc.update_clip(clip_id, config=config)
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]])
|
||||
def batch_update_editor_clip_subtitles(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: list[dict[str, Any]],
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""批量更新片段字幕(全量替换)"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
config["subtitles"] = body
|
||||
plan_svc.update_clip(clip_id, config=config)
|
||||
return body
|
||||
|
||||
|
||||
# ── 片段调整模块 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_clip_config(clip) -> dict:
|
||||
config = getattr(clip, "config", {}) or {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
return config
|
||||
|
||||
|
||||
def _get_adjust_volume(clip) -> float:
|
||||
config = _get_clip_config(clip)
|
||||
return float(config.get("volume", 1.0))
|
||||
|
||||
|
||||
def _get_adjust_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_adjust_response(clip) -> ClipAdjustResponse:
|
||||
trim_start, trim_end = _get_adjust_trim(clip)
|
||||
return ClipAdjustResponse(
|
||||
clip_id=clip.id,
|
||||
speed=clip.playback_speed,
|
||||
volume=_get_adjust_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)")
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
|
||||
def adjust_editor_clip_speed(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: SpeedAdjustRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段播放速度"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
updated = plan_svc.update_clip(clip_id, playback_speed=body.speed)
|
||||
return _build_adjust_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
|
||||
def adjust_editor_clip_volume(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: VolumeAdjustRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段音量"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["volume"] = body.volume
|
||||
updated = plan_svc.update_clip(clip_id, config=config)
|
||||
return _build_adjust_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
|
||||
def adjust_editor_clip_trim(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: TrimAdjustRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipAdjustResponse:
|
||||
"""裁剪片段(trim in/out)"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
try:
|
||||
_validate_trim(body.trim_start, body.trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["trim_start"] = body.trim_start
|
||||
config["trim_end"] = body.trim_end
|
||||
updated = plan_svc.update_clip(clip_id, config=config)
|
||||
return _build_adjust_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
|
||||
def adjust_editor_clip_all(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: ClipAdjustmentsRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipAdjustResponse:
|
||||
"""统一调整片段的 speed / volume / trim"""
|
||||
_, plan_svc = services
|
||||
clip = plan_svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
update_kwargs: dict[str, Any] = {}
|
||||
config_updates: dict[str, Any] = {}
|
||||
|
||||
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
|
||||
|
||||
current_trim_start, current_trim_end = _get_adjust_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=400, 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_adjust_response(clip)
|
||||
|
||||
updated = plan_svc.update_clip(clip_id, **update_kwargs)
|
||||
return _build_adjust_response(updated)
|
||||
|
||||
|
||||
@router.post("/clips/batch-speed", response_model=BatchSpeedResponse)
|
||||
def batch_adjust_editor_speed(
|
||||
template_id: str,
|
||||
body: BatchSpeedRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> BatchSpeedResponse:
|
||||
"""批量调整草稿内所有片段的播放速度"""
|
||||
_, plan_svc = services
|
||||
clips = plan_svc.list_clips(plan_id, limit=500, skip=0)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
plan_svc.update_clip(clip.id, playback_speed=body.speed)
|
||||
count += 1
|
||||
|
||||
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
|
||||
|
||||
|
||||
# ── 封面管理模块 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/cover", response_model=CoverConfigResponse)
|
||||
def get_editor_cover(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config = plan.config or {}
|
||||
cover_config = config.get("cover", {})
|
||||
|
||||
return CoverConfigResponse(
|
||||
plan_id=plan.id,
|
||||
cover_type=cover_config.get("cover_type", "auto"),
|
||||
cover_image_url=cover_config.get("cover_image_url", ""),
|
||||
clip_id=cover_config.get("clip_id", ""),
|
||||
frame_time=cover_config.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/cover", response_model=CoverConfigResponse)
|
||||
def update_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
current_cover = dict(config.get("cover", {}))
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
current_cover.update(update_data)
|
||||
|
||||
config["cover"] = current_cover
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
return CoverConfigResponse(
|
||||
plan_id=plan.id,
|
||||
cover_type=current_cover.get("cover_type", "auto"),
|
||||
cover_image_url=current_cover.get("cover_image_url", ""),
|
||||
clip_id=current_cover.get("clip_id", ""),
|
||||
frame_time=current_cover.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/extract", response_model=CoverGenerateResponse)
|
||||
def extract_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverExtractRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段抽帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
clip = plan_svc.get_clip(body.clip_id)
|
||||
if not clip or clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿")
|
||||
|
||||
cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg"
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "extract",
|
||||
"cover_image_url": cover_url,
|
||||
"clip_id": body.clip_id,
|
||||
"frame_time": body.frame_time,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
current_user.user_id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
cover_url=cover_url,
|
||||
cover_type="extract",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/smart", response_model=CoverGenerateResponse)
|
||||
def smart_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverSmartRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
cover_url = f"cover/smart/{plan_id}_smart.jpg"
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "smart",
|
||||
"cover_image_url": cover_url,
|
||||
"strategy": body.strategy,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.strategy,
|
||||
current_user.user_id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
cover_url=cover_url,
|
||||
cover_type="smart",
|
||||
)
|
||||
|
||||
|
||||
# ── 片段批量操作模块 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/clips/reorder", response_model=ClipReorderResponse)
|
||||
def reorder_editor_clips(
|
||||
template_id: str,
|
||||
body: ClipReorderRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipReorderResponse:
|
||||
"""批量重排片段顺序"""
|
||||
_, plan_svc = services
|
||||
count = 0
|
||||
for item in body.items:
|
||||
try:
|
||||
plan_svc.update_clip(item.clip_id, order=item.order)
|
||||
count += 1
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return ClipReorderResponse(updated_count=count, plan_id=plan_id)
|
||||
|
||||
|
||||
@router.post("/clips/batch-delete", response_model=ClipBatchDeleteResponse)
|
||||
def batch_delete_editor_clips(
|
||||
template_id: str,
|
||||
body: ClipBatchDeleteRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipBatchDeleteResponse:
|
||||
"""批量删除片段"""
|
||||
_, plan_svc = services
|
||||
deleted = 0
|
||||
for clip_id in body.clip_ids:
|
||||
if plan_svc.delete_clip(clip_id):
|
||||
deleted += 1
|
||||
|
||||
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
|
||||
|
||||
|
||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets_editor(
|
||||
template_id: str,
|
||||
body: ClipsFromAssetsRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段"""
|
||||
_, plan_svc = services
|
||||
clips = []
|
||||
for i, asset_id in enumerate(body.asset_ids):
|
||||
try:
|
||||
clip = plan_svc.create_clip(
|
||||
plan_id,
|
||||
clip_type="main",
|
||||
order=body.start_order + i if hasattr(body, "start_order") else i,
|
||||
duration=5.0,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
clips.append(clip)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
len(clips),
|
||||
current_user.user_id,
|
||||
)
|
||||
|
||||
return ClipsFromAssetsResponse(
|
||||
created_count=len(clips),
|
||||
plan_id=plan_id,
|
||||
clip_ids=[c.id for c in clips],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user