Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea65033b01 |
File diff suppressed because one or more lines are too long
Executable → Regular
+781
-28
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")
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Executable → Regular
+8
-12
@@ -108,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]
|
||||||
|
|
||||||
|
|
||||||
@@ -279,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:
|
||||||
@@ -326,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)
|
||||||
@@ -362,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,
|
||||||
@@ -405,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)
|
||||||
@@ -417,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Executable → Regular
+9
-25
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
-17
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 成品 / 视频相关 API
|
* 成品 / 视频相关 API
|
||||||
* 包含:列表查询、复核状态、批量下载
|
|
||||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
|
|||||||
@@ -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}秒`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}秒` : ""}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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={generated ? "生成完成" : "正在生成视频"}
|
|
||||||
open={generating || generated}
|
|
||||||
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}
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 搜索防抖处理 */
|
/** 搜索防抖处理 */
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: 合成各层
|
||||||
|
|||||||
@@ -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": "数据库连接失败"}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Executable → Regular
-3
@@ -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 "",
|
||||||
|
|||||||
@@ -148,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)
|
||||||
|
|||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Executable → Regular
+1
-10
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user