Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia ea65033b01 fix(worker): 修复瘦身后PYTHONPATH缺少packages目录导致shared模块无法导入
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1518h22m15s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1518h22m18s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1518h22m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1518h22m18s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1518h22m20s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1518h22m20s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1518h53m56s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1518h53m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1518h54m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1518h54m0s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1518h54m0s
2026-07-15 08:30:58 +08:00
71 changed files with 1105 additions and 1823 deletions
File diff suppressed because one or more lines are too long
Executable → Regular
+786 -29
View File
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
"""add result_count to edit_plans
Revision ID: 041_result_count
Revises: 040_playback_speed
Create Date: 2026-07-15 14:05:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "041_result_count"
down_revision = "040_playback_speed"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plans",
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("edit_plans", "result_count")
+2 -4
View File
@@ -239,9 +239,7 @@ def get_duplication_detail(
return _to_detail_response(record) return _to_detail_response(record)
@router.delete( @router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_duplication_record( def delete_duplication_record(
record_id: str, record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -289,7 +287,7 @@ def retry_duplication(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), detail=str(e),
) from e )
if updated is None: if updated is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+8 -14
View File
@@ -72,7 +72,6 @@ class EditPlanResponse(BaseModel):
name: str name: str
status: str status: str
total_duration: float total_duration: float
result_count: int = 0
project_id: str = "" project_id: str = ""
created_by_user_id: str = "" created_by_user_id: str = ""
config: dict[str, Any] config: dict[str, Any]
@@ -109,10 +108,6 @@ class EditPlanGenerationStatusResponse(BaseModel):
plan_id: str plan_id: str
plan_status: str plan_status: str
generation_task_id: Optional[str] = None 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] clips: List[ClipStatusItem]
@@ -242,7 +237,6 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
name=p.name, name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status, status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration, total_duration=p.total_duration,
result_count=getattr(p, "result_count", 0),
project_id=p.project_id or "", project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "", created_by_user_id=p.created_by_user_id or "",
config=p.config, config=p.config,
@@ -281,11 +275,11 @@ def list_plans(
if status_filter: if status_filter:
try: try:
status_enum = EditPlanStatus(status_filter) status_enum = EditPlanStatus(status_filter)
except ValueError as _e: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态", detail="无效的筛选条件,请选择正确的状态",
) from _e )
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问 # 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id: if project_id:
@@ -328,7 +322,7 @@ def get_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc), detail=str(exc),
) from exc )
# 项目鉴权 # 项目鉴权
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -364,7 +358,7 @@ def create_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc), detail=str(exc),
) from exc )
logger.info( logger.info(
"创建剪辑计划: id=%s name=%s by user=%s", "创建剪辑计划: id=%s name=%s by user=%s",
created.id, created.id,
@@ -407,11 +401,11 @@ def update_plan(
if body.status is not None: if body.status is not None:
try: try:
target_status = EditPlanStatus(body.status) target_status = EditPlanStatus(body.status)
except ValueError as _e: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态", detail="无效的状态值,请选择正确的状态",
) from _e )
svc.transition_status(plan_id, target_status) svc.transition_status(plan_id, target_status)
except ValueError as exc: except ValueError as exc:
err_msg = str(exc) err_msg = str(exc)
@@ -419,11 +413,11 @@ def update_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg, detail=err_msg,
) from exc )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg, detail=err_msg,
) from exc )
# 返回最新状态 # 返回最新状态
result = svc.get_plan_or_raise(plan_id) result = svc.get_plan_or_raise(plan_id)
+4 -4
View File
@@ -58,7 +58,7 @@ def ai_recommend_clips(
try: try:
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -103,7 +103,7 @@ def ai_recommend_clips(
config=normalized_config, config=normalized_config,
total_duration=result["total_duration"], total_duration=result["total_duration"],
) )
except Exception as _e: except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id) logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try: try:
db.rollback() db.rollback()
@@ -116,7 +116,7 @@ def ai_recommend_clips(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试", detail="AI推荐结果保存失败,请稍后重试",
) from _e )
logger.info( logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s", "AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
@@ -167,7 +167,7 @@ def generate_cover(
try: try:
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
+9 -25
View File
@@ -183,7 +183,9 @@ def _auto_fallback_auto_material_mode(
def _check_queue_limits(gen_task_repo, user_id: str) -> None: def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查""" """队列限流预检查"""
try: try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total") has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count: if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id) user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total() global_pending = gen_task_repo.count_pending_total()
@@ -239,7 +241,7 @@ def generate_plan(
try: try:
can_gen, reason = svc.can_generate(plan_id) can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if not can_gen: if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
@@ -253,15 +255,12 @@ def generate_plan(
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo) gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id) 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( gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand( CreateGenerationTaskCommand(
project_id=plan.project_id or "", project_id="",
template_id=plan.template_id, template_id=plan.template_id,
created_by_user_id=current_user.user.id, created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id, source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
) )
) )
@@ -287,7 +286,7 @@ def generate_plan(
) )
except HTTPException: except HTTPException:
raise raise
except Exception as _e: except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id) logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try: try:
svc.transition_status(plan_id, EditPlanStatus.FAILED) svc.transition_status(plan_id, EditPlanStatus.FAILED)
@@ -296,7 +295,7 @@ def generate_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试", detail="生成失败,请稍后重试",
) from _e )
@router.get( @router.get(
@@ -314,7 +313,7 @@ def get_generation_status(
try: try:
gen_status = svc.get_generation_status(plan_id) gen_status = svc.get_generation_status(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
plan = gen_status["plan"] plan = gen_status["plan"]
if plan.project_id: if plan.project_id:
@@ -334,25 +333,10 @@ def get_generation_status(
for c in clips for c in clips
] ]
# 从 plan.config 中取渲染结果 URL
video_url = (plan.config or {}).get("rendered_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( return EditPlanGenerationStatusResponse(
plan_id=plan_id, plan_id=plan_id,
plan_status=plan_status_val, plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"], 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, clips=clip_items,
) )
@@ -173,7 +173,7 @@ def generate_from_template(
try: try:
template = template_svc.get_template_or_raise(body.template_id) template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200) clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
+6 -6
View File
@@ -105,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name) return sorted(result, key=lambda x: x.name)
except Exception as exc: except Exception as exc:
logger.error("Failed to list feature flags: %s", exc) logger.error("Failed to list feature flags: %s", exc)
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
@router.get("/{name}", response_model=FeatureFlagResponse) @router.get("/{name}", response_model=FeatureFlagResponse)
@@ -120,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to get feature flag %s: %s", name, exc) logger.error("Failed to get feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse) @router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
@@ -136,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier) return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as exc: except Exception as exc:
logger.error("Failed to check feature flag %s: %s", name, exc) logger.error("Failed to check feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
@router.put("/{name}", response_model=FeatureFlagResponse) @router.put("/{name}", response_model=FeatureFlagResponse)
@@ -170,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to update feature flag %s: %s", name, exc) logger.error("Failed to update feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@@ -178,7 +178,7 @@ async def delete_feature_flag(
name: str, name: str,
_: bool = Depends(_verify_internal_api_key), _: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store), store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
): ) :
"""删除 Feature Flag。 """删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。 只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -191,4 +191,4 @@ async def delete_feature_flag(
pass pass
except Exception as exc: except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc) logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
+5 -6
View File
@@ -43,7 +43,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse( return GenerationTaskResponse(
id=task.id, id=task.id,
@@ -283,28 +282,28 @@ def create_generation_task(
created_tasks.append(task) created_tasks.append(task)
else: else:
failed_tasks.append(task) failed_tasks.append(task)
except UserPendingLimitExceeded as _e: except UserPendingLimitExceeded:
# 兜底:如果预检查后又并发提交了,在这里也拦住 # 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="您的待处理任务过多,请等待完成后再提交", detail="您的待处理任务过多,请等待完成后再提交",
) from _e )
break break
except GlobalQueueFull as _e: except GlobalQueueFull:
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from _e )
break break
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True) logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks] items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items)) return BatchGenerationTaskResponse(items=items, total=len(items))
+2 -2
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository) use_case = DeleteProjectUseCase(project_repository)
try: try:
deleted = use_case.execute(project_id, authenticated_user.user.id) deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError as _e: except PermissionError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project", detail="Only the project owner can delete this project",
) from _e )
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return return
+1 -1
View File
@@ -254,7 +254,7 @@ async def payment_callback(
return {"success": True, "message": "支付成功", "record_id": record_id} return {"success": True, "message": "支付成功", "record_id": record_id}
except Exception as e: except Exception as e:
session.rollback() session.rollback()
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
finally: finally:
session.close() session.close()
+15 -17
View File
@@ -147,9 +147,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id) usage = template_repository.get_usage_count(template_id)
except Exception as _e: except Exception:
logger.exception("get_template 查询失败: template_id=%s", template_id) logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage) return _to_response(template, usage_count=usage)
@@ -186,7 +186,7 @@ def create_template(
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -226,10 +226,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository) use_case = UpdateTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -264,10 +264,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository) use_case = CopyTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -299,9 +299,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
try: try:
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
except Exception as _e: except Exception:
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False) return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -326,10 +326,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository) use_case = ValidateTemplateUseCase(template_repository)
try: try:
result = use_case.execute(command) result = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return ValidateTemplateResponse( return ValidateTemplateResponse(
template=_to_response(result.template), template=_to_response(result.template),
@@ -375,9 +375,7 @@ def create_category(
) )
@router.delete( @router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_category( def delete_category(
category_id: str, category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item) return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository) use_case = UpdateTitleLibraryUseCase(title_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return _to_response(item) return _to_response(item)
+7 -7
View File
@@ -236,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository) use_case = GetTTSJobUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job, sign_url) return _to_response(job, sign_url)
@@ -253,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository) use_case = GetTTSJobStatusUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
output_url = job.output_audio_url output_url = job.output_audio_url
if output_url: if output_url:
output_url = sign_url(output_url) output_url = sign_url(output_url)
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository) get_use_case = GetTTSJobUseCase(tts_repository)
try: try:
job = get_use_case.execute(job_id, user_id) job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
# 校验已完成 # 校验已完成
if not job.is_completed: if not job.is_completed:
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return SaveToLibraryResponse( return SaveToLibraryResponse(
id=item.id, id=item.id,
+8 -8
View File
@@ -141,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository) use_case = GetVoiceCloneUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return _to_response(profile) return _to_response(profile)
@@ -157,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository) use_case = GetVoiceCloneStatusUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return VoiceCloneStatusResponse( return VoiceCloneStatusResponse(
id=profile.id, id=profile.id,
status=profile.status, status=profile.status,
@@ -201,13 +201,13 @@ def retry_voice_clone(
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
try: try:
profile = workflow.retry_clone(clone_id, user_id) profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotRetryableError as _e: except VoiceCloneNotRetryableError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)", detail="Voice clone is not retryable (only failed clones can be retried)",
) from _e )
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
+3 -3
View File
@@ -287,7 +287,7 @@ def create_voice(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item, sign_url) return _to_response(item, sign_url)
@@ -317,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository) use_case = UpdateVoiceLibraryUseCase(voice_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url) return _to_response(item, sign_url)
@@ -431,8 +431,6 @@ class EditPlanService:
"clips": List[EditPlanClip], "clips": List[EditPlanClip],
"generation_task_id": Optional[str], "generation_task_id": Optional[str],
"generation_task_status": Optional[str], "generation_task_status": Optional[str],
"progress": float,
"error_message": str,
} }
Raises: Raises:
@@ -444,23 +442,17 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id # 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id") generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id: if generation_task_id:
task = self._generation_task_repo.get(generation_task_id) task = self._generation_task_repo.get(generation_task_id)
if task: if task:
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
progress = getattr(task, "progress", 0.0) or 0.0
error_message = getattr(task, "error_message", "") or ""
return { return {
"plan": plan, "plan": plan,
"clips": clips, "clips": clips,
"generation_task_id": generation_task_id, "generation_task_id": generation_task_id,
"generation_task_status": generation_task_status, "generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
} }
def can_generate(self, plan_id: str) -> tuple[bool, str]: def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -224,7 +224,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 overlay # 剩余为 overlay
for _ in range(1, n): for i in range(1, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -237,7 +237,7 @@ class PlanGeneratorService:
elif editing_mode == EditingMode.VOICE_OVER.value: elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clips(B-roll) # N 个 main clips(B-roll)
for _ in range(n): for i in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -271,7 +271,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 b_roll # 剩余为 b_roll
for _ in range(2, n): for i in range(2, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -284,7 +284,7 @@ class PlanGeneratorService:
else: else:
# ONE_TAKE: N 个 main clips # ONE_TAKE: N 个 main clips
for _ in range(n): for i in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
-17
View File
@@ -33,7 +33,6 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
@@ -4829,22 +4828,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
-1
View File
@@ -42,7 +42,6 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
+6 -15
View File
@@ -130,8 +130,6 @@ export interface EditPlan {
name: string; name: string;
status: EditPlanStatus; status: EditPlanStatus;
total_duration: number; total_duration: number;
/** 生成视频数量(后端 EditPlanResponse.result_count) */
result_count: number;
config: EditPlanConfig; config: EditPlanConfig;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -163,21 +161,14 @@ export interface GenerateResponse {
clip_count: number; clip_count: number;
} }
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */ /** 剪辑计划关联的生成记录 */
export interface EditPlanGeneration { export interface EditPlanGeneration {
id: string; // 即 generation_task_id id: string;
source_edit_plan_id: string; edit_plan_id: string;
template_id: string; generation_task_id: string;
asset_ids: string[];
status: EditPlanStatus; status: EditPlanStatus;
progress: number; created_at: string;
result_count: number; updated_at: string;
error_message: string;
error_info: Record<string, unknown>;
logs: Array<Record<string, unknown>>;
retry_count: number;
created_at?: string;
updated_at?: string;
} }
/** 片段生成状态 */ /** 片段生成状态 */
-1
View File
@@ -1,6 +1,5 @@
/** /**
* 成品 / 视频相关 API * 成品 / 视频相关 API
* 包含:列表查询、复核状态、批量下载
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据 * 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
*/ */
import apiClient from "./client"; import apiClient from "./client";
+4 -9
View File
@@ -16,22 +16,17 @@ import type { EditPlanConfig } from "./editPlans";
/** 模板条目(后端 TemplateResponse) */ /** 模板条目(后端 TemplateResponse) */
export interface TemplateItem { export interface TemplateItem {
id: string; id: string;
user_id?: string;
name: string; name: string;
description?: string; description: string;
mode?: string;
category: string; category: string;
tags?: string[]; tags?: string[];
/** 预估时长(后端字段名 estimated_duration) */ target_duration: number;
estimated_duration?: number; clip_count: number;
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number;
clip_count?: number;
/** 使用次数 */ /** 使用次数 */
usage_count?: number; usage_count?: number;
thumbnail_url?: string; thumbnail_url?: string;
preview_url?: string; preview_url?: string;
is_active?: boolean; is_active: boolean;
is_favorite?: boolean; is_favorite?: boolean;
/** 素材规则(片段配置) */ /** 素材规则(片段配置) */
segments?: TemplateSegment[]; segments?: TemplateSegment[];
@@ -33,9 +33,8 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds?: number) => { const formatDuration = (seconds?: number) => {
if (!seconds) return "-"; if (!seconds) return "-";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
return m > 0 ? `${m}分${s}秒` : `${s}秒`; return m > 0 ? `${m}分${s}秒` : `${s}秒`;
}; };
@@ -59,9 +59,8 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds?: number) => { const formatDuration = (seconds?: number) => {
if (!seconds) return "-"; if (!seconds) return "-";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
return m > 0 ? `${m}分${s}秒` : `${s}秒`; return m > 0 ? `${m}分${s}秒` : `${s}秒`;
}; };
+2 -13
View File
@@ -86,9 +86,8 @@ const STATUS_CONFIG: Record<
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "-"; if (seconds <= 0) return "-";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
if (m === 0) return `${s}秒`; if (m === 0) return `${s}秒`;
return `${m}分${s > 0 ? `${s}秒` : ""}`; return `${m}分${s > 0 ? `${s}秒` : ""}`;
}; };
@@ -254,16 +253,6 @@ export default function EditPlans() {
<span className="plan-duration">{formatDuration(seconds)}</span> <span className="plan-duration">{formatDuration(seconds)}</span>
), ),
}, },
{
title: "视频数",
dataIndex: "result_count",
key: "result_count",
width: 80,
align: "center",
render: (count: number) => (
<span className="plan-result-count">{count > 0 ? count : "—"}</span>
),
},
{ {
title: "创建时间", title: "创建时间",
dataIndex: "created_at", dataIndex: "created_at",
@@ -3,8 +3,8 @@
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*/ */
import React, { useState, useCallback, useEffect, useRef } from "react"; import React, { useState, useCallback, useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
import { message, Modal, Progress, Button } from "antd"; import { message } from "antd";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { import type {
EditingTemplate, EditingTemplate,
@@ -20,23 +20,11 @@ import {
getTemplateCategories, getTemplateCategories,
MODE_LABELS, MODE_LABELS,
} from "@/api/editingPlanner"; } from "@/api/editingPlanner";
import type { import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
EditPlanGeneration,
EditPlanConfig,
GeneratedVideo,
MediaAsset,
TransitionEffect,
} from "@/api/editPlans";
import { import {
getMediaAssets, getMediaAssets,
getEditPlanGenerations, getEditPlanGenerations,
generateCover, generateCover,
getEditPlan,
createEditPlan,
updateEditPlan,
generateEditPlan,
getGenerationStatus,
getGenerationTaskResults,
} from "@/api/editPlans"; } from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo"; import { useUndoRedo } from "./hooks/useUndoRedo";
import type { import type {
@@ -45,7 +33,6 @@ import type {
TransitionConfig, TransitionConfig,
SpeedConfig, SpeedConfig,
TtsConfig, TtsConfig,
TtsMode,
TrimConfig, TrimConfig,
WatermarkConfig, WatermarkConfig,
IntroOutroConfig, IntroOutroConfig,
@@ -120,8 +107,8 @@ const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"];
const EditingPlanner: React.FC = () => { const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate();
const urlTemplateId = searchParams.get("templateId") || ""; const urlTemplateId = searchParams.get("templateId") || "";
const urlPlanId = searchParams.get("planId") || "";
/* ── 模板列表 ── */ /* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([]); const [templates, setTemplates] = useState<EditingTemplate[]>([]);
@@ -257,21 +244,6 @@ const EditingPlanner: React.FC = () => {
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]); const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
const [genHistoryLoading, setGenHistoryLoading] = useState(false); const [genHistoryLoading, setGenHistoryLoading] = useState(false);
/* ── 剪辑计划(从列表页编辑进入时) ── */
const [loadedPlanId, setLoadedPlanId] = useState<string | null>(
urlPlanId || null,
);
/* ── 生成进度 ── */
const [generating, setGenerating] = useState(false);
const [genProgress, setGenProgress] = useState(0);
const [genTotalClips, setGenTotalClips] = useState(0);
const [genDoneClips, setGenDoneClips] = useState(0);
const [generated, setGenerated] = useState(false);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [genError, setGenError] = useState<string | null>(null);
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* ── 播放 ── */ /* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
@@ -402,85 +374,6 @@ const EditingPlanner: React.FC = () => {
.catch(() => message.error("加载模板详情失败")); .catch(() => message.error("加载模板详情失败"));
}, [loadedTemplateId, resetClips]); }, [loadedTemplateId, resetClips]);
/**
* 加载已有剪辑计划数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
useEffect(() => {
if (!loadedPlanId) return;
getEditPlan(loadedPlanId)
.then((plan) => {
// 设置关联的模板(触发模板加载 effect)
setLoadedTemplateId(plan.template_id);
// 还原基本信息
setDraftName(plan.name);
// 还原 config 中的编辑器状态
const cfg = plan.config;
if (cfg.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content,
position: cfg.title_config!.position,
font: cfg.title_config!.font_preset,
size: cfg.title_config!.font_size,
color: cfg.title_config!.font_color || "#ffffff",
}));
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}));
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}));
}
// 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips
if (cfg.segments && cfg.segments.length > 0) {
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover"
? "voice"
: "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}));
setTimeout(() => resetClips(mapped), 100);
}
})
.catch(() => message.error("加载剪辑计划失败"));
}, [loadedPlanId, resetClips]);
/* ──────────── 计算 ──────────── */ /* ──────────── 计算 ──────────── */
const currentTemplate = templates.find((t) => t.id === loadedTemplateId); const currentTemplate = templates.find((t) => t.id === loadedTemplateId);
@@ -778,65 +671,6 @@ const EditingPlanner: React.FC = () => {
} }
}; };
/** 构建剪辑计划 config(编辑器状态 → API config) */
const buildPlanConfig = (): EditPlanConfig => ({
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverSettings },
});
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */ /* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
const handleOpenSaveModal = () => { const handleOpenSaveModal = () => {
setSaveModalOpen(true); setSaveModalOpen(true);
@@ -929,139 +763,94 @@ const EditingPlanner: React.FC = () => {
}; };
/** /**
* 剪辑计划生成 * 跳转到一键生成页面
* 1. 有 planId → 更新计划配置 + 触发生成 * 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfig(JSON 序列化)
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成 * 一键生成页面从 params 解析配置,无需重复请求接口
* 3. 触发生成后轮询状态,完成后获取视频结果
*/ */
const handleGoToGenerate = async () => { const handleGoToGenerate = () => {
if (!loadedTemplateId) { const planConfig = {
message.warning("请先选择一个模板"); title_config: {
return; ai_auto_select: titleSettings.aiAutoSelect,
} content: titleSettings.title,
if (clips.length === 0) { position: titleSettings.position,
message.warning("请先添加片段"); font_preset: titleSettings.font,
return; font_color: titleSettings.color,
} font_size: titleSettings.size,
bold: titleSettings.bold,
setGenerating(true); italic: titleSettings.italic,
setGenerated(false); stroke: titleSettings.stroke,
setGeneratedVideos([]); shadow: titleSettings.shadow,
setGenError(null); },
setGenProgress(0); subtitle_config: {
enabled: subtitleSettings.enabled,
try { position: subtitleSettings.position,
const config = buildPlanConfig(); font: subtitleSettings.font,
let planId = loadedPlanId; color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
if (planId) { animation: subtitleSettings.animation,
// 已有计划 → 更新配置 },
await updateEditPlan(planId, { bgm_config: {
config, enabled: bgmSettings.enabled,
total_duration: totalDuration, music_id: bgmSettings.music_id,
status: "editing", },
}); mode: currentMode,
} else { total_duration: totalDuration,
// 无计划 → 创建新计划 segments: clips.map((c, i) => ({
const plan = await createEditPlan({ order: i,
template_id: loadedTemplateId, material_type: c.type === "voice" ? "voiceover" : "video",
name: draftName || "未命名计划", duration: c.duration,
config, template_segment_id: c.template_segment_id,
total_duration: totalDuration, script_text: c.script_text,
}); voice_asset_id: c.voice_asset_id,
planId = plan.id; voice_file_url: c.voice_file_url,
setLoadedPlanId(planId); transition: c.transition
// 更新 URL 参数(不刷新页面) ? { type: c.transition.type, duration: c.transition.duration }
const params = new URLSearchParams(window.location.search); : undefined,
params.set("planId", planId); playback_speed: c.speed ? c.speed.rate : undefined,
window.history.replaceState(null, "", `?${params.toString()}`); tts_config: c.tts_config
} ? {
mode: c.tts_config.mode,
// 触发生成 text: c.tts_config.text,
const genRes = await generateEditPlan(planId); voice_id: c.tts_config.voice_id,
setGenTotalClips(genRes.clip_count); speed: c.tts_config.speed,
message.info("已提交生成,等待处理..."); pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
// 开始轮询 subtitle_sync: c.tts_config.subtitle_sync,
startPolling(planId);
} catch (err) {
console.error("[生成失败]", err);
setGenError("生成提交失败,请重试");
setGenerating(false);
}
};
/** 轮询生成状态,每 2 秒一次 */
const startPolling = (planId: string) => {
const poll = async () => {
try {
const status = await getGenerationStatus(planId);
// 计算进度
const total = status.clips.length || genTotalClips;
const done = status.clips.filter(
(c) => c.status === "completed" || c.status === "failed",
).length;
setGenDoneClips(done);
setGenTotalClips(total);
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5);
if (status.plan_status === "completed") {
setGenProgress(100);
setGenerating(false);
setGenerated(true);
// 获取视频结果
if (status.generation_task_id) {
try {
const videos = await getGenerationTaskResults(
status.generation_task_id,
);
setGeneratedVideos(videos);
} catch (e) {
console.error("[获取视频结果失败]", e);
} }
} : undefined,
message.success("视频生成完成!"); trim_config: c.trim_config
return; // 停止轮询 ? {
} start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
if (status.plan_status === "failed") { }
setGenerating(false); : undefined,
setGenError("生成失败,请重试"); })),
return; // 停止轮询 watermark_config: { ...watermarkSettings },
} intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
// 继续轮询 filter_config: { ...filterSettings },
genTimerRef.current = setTimeout(poll, 2000); green_screen_config: { ...chromaKeySettings },
} catch (err) { sticker_config: { ...stickerSettings },
console.error("[轮询状态失败]", err); cover_config: { ...coverSettings },
genTimerRef.current = setTimeout(poll, 5000); // 出错后 5 秒重试
}
}; };
const params = new URLSearchParams();
// 首次延迟 2 秒后开始 if (loadedTemplateId) {
genTimerRef.current = setTimeout(poll, 2000); params.set("edit_plan_id", loadedTemplateId);
}
params.set("plan_config", JSON.stringify(planConfig));
navigate(`/app/generate?${params.toString()}`);
}; };
/** 清理轮询定时器 */
useEffect(() => {
return () => {
if (genTimerRef.current) clearTimeout(genTimerRef.current);
};
}, []);
/* 查看生成历史 */ /* 查看生成历史 */
const handleViewGenHistory = async () => { const handleViewGenHistory = async () => {
const targetId = loadedPlanId || loadedTemplateId; if (!loadedTemplateId) {
if (!targetId) { message.warning("请先加载一个模板");
message.warning("请先加载一个模板或计划");
return; return;
} }
setGenHistoryOpen(true); setGenHistoryOpen(true);
setGenHistoryLoading(true); setGenHistoryLoading(true);
try { try {
const items = await getEditPlanGenerations(targetId); const items = await getEditPlanGenerations(loadedTemplateId);
setGenHistory(items); setGenHistory(items);
} catch { } catch {
message.error("加载生成历史失败"); message.error("加载生成历史失败");
@@ -1110,9 +899,8 @@ const EditingPlanner: React.FC = () => {
<button <button
className="ep-btn ep-btn-primary" className="ep-btn ep-btn-primary"
onClick={handleGoToGenerate} onClick={handleGoToGenerate}
disabled={generating}
> >
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"} 🎬 使用此模板生成
</button> </button>
</div> </div>
</div> </div>
@@ -1277,111 +1065,6 @@ const EditingPlanner: React.FC = () => {
onClose={() => setGenHistoryOpen(false)} onClose={() => setGenHistoryOpen(false)}
/> />
{/* ═══ 生成进度弹窗 ═══ */}
<Modal
title={genError ? "生成失败" : generated ? "生成完成" : "正在生成视频"}
open={generating || generated || !!genError}
footer={
generated
? [
<Button
key="close"
onClick={() => {
setGenerated(false);
setGenerating(false);
}}
>
关闭
</Button>,
generatedVideos.length > 0 && (
<Button
key="download"
type="primary"
onClick={() => {
const v = generatedVideos[0];
const url = v.download_url || v.file_url;
if (url) {
const a = document.createElement("a");
a.href = url;
a.download = v.name || "video.mp4";
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}}
>
下载视频
</Button>
),
]
: null
}
closable={!generating}
maskClosable={false}
width={520}
>
{generating && (
<div style={{ padding: "16px 0" }}>
<Progress percent={genProgress} status="active" />
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
已处理 {genDoneClips}/{genTotalClips} 个片段
</p>
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
请耐心等待,生成过程中请勿关闭页面
</p>
</div>
)}
{generated && generatedVideos.length > 0 && (
<div style={{ padding: "8px 0" }}>
<video
src={
generatedVideos[0].file_url || generatedVideos[0].download_url
}
controls
preload="metadata"
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
/>
<p
style={{
marginTop: 8,
textAlign: "center",
color: "var(--text-secondary)",
}}
>
{generatedVideos[0].name}
</p>
</div>
)}
{generated && !generatedVideos.length && (
<div style={{ padding: "24px 0", textAlign: "center" }}>
<p>生成完成,但暂未获取到视频结果</p>
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
请稍后在剪辑计划列表中查看
</p>
</div>
)}
{genError && (
<div
style={{
padding: "16px 0",
textAlign: "center",
color: "#ff4d4f",
}}
>
<p>{genError}</p>
<Button
onClick={() => {
setGenError(null);
setGenerating(false);
}}
>
关闭
</Button>
</div>
)}
</Modal>
{/* ═══ BGM 选择器 Drawer ═══ */} {/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector <BgmSelector
open={bgmDrawerOpen} open={bgmDrawerOpen}
@@ -65,7 +65,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
return ( return (
<tr key={gen.id} className="ep-gh-table-row"> <tr key={gen.id} className="ep-gh-table-row">
<td className="ep-gh-td ep-gh-td-id"> <td className="ep-gh-td ep-gh-td-id">
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"} {gen.generation_task_id.slice(0, 8)}...
</td> </td>
<td className="ep-gh-td"> <td className="ep-gh-td">
<span className={`ep-gh-status-tag ${statusClass}`}> <span className={`ep-gh-status-tag ${statusClass}`}>
@@ -23,6 +23,7 @@ import {
getTemplate, getTemplate,
toggleFavoriteTemplate, toggleFavoriteTemplate,
copyTemplate, copyTemplate,
generateFromTemplate,
type TemplateItem, type TemplateItem,
type TemplateListParams, type TemplateListParams,
type TemplateSegment, type TemplateSegment,
@@ -90,11 +91,10 @@ const gradientForCategory = (category: string): string => {
}; };
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds: number | undefined | null): string => { const formatDuration = (seconds: number): string => {
if (!seconds || seconds <= 0) return "0秒"; if (seconds <= 0) return "0秒";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
if (m === 0) return `${s}秒`; if (m === 0) return `${s}秒`;
return `${m}分${s > 0 ? `${s}秒` : ""}`; return `${m}分${s > 0 ? `${s}秒` : ""}`;
}; };
@@ -237,9 +237,7 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
{ {
key: "duration", key: "duration",
label: "目标时长", label: "目标时长",
children: formatDuration( children: formatDuration(template.target_duration),
template.estimated_duration ?? template.target_duration,
),
}, },
{ {
key: "clips", key: "clips",
@@ -411,9 +409,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-thumb-name">{template.name}</div> <div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta"> <div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration"> <span className="xx-template-thumb-duration">
{formatDuration( {formatDuration(template.target_duration)}
template.estimated_duration ?? template.target_duration,
)}
</span> </span>
</div> </div>
<div className="xx-template-preview-hint">点击查看详情</div> <div className="xx-template-preview-hint">点击查看详情</div>
@@ -541,6 +537,19 @@ const TemplateLibrary: React.FC = () => {
}, },
}); });
// ── 从模板生成剪辑计划 mutation ──
const generateMutation = useMutation({
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
generateFromTemplate(templateId, { name }),
onSuccess: (data) => {
message.success(`剪辑计划「${data.name}」已创建`);
navigate("/app/edit-plans");
},
onError: () => {
message.error("生成剪辑计划失败,请稍后重试");
},
});
/** 切换收藏 */ /** 切换收藏 */
const toggleFavorite = useCallback( const toggleFavorite = useCallback(
(id: string, e?: React.MouseEvent) => { (id: string, e?: React.MouseEvent) => {
@@ -573,12 +582,15 @@ const TemplateLibrary: React.FC = () => {
[copyMutation], [copyMutation],
); );
/** 使用模板 → 进入剪辑编辑器配置 */ /** 使用模板 → 生成剪辑计划 */
const handleUse = useCallback( const handleUse = useCallback(
(template: TemplateItem) => { (template: TemplateItem) => {
navigate(`/app/editing-planner?templateId=${template.id}`); generateMutation.mutate({
templateId: template.id,
name: `基于「${template.name}」的剪辑计划`,
});
}, },
[navigate], [generateMutation, navigate],
); );
/** 搜索防抖处理 */ /** 搜索防抖处理 */
-1
View File
@@ -37,7 +37,6 @@ export default defineConfig({
}, },
}, },
build: { build: {
cache: true,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
@@ -181,9 +181,9 @@ def _validate_video_path(video_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve() resolved_work_dir = work_dir.resolve()
try: try:
resolved_path.relative_to(resolved_work_dir) resolved_path.relative_to(resolved_work_dir)
except ValueError as _e: except ValueError:
if not is_in_allowed_dirs(resolved_path): if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责) # URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名 # 但检查扩展名
else: else:
+1 -1
View File
@@ -356,7 +356,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
except Exception as e: except Exception as e:
logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}") logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}")
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
finally: finally:
session.close() session.close()
import shutil import shutil
@@ -191,9 +191,9 @@ def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve() resolved_work_dir = work_dir.resolve()
try: try:
resolved_path.relative_to(resolved_work_dir) resolved_path.relative_to(resolved_work_dir)
except ValueError as _e: except ValueError:
if not is_in_allowed_dirs(resolved_path): if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}") from _e raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责) # URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名 # 但检查扩展名
else: else:
@@ -129,8 +129,8 @@ def safe_resolve_path(
if not allow_outside: if not allow_outside:
try: try:
full_path.relative_to(base_dir) full_path.relative_to(base_dir)
except ValueError as _e: except ValueError:
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围") from _e raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
# 扩展名校验 # 扩展名校验
if allowed_extensions is not None: if allowed_extensions is not None:
+1 -1
View File
@@ -403,7 +403,7 @@ class PiPEngine:
input_args: list[str] = [] input_args: list[str] = []
current_label = base_label current_label = base_label
for i, (_input_label, layer, path) in enumerate(pip_sources): for i, (input_label, layer, path) in enumerate(pip_sources):
# 添加输入 # 添加输入
input_args.extend(["-i", str(path)]) input_args.extend(["-i", str(path)])
@@ -359,7 +359,7 @@ class StickerEngine:
image_stickers: list[ImageStickerConfig] = [] image_stickers: list[ImageStickerConfig] = []
image_paths: list[str] = [] image_paths: list[str] = []
for _, s in enumerate(stickers): for i, s in enumerate(stickers):
try: try:
sticker_type = s.get("type", "image") sticker_type = s.get("type", "image")
z = int(s.get("z_index", 10)) z = int(s.get("z_index", 10))
@@ -682,6 +682,6 @@ def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve() resolved_work_dir = work_dir.resolve()
try: try:
resolved_path.relative_to(resolved_work_dir) resolved_path.relative_to(resolved_work_dir)
except ValueError as _e: except ValueError:
if not is_in_allowed_dirs(resolved_path): if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}") from _e raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
@@ -1363,37 +1363,23 @@ class UnifiedRenderService:
# 单 clip 层,直接使用预处理标签 # 单 clip 层,直接使用预处理标签
layer_output_labels[layer.role] = layer_labels[0] layer_output_labels[layer.role] = layer_labels[0]
else: else:
# 多 clip 层,用 TransitionEngine 构建转场链
out_label = f"{layer.role}_merged" out_label = f"{layer.role}_merged"
# 判断是否全部为硬切:是则用 concat filter,否则用 xfade 转场链 # 计算该层使用的转场时长(取首个非零值,否则用默认)
all_cut = all( layer_dur = 0.0
t is None or t == "" or str(t).lower() == "cut" for d in layer_transition_durations:
for t in layer_transitions[1:] # 第一个 clip 的转场忽略 if d > 0:
layer_dur = d
break
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=layer_dur if layer_dur > 0 else None,
output_label=out_label,
) )
if all_cut: if xfade_filter:
# 全硬切:用 concat filter,性能远优于 xfade filter_parts.append(xfade_filter)
concat_inputs = "".join(f"[{label}]" for label in layer_labels)
filter_parts.append(f"{concat_inputs}concat=n={len(layer_labels)}:v=1:a=0[{out_label}]")
logger.info(
"[unified-render] layer=%s clips=%d using concat (all hard-cut)",
layer.role,
len(layer_labels),
)
else:
# 有转场效果:用 TransitionEngine 构建 xfade 链
layer_dur = 0.0
for d in layer_transition_durations:
if d > 0:
layer_dur = d
break
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=layer_dur if layer_dur > 0 else None,
output_label=out_label,
)
if xfade_filter:
filter_parts.append(xfade_filter)
layer_output_labels[layer.role] = out_label layer_output_labels[layer.role] = out_label
# Step 3: 合成各层 # Step 3: 合成各层
+1 -13
View File
@@ -63,18 +63,6 @@ def compose_video(self, job_id: str, **kwargs):
resolver = get_render_engine_resolver() resolver = get_render_engine_resolver()
user_id = job.created_by_user_id or None user_id = job.created_by_user_id or None
engine = resolver.get_engine(user_id=user_id) engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"compose_video 引擎选择: job_id=%s engine=%s user_id=%s enabled=%s percentage=%s whitelist=%d default=%s",
job_id,
engine,
user_id,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
if engine == "unified": if engine == "unified":
return _compose_with_unified_engine(self, job_service, job, plan_id, db) return _compose_with_unified_engine(self, job_service, job, plan_id, db)
@@ -90,7 +78,7 @@ def compose_video(self, job_id: str, **kwargs):
job_service.fail_job(job_id, str(exc)[:500]) job_service.fail_job(job_id, str(exc)[:500])
except Exception: except Exception:
logger.exception("更新 Job 失败状态时出错") logger.exception("更新 Job 失败状态时出错")
raise self.retry(exc=exc, countdown=60) from exc raise self.retry(exc=exc, countdown=60)
finally: finally:
db.close() db.close()
@@ -77,21 +77,9 @@ def _resolve_render_engine(user_id: str) -> str:
from video_processing.render_engine_resolver import get_render_engine_resolver from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver() resolver = get_render_engine_resolver()
engine = resolver.get_engine(user_id=user_id) return resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"edit_plan 引擎选择: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc: except Exception as exc:
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True) logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc)
return "legacy" return "legacy"
@@ -107,14 +95,6 @@ def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, err
gen_task.status = "failed" gen_task.status = "failed"
gen_task.error_message = error_msg gen_task.error_message = error_msg
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
try:
gen_task.append_log(
stage="render_failed",
message=error_msg[:500],
level="ERROR",
)
except Exception:
pass
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
@@ -168,13 +148,9 @@ def _finalize_render_success(
clip.mark_rendered() clip.mark_rendered()
clip_repo.update(clip) clip_repo.update(clip)
# 更新 EditPlan 状态为 completed + 回写实际渲染时长 + 结果数 # 更新 EditPlan 状态为 completed
plan.config["rendered_url"] = output_url or "" plan.config["rendered_url"] = output_url or ""
plan.config["rendered_storage_key"] = storage_key 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.mark_completed()
plan_repo.update(plan) plan_repo.update(plan)
@@ -184,15 +160,7 @@ def _finalize_render_success(
if gen_task: if gen_task:
gen_task.status = "completed" gen_task.status = "completed"
gen_task.progress = 100.0 gen_task.progress = 100.0
# 剪辑计划是多片段合成 1 个成片,result_count = 1 gen_task.result_count = len(rendered_clip_ids)
gen_task.result_count = 1
gen_task.append_log(
stage="render_complete",
message=f"渲染完成,输出时长 {duration:.1f}s",
level="INFO",
engine=engine,
clip_count=len(rendered_clip_ids),
)
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
@@ -397,13 +365,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
if gen_task: if gen_task:
gen_task.status = "running" gen_task.status = "running"
gen_task.started_at = datetime.now(timezone.utc) gen_task.started_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="render_start",
message=f"开始渲染,引擎 {engine},片段数 {len(clips)}",
level="INFO",
engine=engine,
clip_count=len(clips),
)
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
# 3. 下载素材并构建 asset_path_map # 3. 下载素材并构建 asset_path_map
@@ -468,28 +429,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed" gen_task.status = "failed"
gen_task.error_message = "所有片段素材下载失败" gen_task.error_message = "所有片段素材下载失败"
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="download_failed",
message="所有片段素材下载失败",
level="ERROR",
)
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
return {"status": "error", "message": "所有片段素材下载失败"} return {"status": "error", "message": "所有片段素材下载失败"}
# 素材下载完成,记录日志
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.append_log(
stage="download_done",
message=f"素材下载完成,成功 {len(asset_path_map)} 个,失败 {len(failed_clip_ids)} 个",
level="INFO",
success_count=len(asset_path_map),
failed_count=len(failed_clip_ids),
)
gen_task.progress = 30.0
gen_task_repo.update(gen_task)
# 4. 根据引擎选择渲染方式 # 4. 根据引擎选择渲染方式
if engine == "unified": if engine == "unified":
result = _render_with_unified( result = _render_with_unified(
@@ -541,15 +483,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed" gen_task.status = "failed"
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}" gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
try:
gen_task.append_log(
stage="render_failed",
message=f"渲染异常: {type(exc).__name__}: {str(exc)[:500]}",
level="ERROR",
exception_type=type(exc).__name__,
)
except Exception:
pass
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
logger.info( logger.info(
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s", "GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
@@ -560,6 +493,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.warning( logger.warning(
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True "更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
) )
raise self.retry(exc=exc, countdown=60) from exc raise self.retry(exc=exc, countdown=60)
return {"status": "error", "message": "数据库连接失败"} return {"status": "error", "message": "数据库连接失败"}
+3 -16
View File
@@ -787,23 +787,10 @@ def _resolve_render_engine(user_id: str) -> str:
from video_processing.render_engine_resolver import get_render_engine_resolver from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver() resolver = get_render_engine_resolver()
engine = resolver.get_engine(user_id=user_id) return resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"[渲染引擎] flag 解析: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc: except Exception as exc:
# 异常时 fallback 到 legacy(保守策略,与 edit_plan_generation 一致) logger.warning("获取渲染引擎配置失败,fallback 到 unified: %s", exc)
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True) return ENGINE_UNIFIED
return ENGINE_LEGACY
# ── 旧引擎渲染(FFmpeg filter_complex) ──────────────────────────────────────── # ── 旧引擎渲染(FFmpeg filter_complex) ────────────────────────────────────────
@@ -63,7 +63,7 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
if session is not None: if session is not None:
session.rollback() session.rollback()
# 超时重试,指数退避 # 超时重试,指数退避
raise self.retry(exc=e, countdown=30) from e raise self.retry(exc=e, countdown=30)
except CosyVoiceError as e: except CosyVoiceError as e:
logger.error(f"TTS synthesis failed for {job_id}: {e}") logger.error(f"TTS synthesis failed for {job_id}: {e}")
@@ -139,7 +139,7 @@ def process_tts_segment_synthesis(self: Task, job_id: str) -> dict:
logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}") logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}")
if session is not None: if session is not None:
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
except CosyVoiceError as e: except CosyVoiceError as e:
logger.error(f"TTS segment synthesis failed for {job_id}: {e}") logger.error(f"TTS segment synthesis failed for {job_id}: {e}")
+1 -1
View File
@@ -73,7 +73,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
if session is not None: if session is not None:
session.rollback() session.rollback()
# 超时属于临时性故障,延迟 30 秒后重试 # 超时属于临时性故障,延迟 30 秒后重试
raise self.retry(exc=e, countdown=30) from e raise self.retry(exc=e, countdown=30)
except CosyVoiceError as e: except CosyVoiceError as e:
logger.error(f"Voice clone failed for {profile_id}: {e}") logger.error(f"Voice clone failed for {profile_id}: {e}")
@@ -105,7 +105,7 @@ def extract_voice_task(self: Task, asset_id: str) -> dict:
except Exception as e: except Exception as e:
logger.error(f"Voice extraction failed for {asset_id}: {str(e)}") logger.error(f"Voice extraction failed for {asset_id}: {str(e)}")
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
finally: finally:
session.close() session.close()
import shutil import shutil
@@ -141,7 +141,7 @@ def extract_background_task(self: Task, asset_id: str) -> dict:
except Exception as e: except Exception as e:
logger.error(f"Background extraction failed for {asset_id}: {str(e)}") logger.error(f"Background extraction failed for {asset_id}: {str(e)}")
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
finally: finally:
session.close() session.close()
import shutil import shutil
-8
View File
@@ -990,14 +990,6 @@
"type": "FLOAT", "type": "FLOAT",
"unique": false "unique": false
}, },
{
"index": false,
"name": "result_count",
"nullable": false,
"primary_key": false,
"type": "INTEGER",
"unique": false
},
{ {
"index": false, "index": false,
"name": "config", "name": "config",
-3
View File
@@ -100,7 +100,6 @@ class SQLAlchemyEditPlanRepository:
name=plan.name, name=plan.name,
status=plan.status, status=plan.status,
total_duration=plan.total_duration, total_duration=plan.total_duration,
result_count=plan.result_count,
source_edit_plan_id=plan.source_edit_plan_id or None, source_edit_plan_id=plan.source_edit_plan_id or None,
project_id=plan.project_id or "", project_id=plan.project_id or "",
created_by_user_id=plan.created_by_user_id or "", created_by_user_id=plan.created_by_user_id or "",
@@ -120,7 +119,6 @@ class SQLAlchemyEditPlanRepository:
model.name = plan.name model.name = plan.name
model.status = plan.status model.status = plan.status
model.total_duration = plan.total_duration 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.source_edit_plan_id = plan.source_edit_plan_id or None
model.project_id = plan.project_id or "" model.project_id = plan.project_id or ""
model.created_by_user_id = plan.created_by_user_id or "" model.created_by_user_id = plan.created_by_user_id or ""
@@ -154,7 +152,6 @@ class SQLAlchemyEditPlanRepository:
name=model.name, name=model.name,
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT, status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
total_duration=model.total_duration or 0.0, 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 "", source_edit_plan_id=model.source_edit_plan_id or "",
project_id=model.project_id or "", project_id=model.project_id or "",
created_by_user_id=model.created_by_user_id or "", created_by_user_id=model.created_by_user_id or "",
+1 -3
View File
@@ -1,10 +1,9 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
Base: Any = declarative_base() Base = declarative_base()
class UserModel(Base): class UserModel(Base):
@@ -149,7 +148,6 @@ class EditPlanModel(Base):
name = Column(String(200), nullable=False) name = Column(String(200), nullable=False)
status = Column(String(20), nullable=False, default="draft", index=True) status = Column(String(20), nullable=False, default="draft", index=True)
total_duration = Column(Float, nullable=False, default=0.0) total_duration = Column(Float, nullable=False, default=0.0)
result_count = Column(Integer, nullable=False, default=0)
config = Column(JSON, nullable=False, default=dict) config = Column(JSON, nullable=False, default=dict)
source_edit_plan_id = Column(String(36), nullable=True, index=True) source_edit_plan_id = Column(String(36), nullable=True, index=True)
project_id = Column(String(36), nullable=False, default="", index=True) project_id = Column(String(36), nullable=False, default="", index=True)
+3 -3
View File
@@ -147,10 +147,10 @@ class JWTService:
algorithms=[self.config.ALGORITHM], algorithms=[self.config.ALGORITHM],
) )
return payload return payload
except ExpiredSignatureError as _e: except ExpiredSignatureError:
raise ExpiredSignatureError("Token has expired") from _e raise ExpiredSignatureError("Token has expired")
except InvalidTokenError as e: except InvalidTokenError as e:
raise InvalidTokenError(f"Invalid token: {str(e)}") from e raise InvalidTokenError(f"Invalid token: {str(e)}")
def verify_access_token(self, token: str) -> Dict[str, Any]: def verify_access_token(self, token: str) -> Dict[str, Any]:
""" """
+2 -2
View File
@@ -656,8 +656,8 @@ class CosyVoiceService:
code = body.get("code", "") code = body.get("code", "")
message = body.get("message", "") message = body.get("message", "")
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}") raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
except ValueError as _e: except ValueError:
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}") from _e raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
elif response.status_code >= 500: elif response.status_code >= 500:
# 服务端错误,可重试 # 服务端错误,可重试
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}") last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
+4 -4
View File
@@ -78,16 +78,16 @@ class AudioMerger:
run_ffmpeg(cmd, timeout=120) run_ffmpeg(cmd, timeout=120)
except CalledProcessError as e: except CalledProcessError as e:
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}") logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}") from e raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}")
with open(output_path, "rb") as f: with open(output_path, "rb") as f:
return f.read() return f.read()
except TimeoutExpired as _e: except TimeoutExpired:
raise AudioMergeError("FFmpeg 合并超时(120 秒)") from _e raise AudioMergeError("FFmpeg 合并超时(120 秒)")
except AudioMergeError: except AudioMergeError:
raise raise
except Exception as e: except Exception as e:
raise AudioMergeError(f"音频合并失败: {e}") from e raise AudioMergeError(f"音频合并失败: {e}")
finally: finally:
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
Executable → Regular
-3
View File
@@ -42,7 +42,6 @@ class EditPlan:
name: str name: str
status: EditPlanStatus = EditPlanStatus.DRAFT status: EditPlanStatus = EditPlanStatus.DRAFT
total_duration: float = 0.0 total_duration: float = 0.0
result_count: int = 0
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
project_id: str = "" project_id: str = ""
created_by_user_id: str = "" created_by_user_id: str = ""
@@ -58,7 +57,6 @@ class EditPlan:
*, *,
config: dict[str, Any] | None = None, config: dict[str, Any] | None = None,
total_duration: float = 0.0, total_duration: float = 0.0,
result_count: int = 0,
source_edit_plan_id: str = "", source_edit_plan_id: str = "",
project_id: str = "", project_id: str = "",
created_by_user_id: str = "", created_by_user_id: str = "",
@@ -75,7 +73,6 @@ class EditPlan:
name=clean_name, name=clean_name,
status=EditPlanStatus.DRAFT, status=EditPlanStatus.DRAFT,
total_duration=total_duration, total_duration=total_duration,
result_count=result_count,
source_edit_plan_id=source_edit_plan_id.strip(), source_edit_plan_id=source_edit_plan_id.strip(),
project_id=project_id.strip(), project_id=project_id.strip(),
created_by_user_id=created_by_user_id.strip(), created_by_user_id=created_by_user_id.strip(),
+2 -2
View File
@@ -170,8 +170,8 @@ class GenerationTask:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = GenerationTaskStatus(new_status) new_status = GenerationTaskStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+4 -4
View File
@@ -147,8 +147,8 @@ class Job:
if isinstance(job_type, str): if isinstance(job_type, str):
try: try:
job_type = JobType(job_type) job_type = JobType(job_type)
except ValueError as _e: except ValueError:
raise ValueError(f"不支持的任务类型: {job_type}") from _e raise ValueError(f"不支持的任务类型: {job_type}")
return cls( return cls(
id=uuid4().hex, id=uuid4().hex,
@@ -182,8 +182,8 @@ class Job:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = JobStatus(new_status) new_status = JobStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+2 -2
View File
@@ -193,8 +193,8 @@ class TTSJob:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = TTSJobStatus(new_status) new_status = TTSJobStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+2 -2
View File
@@ -177,8 +177,8 @@ class VoiceCloneProfile:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = VoiceCloneStatus(new_status) new_status = VoiceCloneStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+2 -2
View File
@@ -114,7 +114,7 @@ class SharedStorageService:
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type}) self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}" return f"{self.public_url}/{storage_key}"
except Exception as e: except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}") from e raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str: def get_url(self, storage_key: str) -> str:
"""Get public URL for a file.""" """Get public URL for a file."""
@@ -129,7 +129,7 @@ class SharedStorageService:
os.makedirs(os.path.dirname(local_path), exist_ok=True) os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path) self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e: except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}") from e raise Exception(f"Failed to download file from OSS: {e}")
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str: def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""Get signed download URL.""" """Get signed download URL."""
+12 -16
View File
@@ -66,19 +66,18 @@ exclude = [
] ]
[tool.ruff.lint] [tool.ruff.lint]
# 正式替换 flake8:规则集与原 flake8 完全对齐 # 当前阶段:摸底模式,规则集与原flake8对齐
# 后续迭代计划: # 后续迭代计划:
# Phase 2: 加入 B (flake8-bugbear),修完后升级为阻断级 # Phase 1: 修完 bugbear 后正式替换 flake8
# Phase 3: 启用 UP(pyupgrade) + SIM(simplify) # Phase 2: 启用 UP(pyupgrade) + SIM(simplify)
# Phase 4: 启用 RET(return) + ARG(unused-args) # Phase 3: 启用 RET(return) + ARG(unused-args)
select = [ select = [
"E", # pycodestyle errors(同 flake8) "E", # pycodestyle errors(同flake8)
"F", # pyflakes(同 flake8) "F", # pyflakes(同flake8)
"W", # pycodestyle warnings(同 flake8) "W", # pycodestyle warnings(同flake8)
"B", # flake8-bugbear(P0-5 Step 2 已完成修复) "B", # flake8-bugbear(新增,摸底用)
] ]
# 与原 setup.cfg + .flake8 的 flake8 配置完全对齐 # 与原 setup.cfg flake8 配置对齐,确保不新增阻断
# 注意:W503 在 ruff≥0.14 中已被移除(行为变默认),故不列入
ignore = [ ignore = [
"E203", "E203",
"E501", # line-too-long(black管) "E501", # line-too-long(black管)
@@ -87,19 +86,16 @@ ignore = [
"E722", # bare-except "E722", # bare-except
"W291", "W291",
"W293", "W293",
"B008", # function-call-in-default-argument(FastAPI 依赖注入模式,大量使用)
"F401", # unused-import "F401", # unused-import
"F403", "F403",
"F405", "F405",
"F841", # unused-variable "F841", # unused-variable
"B008", # do-not-perform-callback-from-arg(fastapi依赖注入)
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"] "__init__.py" = ["F401", "F403", "F405"]
"tests/*" = ["E402", "F401", "F821", "F841"] "tests/*" = ["E402", "F401", "F841"]
"packages/ports/*" = ["E301"] # E704 在 ruff≥0.14 已移除 "packages/ports/*" = ["E301"]
"apps/api/app/api/routes/auth.py" = ["ALL"]
"apps/api/app/api/routes/workspaces.py" = ["ALL"]
"apps/api/app/middleware/auth.py" = ["ALL"]
"apps/*/migrations/*" = ["ALL"] "apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"] "alembic/*" = ["ALL"]
Executable → Regular
+1 -1
View File
@@ -3,7 +3,7 @@
# 代码质量 # 代码质量
black==26.5.1 black==26.5.1
isort==8.0.1 isort==8.0.1
ruff==0.14.0 flake8==7.3.0
bandit==1.9.4 bandit==1.9.4
# 测试 # 测试
-12
View File
@@ -60,9 +60,6 @@ fi
# 默认只读不写,防止 feature 分支污染主缓存 # 默认只读不写,防止 feature 分支污染主缓存
# 只有 develop/main 分支才写回缓存 # 只有 develop/main 分支才写回缓存
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}" BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
# 清理本地旧镜像
docker rmi -f "$API_IMAGE" "$API_LATEST" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \ docker buildx build \
--build-arg APP_VERSION="$VERSION" \ --build-arg APP_VERSION="$VERSION" \
@@ -92,9 +89,6 @@ build_with_cache() {
echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}" echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}"
fi fi
# 清理本地旧镜像,避免 buildx --load 报 already exists 错误
docker rmi -f "$IMG_NAME:$VERSION" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
if [ -n "$CACHE_TO" ]; then if [ -n "$CACHE_TO" ]; then
docker buildx build \ docker buildx build \
@@ -125,9 +119,6 @@ build_with_cache "api" "infra/docker/api.Dockerfile" \
docker tag "$API_IMAGE" "$API_LATEST" docker tag "$API_IMAGE" "$API_LATEST"
echo "=== Building Worker image ===" echo "=== Building Worker image ==="
# 清理本地旧镜像
docker rmi -f "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \ docker buildx build \
--build-arg APP_VERSION="$VERSION" \ --build-arg APP_VERSION="$VERSION" \
@@ -157,9 +148,6 @@ docker run --rm \
test -f apps/web/dist/index.html test -f apps/web/dist/index.html
# 清理本地旧镜像
docker rmi -f "$WEB_IMAGE" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \ docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \ --cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
+22 -32
View File
@@ -30,13 +30,10 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os import os
import re import re
import subprocess import subprocess
import sys import sys
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import List, Tuple
@@ -102,37 +99,30 @@ def extract_upgrade_content(content: str) -> str:
def get_new_migrations_via_diff(diff_target: str) -> List[Path]: def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
""" """
通过 Gitea API 对比目标分支,找出 alembic/versions/ 下新增的迁移文件。 通过 git diff 对比目标分支/commit,找出 alembic/versions/ 下新增的迁移文件。
不依赖本地 git,避免 CI 环境下 git 操作不稳定的问题。 只包含新增文件(A状态),不包含修改或删除的文件。
""" """
api_url = os.environ.get("GITHUB_API_URL", "")
repo = os.environ.get("GITHUB_REPOSITORY", "")
token = os.environ.get("GITHUB_TOKEN", "")
branch = diff_target.replace("origin/", "")
if not api_url or not repo or not token:
print("⚠️ CI 环境变量不完整,降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
try: try:
url = f"{api_url}/repos/{repo}/contents/alembic/versions?ref={branch}" result = subprocess.run(
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) [
with urllib.request.urlopen(req, timeout=15) as resp: "git",
data = json.loads(resp.read().decode()) "diff",
"--name-only",
remote_files = {item["name"] for item in data if item["name"].endswith(".py")} "--diff-filter=A",
local_files = {f.name for f in ALEMBIC_VERSIONS_DIR.glob("*.py")} diff_target,
new_file_names = sorted(local_files - remote_files) "HEAD",
"--",
if new_file_names: "alembic/versions/",
result = [ALEMBIC_VERSIONS_DIR / f for f in new_file_names] ],
print(f" (API 对比 {branch} 分支,发现 {len(result)} 个新增迁移)") cwd=str(REPO_ROOT),
return result capture_output=True,
else: text=True,
print(f" (API 对比 {branch} 分支,无新增迁移)") check=True,
return [] )
except Exception as e: files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
print(f"⚠️ API 获取迁移列表失败:{e}") return [REPO_ROOT / f for f in files]
except subprocess.CalledProcessError as e:
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
print(" 降级为检查所有迁移文件") print(" 降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py")) return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
-91
View File
@@ -1,91 +0,0 @@
#!/bin/bash
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底)
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
# 用法: docker_build_push.sh <Dockerfile> <image_tag> <cache_ref> [build_arg...]
set -eu
DOCKERFILE="$1"
IMAGE_TAG="$2"
CACHE_REF="$3"
shift 3
BUILD_ARGS=""
for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
docker buildx create --use --name ci-builder --driver docker-container
echo "Created ci-builder"
else
docker buildx use ci-builder
echo "Using existing ci-builder"
fi
docker buildx inspect --bootstrap
# 从cache_ref中提取缓存名称(如 api-cache:develop -> api-cache-develop)
CACHE_NAME=$(echo "$CACHE_REF" | tr '/' '_' | tr ':' '-')
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
# 缓存源:local优先,registry兜底
CACHE_FROM_LOCAL="type=local,src=${LOCAL_CACHE_DIR}"
CACHE_FROM_REGISTRY="type=registry,ref=${CACHE_REF},ignore-error=true"
# 本地缓存目标(必选,mode=max最大化命中率)
CACHE_TO_LOCAL="type=local,dest=${LOCAL_CACHE_DIR},mode=max"
echo "=== Step 1: Build & push image (local cache read-write + registry read) ==="
echo "Local cache: ${LOCAL_CACHE_DIR}"
echo "Registry cache: ${CACHE_REF}"
echo ""
docker buildx build \
$BUILD_ARGS \
--cache-from "${CACHE_FROM_LOCAL}" \
--cache-from "${CACHE_FROM_REGISTRY}" \
--cache-to "${CACHE_TO_LOCAL}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
.
echo ""
echo "Image pushed: ${IMAGE_TAG}"
echo "Local cache updated"
echo ""
echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
MAX_RETRIES=3
SUCCESS=0
for attempt in $(seq 1 $MAX_RETRIES); do
echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
if docker buildx build \
$BUILD_ARGS \
--cache-from "${CACHE_FROM_LOCAL}" \
--cache-to "${CACHE_TO_REGISTRY}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
.; then
echo "Registry cache synced (attempt $attempt)"
SUCCESS=1
break
else
echo "Registry cache sync failed (attempt $attempt)"
if [ $attempt -lt $MAX_RETRIES ]; then
WAIT=$((attempt * 5))
echo "Retrying in ${WAIT}s..."
sleep $WAIT
fi
fi
done
if [ $SUCCESS -eq 0 ]; then
echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
fi
echo ""
echo "Build completed: ${IMAGE_TAG}"
+4 -4
View File
@@ -7,7 +7,7 @@
# 环境变量: # 环境变量:
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名) # IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
# REGISTRY_TOKEN - Registry 访问令牌 # REGISTRY_TOKEN - Registry 访问令牌
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji) # REGISTRY - Registry 地址(默认 git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas)
# REGISTRY_USER - Registry 用户名(默认 xiaoxia) # REGISTRY_USER - Registry 用户名(默认 xiaoxia)
# ENV_FILE - 环境变量文件路径 # ENV_FILE - 环境变量文件路径
# GENERATED_DIR - 生成文件目录 # GENERATED_DIR - 生成文件目录
@@ -16,9 +16,9 @@
set -eu set -eu
IMAGE_TAG="${IMAGE_TAG:-}" IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}" REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}" REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}" REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}" ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}" GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
+2 -2
View File
@@ -137,7 +137,7 @@ class PerfAssert:
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms) result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
last_response = None last_response = None
for _ in range(num_samples): for i in range(num_samples):
start = time.perf_counter() start = time.perf_counter()
last_response = func() last_response = func()
elapsed = (time.perf_counter() - start) * 1000 elapsed = (time.perf_counter() - start) * 1000
@@ -261,7 +261,7 @@ def run_perf_test(
result = PerfResult(name=name, threshold_ms=threshold_ms) result = PerfResult(name=name, threshold_ms=threshold_ms)
last_response = None last_response = None
for _ in range(samples): for i in range(samples):
start = time.perf_counter() start = time.perf_counter()
last_response = func() last_response = func()
elapsed = (time.perf_counter() - start) * 1000 elapsed = (time.perf_counter() - start) * 1000
+1 -1
View File
@@ -530,7 +530,7 @@ class TestLargeDataRequests:
def test_rapid_sequential_requests(self, auth_headers): def test_rapid_sequential_requests(self, auth_headers):
"""快速连续请求不应触发限流导致 500。""" """快速连续请求不应触发限流导致 500。"""
statuses = [] statuses = []
for _ in range(20): for i in range(20):
resp = client.get("/api/v1/projects", headers=auth_headers) resp = client.get("/api/v1/projects", headers=auth_headers)
statuses.append(resp.status_code) statuses.append(resp.status_code)
+1 -10
View File
@@ -665,16 +665,7 @@ class TestResponseSchema:
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
expected_keys = { expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
"plan_id",
"plan_status",
"generation_task_id",
"generation_task_status",
"progress",
"video_url",
"error_message",
"clips",
}
assert set(data.keys()) == expected_keys assert set(data.keys()) == expected_keys
+6 -6
View File
@@ -44,7 +44,7 @@ class TestEditTemplate:
def test_create_empty_name_raises(self): def test_create_empty_name_raises(self):
try: try:
EditTemplate.create(" ") EditTemplate.create(" ")
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
assert "模板名称不能为空" in str(e) assert "模板名称不能为空" in str(e)
@@ -74,14 +74,14 @@ class TestEditPlan:
def test_create_empty_name_raises(self): def test_create_empty_name_raises(self):
try: try:
EditPlan.create("tpl-1", " ") EditPlan.create("tpl-1", " ")
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
assert "计划名称不能为空" in str(e) assert "计划名称不能为空" in str(e)
def test_create_empty_template_id_raises(self): def test_create_empty_template_id_raises(self):
try: try:
EditPlan.create(" ", "test") EditPlan.create(" ", "test")
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
assert "template_id 不能为空" in str(e) assert "template_id 不能为空" in str(e)
@@ -112,7 +112,7 @@ class TestEditPlan:
p = EditPlan.create("tpl-1", "test") p = EditPlan.create("tpl-1", "test")
try: try:
p.start_rendering() # draft → rendering 不合法 p.start_rendering() # draft → rendering 不合法
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True) logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
@@ -120,7 +120,7 @@ class TestEditPlan:
p = EditPlan.create("tpl-1", "test") p = EditPlan.create("tpl-1", "test")
try: try:
p.mark_completed() # draft → completed 不合法 p.mark_completed() # draft → completed 不合法
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True) logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
@@ -128,7 +128,7 @@ class TestEditPlan:
p = EditPlan.create("tpl-1", "test") p = EditPlan.create("tpl-1", "test")
try: try:
p.reset_to_draft() # draft → draft 不合法 p.reset_to_draft() # draft → draft 不合法
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True) logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
+1 -1
View File
@@ -253,7 +253,7 @@ class TestConcatSecurity:
# 创建超过上限的段数 # 创建超过上限的段数
segments = [] segments = []
for _ in range(MAX_CONCAT_SEGMENTS + 5): for i in range(MAX_CONCAT_SEGMENTS + 5):
segments.append(ConcatSegment(video_path=str(sample_video))) segments.append(ConcatSegment(video_path=str(sample_video)))
config = ConcatConfig(segments=segments) config = ConcatConfig(segments=segments)
+1 -1
View File
@@ -367,7 +367,7 @@ class TestVerifyUrlRedirectValidation:
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if 300 <= e.code < 400 and e.headers.get("Location"): if 300 <= e.code < 400 and e.headers.get("Location"):
if redirect_count >= max_redirects: if redirect_count >= max_redirects:
raise Exception(f"重定向次数超过上限 ({max_redirects})") from e raise Exception(f"重定向次数超过上限 ({max_redirects})")
location = e.headers["Location"] location = e.headers["Location"]
current = urljoin(safe_url, location) current = urljoin(safe_url, location)
redirect_count += 1 redirect_count += 1
+2 -2
View File
@@ -115,7 +115,7 @@ class TestAudioMerger:
# 创建临时文件 # 创建临时文件
paths = [] paths = []
for _ in range(3): for i in range(3):
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"audio") f.write(b"audio")
paths.append(f.name) paths.append(f.name)
@@ -151,7 +151,7 @@ class TestAudioMerger:
mock_run_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error details") mock_run_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error details")
paths = [] paths = []
for _ in range(2): for i in range(2):
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"audio") f.write(b"audio")
paths.append(f.name) paths.append(f.name)
+2 -34
View File
@@ -45,7 +45,6 @@ class FakeClip:
start_time: float = 0.0 start_time: float = 0.0
duration: float = 0.0 duration: float = 0.0
transition_effect: str = "cut" transition_effect: str = "cut"
transition_duration: float = 0.0
status: str = "ready" status: str = "ready"
config: dict[str, Any] = field(default_factory=dict) config: dict[str, Any] = field(default_factory=dict)
@@ -66,7 +65,6 @@ def _make_clip(
asset_id: str = "", asset_id: str = "",
duration: float = 0.0, duration: float = 0.0,
transition_effect: str = "cut", transition_effect: str = "cut",
transition_duration: float = 0.0,
config: dict[str, Any] | None = None, config: dict[str, Any] | None = None,
) -> FakeClip: ) -> FakeClip:
return FakeClip( return FakeClip(
@@ -76,7 +74,6 @@ def _make_clip(
asset_id=asset_id or f"asset_{clip_id}.mp4", asset_id=asset_id or f"asset_{clip_id}.mp4",
duration=duration, duration=duration,
transition_effect=transition_effect, transition_effect=transition_effect,
transition_duration=transition_duration,
config=config or {}, config=config or {},
) )
@@ -335,7 +332,7 @@ class TestBuildFilterComplex:
assert "[final_video]" in fc assert "[final_video]" in fc
def test_single_layer_multi_clips(self): def test_single_layer_multi_clips(self):
"""多个 main clips(默认硬切)→ concat 串联。""" """多个 main clips → xfade 串联。"""
clips = [ clips = [
_make_clip("c1", "main", order=0, duration=3.0), _make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=3.0), _make_clip("c2", "main", order=1, duration=3.0),
@@ -346,35 +343,6 @@ class TestBuildFilterComplex:
} }
svc = _make_service(clips, asset_paths) svc = _make_service(clips, asset_paths)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
fc, input_args = svc._build_filter_complex(layers)
assert input_args.count("-i") == 2
# 全硬切场景走 concat filter(性能远优于 xfade)
assert "concat=n=2:v=1:a=0" in fc
assert "[final_video]" in fc
def test_single_layer_multi_clips_with_transition(self):
"""多个 main clips 带转场效果 → xfade 串联。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip(
"c2",
"main",
order=1,
duration=3.0,
transition_effect="fade",
transition_duration=0.5,
),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
}
svc = _make_service(clips, asset_paths)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips() resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved) layers = svc._group_clips_into_layers(resolved)
@@ -412,7 +380,7 @@ class TestBuildFilterComplex:
""" """
clips = [ clips = [
_make_clip("c1", "main", order=0, duration=3.0), _make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=5.0, transition_effect="fade", transition_duration=0.5), _make_clip("c2", "main", order=1, duration=5.0),
] ]
asset_paths = { asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"), "asset_c1.mp4": Path("/tmp/asset_c1.mp4"),