Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoxia c845ceb6ca feat(ci): 部署脚本从 base64 内嵌改为调用仓库脚本
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m13s
CI/CD Pipeline / Deploy Staging (SSH script) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 27s
- Staging: 从 Watchtower 改为 SSH 脚本主动部署
- Production: 从 base64 内嵌改为调用 scripts/ci-deploy-production.sh
- 部署脚本版本化,可审阅、可回滚
2026-07-13 15:29:38 +08:00
xiaoxia f92721e689 feat(ci): 提取生产部署脚本到 scripts/ci-deploy-production.sh 2026-07-13 15:29:18 +08:00
xiaoxia 47699a2e93 feat(ci): 提取 staging 部署脚本到 scripts/ci-deploy-staging.sh 2026-07-13 15:29:03 +08:00
235 changed files with 8969 additions and 19263 deletions
+1
View File
@@ -5,6 +5,7 @@ APP_ENV=production
ENVIRONMENT=production
DEBUG=false
USE_IN_MEMORY_DB=false
LOG_LEVEL=WARNING
# ==================== 数据库(必须修改)====================
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
Regular → Executable
+101 -88
View File
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
"""add error_info and retry fields to generation_tasks
Revision ID: 038_error_retry
Revises: 037_generation_logs
Create Date: 2026-07-13 22:15:00.000000
"""
import sqlalchemy as sa
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
from alembic import op
# revision identifiers, used by Alembic.
revision = "038_error_retry"
down_revision = "037_generation_logs"
branch_labels = None
depends_on = None
def upgrade():
# error_info: 结构化错误信息(error_type, message, stack_trace, failed_at, stage等)
op.add_column(
"generation_tasks",
sa.Column("error_info", sa.JSON(), nullable=True),
)
# retry_count: 重试次数
op.add_column(
"generation_tasks",
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
)
# auto_retry_enabled: 是否开启自动重试
op.add_column(
"generation_tasks",
sa.Column("auto_retry_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
# auto_retry_max: 最大自动重试次数
op.add_column(
"generation_tasks",
sa.Column("auto_retry_max", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_column("generation_tasks", "auto_retry_max")
op.drop_column("generation_tasks", "auto_retry_enabled")
op.drop_column("generation_tasks", "retry_count")
op.drop_column("generation_tasks", "error_info")
@@ -1,34 +0,0 @@
"""add transition_duration to edit_plan_clips
Revision ID: 039_transition_duration
Revises: 038_error_retry
Create Date: 2026-07-14 09:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "039_transition_duration"
down_revision = "038_error_retry"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column(
"transition_duration",
sa.Float(),
nullable=False,
server_default="0.0",
),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "transition_duration")
@@ -1,29 +0,0 @@
"""add playback_speed to edit_plan_clips
Revision ID: 040_playback_speed
Revises: 039_transition_duration
Create Date: 2026-07-14 10:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "040_playback_speed"
down_revision = "039_transition_duration"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "playback_speed")
+29 -5
View File
@@ -4,14 +4,19 @@ from app.api.routes.assets import router as assets_router
from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.dashboard import router as dashboard_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.edit_templates import router as edit_templates_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generated_videos import router as generated_videos_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.jobs import router as jobs_router
from app.api.routes.projects import router as projects_router
from app.api.routes.recipes import router as recipes_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
@@ -19,7 +24,6 @@ from app.api.routes.templates import router as templates_router
from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router
from app.api.routes.videos import router as videos_router
from app.api.routes.voice_clones import router as voice_clones_router
from app.api.routes.voices import router as voices_router
from fastapi import APIRouter
@@ -85,6 +89,15 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
jobs_router,
tags=["Job"],
)
api_router.include_router(
generated_videos_router,
prefix="/generated-videos",
tags=["GeneratedVideo"],
)
api_router.include_router(
titles_router,
prefix="/titles",
@@ -100,10 +113,6 @@ api_router.include_router(
prefix="/voice-clones",
tags=["VoiceClone"],
)
api_router.include_router(
videos_router,
tags=["VideoCenter"],
)
api_router.include_router(
duplication_router,
prefix="/duplication",
@@ -114,11 +123,26 @@ api_router.include_router(
prefix="/subscription",
tags=["Subscription"],
)
api_router.include_router(
recipes_router,
prefix="/recipes",
tags=["Recipe"],
)
api_router.include_router(
templates_router,
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
dashboard_router,
prefix="/dashboard",
tags=["Dashboard"],
)
api_router.include_router(
edit_templates_router,
prefix="/edit-templates",
tags=["EditTemplate"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
+6 -5
View File
@@ -12,7 +12,7 @@ from app.schemas.asset_library import (
EnsureDefaultLibraryRequest,
ListAssetLibrariesResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.application import (
CreateAssetLibraryCommand,
@@ -146,7 +146,7 @@ def ensure_default_library(
return _to_asset_library_response(created)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_asset_library(
library_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -163,10 +163,11 @@ def delete_asset_library(
# 权限校验:检查用户是否有项目访问权限
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
# 删除库内所有素材(硬删除,素材库已删除,无需保留软删除状态
# 删除库内所有素材(无 FK 级联,需手动清理
assets_in_library = asset_repository.find_by_library(library_id)
for asset in assets_in_library:
asset_repository.delete(asset.id)
if assets_in_library:
asset_ids_to_delete = [a.id for a in assets_in_library]
asset_repository.batch_delete(asset_ids_to_delete)
# 删除素材库本身
asset_library_repository.delete(library_id)
+22 -185
View File
@@ -1,7 +1,6 @@
import logging
from typing import Any, Optional
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
@@ -12,18 +11,15 @@ from app.dependencies import (
)
from app.schemas.asset import (
AssetResponse,
BatchClassifyRequest,
BatchDeleteRequest,
BatchMarkRequest,
BatchOperationResponse,
BatchTagRequest,
BatchDeleteResponse,
CreateAssetRequest,
ListAssetsResponse,
UpdateAssetRequest,
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
CreateAssetCommand,
@@ -31,6 +27,8 @@ from packages.application import (
)
from packages.domain import AssetStatus, ClassificationStatus
from app.api.routes._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -76,6 +74,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
)
@router.get("", response_model=ListAssetsResponse)
def list_assets(
library_id: Optional[str] = Query(None),
@@ -85,15 +84,6 @@ def list_assets(
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
smart_view: Optional[str] = Query(
None,
description="智能视图筛选:recommended=推荐(质量分≥80)、cautious=慎用(60-79)、risky=高风险(<60或已驳回)、unused=未使用、used=已使用、pending_review=待复核",
pattern="^(recommended|cautious|risky|unused|used|pending_review)$",
),
classification: Optional[str] = Query(
None,
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -113,11 +103,11 @@ def list_assets(
if not filter_tag_ids:
filter_tag_ids = None
# 需要内存过滤的标志(keyword/gender/style/tag_ids/smart_view/classification 无法在 DB 层过滤)
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids or smart_view or classification)
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
def _apply_memory_filters(items):
"""应用 keyword / gender / style / tag_ids / smart_view / classification 内存过滤。"""
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
result = items
if keyword:
kw = keyword.lower()
@@ -126,38 +116,9 @@ def list_assets(
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
if style:
result = [i for i in result if (i.metadata or {}).get("style") == style]
if classification:
result = [i for i in result if (i.metadata or {}).get("classification") == classification]
if filter_tag_ids:
tag_set = set(filter_tag_ids)
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
if smart_view:
def __meta(a):
return a.metadata or {}
def __use_count(a):
return int(__meta(a).get("generation_use_count") or 0)
def __review_status(a):
return __meta(a).get("review_status", "")
if smart_view == "recommended":
result = [i for i in result if i.quality_score is not None and i.quality_score >= 80]
elif smart_view == "cautious":
result = [i for i in result if i.quality_score is not None and 60 <= i.quality_score < 80]
elif smart_view == "risky":
result = [
i
for i in result
if (i.quality_score is not None and i.quality_score < 60) or __review_status(i) == "rejected"
]
elif smart_view == "unused":
result = [i for i in result if __use_count(i) == 0]
elif smart_view == "used":
result = [i for i in result if __use_count(i) > 0]
elif smart_view == "pending_review":
result = [i for i in result if __review_status(i) == "pending_review"]
return result
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
@@ -301,157 +262,33 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch-delete", response_model=BatchOperationResponse)
@router.post("/batch-delete", response_model=BatchDeleteResponse)
def batch_delete_assets(
request: BatchDeleteRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchOperationResponse:
"""批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。"""
) -> BatchDeleteResponse:
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
deleted_ids: list[str] = []
failed_ids: list[str] = []
for asset_id in request.asset_ids:
for asset_id in request.ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
failed_ids.append(asset_id)
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
deleted_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
failed_ids.append(asset_id)
if success_ids:
asset_repository.batch_delete(success_ids)
if deleted_ids:
asset_repository.batch_delete(deleted_ids)
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
@router.post("/batch-tag", response_model=BatchOperationResponse)
def batch_tag_assets(
request: BatchTagRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
tag_repository: Any = Depends(get_tag_repository),
) -> BatchOperationResponse:
"""批量打标签(添加或替换模式),需逐项校验项目权限和标签权限。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
# 校验标签存在且属于当前用户
for tag_id in request.tag_ids:
tag = tag_repository.get(tag_id)
if tag is None:
return BatchOperationResponse(
success_count=0,
failed_ids=list(request.asset_ids),
failed_details={aid: f"tag_not_found:{tag_id}" for aid in request.asset_ids},
)
if tag.user_id != user_id:
return BatchOperationResponse(
success_count=0,
failed_ids=list(request.asset_ids),
failed_details={aid: f"tag_access_denied:{tag_id}" for aid in request.asset_ids},
)
# 校验素材权限
for asset_id in request.asset_ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
if success_ids:
if request.mode == "replace":
asset_repository.batch_replace_tags(success_ids, request.tag_ids)
else:
asset_repository.batch_add_tags(success_ids, request.tag_ids)
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
@router.post("/batch-classify", response_model=BatchOperationResponse)
def batch_classify_assets(
request: BatchClassifyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchOperationResponse:
"""批量修改素材内容分类(person/scenic/product等),存在metadata.category中。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
for asset_id in request.asset_ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
if success_ids:
asset_repository.batch_update_metadata(success_ids, {"category": request.category})
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
@router.post("/batch-mark", response_model=BatchOperationResponse)
def batch_mark_assets(
request: BatchMarkRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchOperationResponse:
"""批量设置智能视图标记(recommended/caution/high_risk),存在metadata.smart_view中。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
for asset_id in request.asset_ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
if success_ids:
asset_repository.batch_update_metadata(success_ids, {"smart_view": request.smart_view})
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
@router.get("/{asset_id}", response_model=AssetResponse)
@@ -493,7 +330,7 @@ def update_asset(
return _to_asset_response(updated)
@router.delete("/{asset_id}", status_code=204, response_class=Response)
@router.delete("/{asset_id}", status_code=204)
def delete_asset(
asset_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -532,7 +369,7 @@ def tag_asset(
return _to_asset_response(updated)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204, response_class=Response)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
def untag_asset(
asset_id: str,
tag_id: str,
+11 -11
View File
@@ -105,7 +105,7 @@ async def register(
request: RegisterRequest,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
) -> RegisterResponse:
):
use_case = RegisterUserUseCase(
user_repository=user_repository,
base_url=settings.APP_BASE_URL,
@@ -136,7 +136,7 @@ async def login(
request: LoginRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
) -> LoginResponse:
):
use_case = LoginUseCase(
user_repository=user_repository,
session_store=session_store,
@@ -162,7 +162,7 @@ async def refresh(
request: RefreshRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
) -> LoginResponse:
):
use_case = RefreshTokenUseCase(
user_repository=user_repository,
session_store=session_store,
@@ -194,7 +194,7 @@ def _verify_email_token(token: str, user_repository: UserRepository) -> MessageR
async def verify_email(
token: str,
user_repository: UserRepository = Depends(get_user_repository),
) -> MessageResponse:
):
return _verify_email_token(token, user_repository)
@@ -202,7 +202,7 @@ async def verify_email(
async def verify_email_post(
request: VerifyEmailRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
) -> MessageResponse:
):
return _verify_email_token(request.token, user_repository)
@@ -211,7 +211,7 @@ async def forgot_password(
request: PasswordResetRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
) -> MessageResponse:
):
success, error = RequestPasswordResetUseCase(
user_repository=user_repository,
base_url=settings.APP_BASE_URL,
@@ -227,7 +227,7 @@ async def forgot_password(
async def reset_password(
request: ResetPasswordModel,
user_repository: UserRepository = Depends(get_user_repository),
) -> MessageResponse:
):
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
ResetPasswordRequest(token=request.token, new_password=request.new_password)
)
@@ -241,7 +241,7 @@ async def reset_password(
async def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> MessageResponse:
):
"""登出 - 将当前 token 加入黑名单"""
if credentials:
@@ -257,7 +257,7 @@ async def logout(
@router.get("/me", response_model=CurrentUserResponse)
async def get_current_user_info(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> CurrentUserResponse:
):
user = authenticated_user.user
return CurrentUserResponse(
user_id=user.id,
@@ -323,7 +323,7 @@ def _get_internal_api_keys() -> list[str]:
if content:
return [k.strip() for k in content.split(",") if k.strip()]
except Exception:
logger.warning("无法读取内部 API 密钥文件,仅依赖环境变量配置", exc_info=True)
logger.debug("Failed to read internal API keys from file", exc_info=True)
return []
@@ -354,7 +354,7 @@ async def wechat_sync(
request: WechatSyncRequest,
user_repository: UserRepository = Depends(get_user_repository),
_: bool = Depends(_verify_internal_api_key),
) -> WechatSyncResponse:
):
"""
微信同步登录/注册(系统级内部接口)
+2 -1
View File
@@ -13,7 +13,6 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
@@ -35,6 +34,8 @@ from fastapi.params import File
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
router = APIRouter()
logger = logging.getLogger(__name__)
+92
View File
@@ -0,0 +1,92 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_generation_task_repository,
get_project_repository,
get_title_library_repository,
get_voice_library_repository,
)
from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo
from fastapi import APIRouter, Depends
router = APIRouter()
def _status_value(status) -> str:
return status.value if hasattr(status, "value") else str(status)
def _generation_step(status: str) -> str:
if status == "pending":
return "等待 Worker 执行"
if status == "running":
return "正在生成成片"
if status == "completed":
return "生成完成"
if status == "failed":
return "生成失败"
return status
@router.get("/overview", response_model=DashboardOverviewResponse)
def get_dashboard_overview(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_repository: Any = Depends(get_asset_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
title_library_repository: Any = Depends(get_title_library_repository),
voice_library_repository: Any = Depends(get_voice_library_repository),
) -> DashboardOverviewResponse:
"""Dashboard 概览:用户级汇总数据。"""
user_id = authenticated_user.user.id
# 获取用户可访问的所有 project
projects = project_repository.find_accessible_projects(user_id)
project_ids = [p.id for p in projects]
# 素材统计
total_assets = asset_repository.count_by_project_ids(project_ids)
used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids)
# 标题库 / 配音库统计
total_titles = title_library_repository.count_by_user(user_id)
total_voices = voice_library_repository.count_by_user(user_id)
# 生成任务统计
total_tasks = generation_task_repository.count_by_user(user_id)
# 最近任务(SQL 层 LIMIT 5
recent = generation_task_repository.list_recent_by_user(user_id, limit=5)
recent_tasks = []
for task in recent:
s = _status_value(task.status)
recent_tasks.append(
RecentTaskItem(
id=task.id,
task_type="generation",
status=s,
current_step=_generation_step(s),
error_message=task.error_message or "",
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
# 订阅信息
user = authenticated_user.user
subscription = SubscriptionInfo(
plan=getattr(user, "subscription_plan", "free") or "free",
is_active=getattr(user, "subscription_status", "") == "active",
)
return DashboardOverviewResponse(
total_assets=total_assets,
used_storage_bytes=used_storage_bytes,
total_titles=total_titles,
total_voices=total_voices,
total_tasks=total_tasks,
total_products=len(projects),
subscription=subscription,
recent_tasks=recent_tasks,
)
+2 -2
View File
@@ -239,7 +239,7 @@ def get_duplication_detail(
return _to_detail_response(record)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_duplication_record(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -257,7 +257,7 @@ def delete_duplication_record(
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
use_case.execute(record_id)
return
return Response(status_code=204)
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
+776 -22
View File
@@ -6,11 +6,12 @@ RESTful CRUD for EditPlan:
- POST /api/v1/edit-plans 创建
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
- DELETE /api/v1/edit-plans/{id} 删除
拆分模块(各自独立 router,由本文件 include_router 聚合):
- edit_plans_generation.py 生成相关(generate / generation-status / generations
- edit_plans_ai.py AI 推荐 & 封面(ai-recommend / generate-cover
- edit_plans_timeline.py 时间线 & 模板生成(timeline / generate-from-template
- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05)
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
业务逻辑委托给 EditPlanService 服务层。
"""
@@ -22,17 +23,32 @@ from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from ._helpers import check_project_access
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
logger = logging.getLogger(__name__)
@@ -146,7 +162,6 @@ class AIRecommendClipItem(BaseModel):
text_content: str = Field(default="", description="文字内容")
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
transition_effect: str = Field(default="cut", description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
asset_id: str = Field(default="", description="关联素材 ID")
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
@@ -210,8 +225,6 @@ class _PlanClipItem(BaseModel):
start_time: float
duration: float
transition_effect: str
transition_duration: float
playback_speed: float = 1.0
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
@@ -245,7 +258,7 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
)
# ── CRUD Routes ───────────────────────────────────────────────────────────────
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse)
@@ -425,7 +438,7 @@ def update_plan(
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
@@ -451,12 +464,753 @@ def delete_plan(
)
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
# ── 生成相关端点(任务 2.05) ─────────────────────────────────────────────────
from .edit_plans_ai import router as ai_router
from .edit_plans_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router
router.include_router(generation_router)
router.include_router(ai_router)
router.include_router(timeline_router)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
# 项目鉴权
plan_check = svc.get_plan(plan_id)
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
# 回退到旧模型 template_segments
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main", # 旧模型无结构角色,统一为主体片段
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = [] # 已分配完
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
# 筛选 ready 状态的视频素材
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
# 随机选取,按片段数轮询分配
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if not can_gen:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=reason,
)
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
try:
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
# 队列限流预检查(repository 不支持计数时跳过)
user_id = current_user.user.id
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
)
def get_generation_status(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度
返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。
"""
svc = EditPlanService(db)
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
plan = gen_status["plan"]
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"],
clips=clip_items,
)
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
)
def list_plan_generations(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationsResponse:
"""查询剪辑计划关联的所有生成记录
返回该剪辑计划触发的所有 GenerationTask,按创建时间倒序。
"""
svc = EditPlanService(db)
# 验证计划存在 + 项目鉴权
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
# ── AI 推荐 & 封面生成端点(任务 3.09) ────────────────────────────────────────
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
)
def ai_recommend_clips(
plan_id: str,
body: AIRecommendRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> AIRecommendResponse:
"""AI 推荐片段方案
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
流程:
1. 验证计划存在且状态为 draft/editing
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
3. 清除计划现有片段,按推荐方案重新创建
4. 更新计划 configcover/title/subtitle/bgm)和 total_duration
5. 返回推荐方案详情
前端对接:
- 请求体只需传 asset_ids(必填),editing_mode 和 target_duration 可选
- 返回的 clips 可直接渲染到时间线
- 返回的 config 包含推荐的封面/标题/字幕/BGM 配置
"""
svc = EditPlanService(db)
# 验证计划存在
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证状态:只允许 draft 或 editing
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
)
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
# ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ──
# TODO: 当前各 repo 方法内部 commit(),无法真正回滚。
# 后续重构 repo 为 flush() 模式后,此处改为统一 commit。
try:
# 清除现有片段
svc.delete_all_clips(plan_id)
# 按推荐方案创建新片段
for clip_data in result["clips"]:
svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
# 更新计划 config 和 total_duration
normalized_config = normalize_plan_config(result.get("config", {}))
svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
# 尝试回滚未提交的变更
try:
db.rollback()
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
AIRecommendClipItem(
clip_type=c["clip_type"],
order=c["order"],
text_content=c.get("text_content", ""),
duration=c["duration"],
transition_effect=c.get("transition_effect", "cut"),
asset_id=c.get("asset_id", ""),
start_time=c.get("start_time", 0.0),
config=c.get("config", {}),
)
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
)
def generate_cover(
plan_id: str,
body: GenerateCoverRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateCoverResponse:
"""AI 生成封面
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
流程:
1. 验证计划存在
2. 调用 AI 封面生成服务(当前为 stub,后续接入真实 AI)
3. 更新 plan.config["cover"] 为生成的封面数据
4. 返回封面数据
前端对接:
- cover_type=ai_frame: AI 智能选帧(默认)
- cover_type=manual: 手动选帧,需传 frame_time
- cover_type=upload: 用户上传,接口返回空 image_url,前端自行上传后更新
- cover_type=ai_regenerate: AI 重新生成
"""
svc = EditPlanService(db)
# 验证计划存在
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 调用 AI 封面生成服务
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
# 更新 plan.config["cover"]
current_config = dict(plan.config)
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"AI 封面生成: plan_id=%s type=%s by user=%s",
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(
plan_id=plan_id,
cover=cover_data,
)
# ── Timeline / Scene 端点(P2-6) ─────────────────────────────────────────────
class TimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str = Field(..., description="场景描述")
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
duration: float = Field(..., ge=0, description="时长(秒)")
color: str = Field(..., description="展示颜色")
clip_id: str = Field(default="", description="关联的片段 ID")
clip_type: str = Field(default="", description="片段类型")
class TimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[TimelineSceneResponse]
# clip_type → 颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""将秒数格式化为 M:SS"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""根据 clip_type 和 text_content 生成场景描述"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
# 截取前 20 个字符作为副标题
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
)
def get_plan_timeline(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> TimelineResponse:
"""获取剪辑计划的时间线场景数据
返回按计划片段排序的时间线场景列表,供前端 GeneratePage 渲染使用。
"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
# 按 order 排序
clips.sort(key=lambda c: c.order)
scenes: List[TimelineSceneResponse] = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
TimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return TimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划
流程:
1. 获取模板及其片段配置
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
3. 返回完整的计划和片段列表
"""
from app.services import EditTemplateService
# 项目鉴权
if body.project_id:
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
# 获取模板
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 获取模板片段配置
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 调用 PlanGeneratorService 生成计划
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
-199
View File
@@ -1,199 +0,0 @@
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/ai-recommend AI 推荐片段方案
- POST /{plan_id}/generate-cover AI 生成封面
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
AIRecommendClipItem,
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
)
def ai_recommend_clips(
plan_id: str,
body: AIRecommendRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> AIRecommendResponse:
"""AI 推荐片段方案
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
流程:
1. 验证计划存在且状态为 draft/editing
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
3. 清除计划现有片段,按推荐方案重新创建
4. 更新计划 configcover/title/subtitle/bgm)和 total_duration
5. 返回推荐方案详情
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
)
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
try:
svc.delete_all_clips(plan_id)
for clip_data in result["clips"]:
svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
normalized_config = normalize_plan_config(result.get("config", {}))
svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
except Exception as rollback_err:
logger.error(
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
plan_id,
rollback_err,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
AIRecommendClipItem(
clip_type=c["clip_type"],
order=c["order"],
text_content=c.get("text_content", ""),
duration=c["duration"],
transition_effect=c.get("transition_effect", "cut"),
asset_id=c.get("asset_id", ""),
start_time=c.get("start_time", 0.0),
config=c.get("config", {}),
)
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
)
def generate_cover(
plan_id: str,
body: GenerateCoverRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateCoverResponse:
"""AI 生成封面
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
current_config = dict(plan.config)
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"AI 封面生成: plan_id=%s type=%s by user=%s",
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(
plan_id=plan_id,
cover=cover_data,
)
@@ -1,383 +0,0 @@
"""剪辑计划生成相关 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/generate 触发剪辑渲染生成
- GET /{plan_id}/generation-status 查询生成进度
- GET /{plan_id}/generations 查询关联的生成记录
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
def _auto_fallback_assign_assets(
svc: EditPlanService,
plan_id: str,
plan_check,
) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = []
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取"""
if not clips_without_asset:
return
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if material_mode != "auto" or not plan_check.project_id:
return
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
plan_check = svc.get_plan(plan_id)
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# 自动兜底流程
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
# 核心生成流程
try:
clip_count = svc.mark_clips_ready(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
)
def get_generation_status(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度"""
svc = EditPlanService(db)
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
plan = gen_status["plan"]
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"],
clips=clip_items,
)
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
)
def list_plan_generations(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationsResponse:
"""查询剪辑计划关联的所有生成记录"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from app.schemas.generation_task import GenerationTaskResponse
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
@@ -1,221 +0,0 @@
"""剪辑计划时间线 & 模板生成 API 端点。
从 edit_plans.py 拆分,包含:
- GET /{plan_id}/timeline 时间线场景数据
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
"""
from __future__ import annotations
import logging
from typing import Any, List
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
_PlanClipItem,
_to_response,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Timeline Schemas ──────────────────────────────────────────────────────────
class TimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str = Field(..., description="场景描述")
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
duration: float = Field(..., ge=0, description="时长(秒)")
color: str = Field(..., description="展示颜色")
clip_id: str = Field(default="", description="关联的片段 ID")
clip_type: str = Field(default="", description="片段类型")
class TimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[TimelineSceneResponse]
# clip_type → 颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""将秒数格式化为 M:SS"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""根据 clip_type 和 text_content 生成场景描述"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
)
def get_plan_timeline(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> TimelineResponse:
"""获取剪辑计划的时间线场景数据"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
clips.sort(key=lambda c: c.order)
scenes: List[TimelineSceneResponse] = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
TimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return TimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划"""
from app.services import EditTemplateService
if body.project_id:
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
transition_duration=c.transition_duration,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
+289
View File
@@ -0,0 +1,289 @@
"""模板管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditTemplate:
- GET /api/v1/edit-templates 列表(分页 + 类型筛选)
- GET /api/v1/edit-templates/{id} 详情
- POST /api/v1/edit-templates 创建(管理员)
- PUT /api/v1/edit-templates/{id} 更新
- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive
业务逻辑委托给 EditTemplateService 服务层。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_template_config
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditTemplateCreateRequest(BaseModel):
"""创建模板请求体"""
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=2000, description="模板描述")
template_type: str = Field(default="default", max_length=50, description="模板类型")
editing_mode: str = Field(
default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
preview_url: str = Field(default="", max_length=500, description="预览地址")
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
class EditTemplateUpdateRequest(BaseModel):
"""更新模板请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
editing_mode: Optional[str] = Field(
default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
status: Optional[str] = Field(default=None, description="状态: active / inactive")
class EditTemplateResponse(BaseModel):
"""模板响应体"""
id: str
name: str
description: str
template_type: str
editing_mode: str
config: dict[str, Any]
preview_url: str
sort_weight: int
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditTemplateListResponse(BaseModel):
"""模板列表响应体"""
items: List[EditTemplateResponse]
total: int
page: int
page_size: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _require_admin(current_user: AuthenticatedUser) -> None:
"""校验当前用户是否为管理员,非管理员返回 403"""
if not getattr(current_user.user, "is_admin", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅管理员可执行此操作",
)
def _to_response(t: EditTemplate) -> EditTemplateResponse:
return EditTemplateResponse(
id=t.id,
name=t.name,
description=t.description,
template_type=t.template_type,
editing_mode=t.editing_mode,
config=t.config,
preview_url=t.preview_url,
sort_weight=t.sort_weight,
status=t.status.value if hasattr(t.status, "value") else t.status,
created_at=t.created_at,
updated_at=t.updated_at,
)
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditTemplateListResponse)
def list_templates(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_type: Optional[str] = Query(default=None, description="按类型筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: active / inactive",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateListResponse:
"""获取模板列表(支持分页、按类型/状态筛选)"""
svc = EditTemplateService(db)
# 解析状态筛选
status_enum: Optional[EditTemplateStatus] = None
if status_filter:
try:
status_enum = EditTemplateStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {status_filter},可选值: active, inactive",
)
skip = (page - 1) * page_size
templates = svc.list_templates(
template_type=template_type,
status=status_enum,
skip=skip,
limit=page_size,
)
total = svc.count_templates(
template_type=template_type,
status=status_enum,
)
return EditTemplateListResponse(
items=[_to_response(t) for t in templates],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{template_id}", response_model=EditTemplateResponse)
def get_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""获取单个模板详情"""
svc = EditTemplateService(db)
try:
template = svc.get_template_or_raise(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
return _to_response(template)
@router.post("", response_model=EditTemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
body: EditTemplateCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""创建模板(管理员)"""
_require_admin(current_user)
svc = EditTemplateService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_template_config(body.config)
try:
created = svc.create_template(
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=normalized_config,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id)
return _to_response(created)
@router.put("/{template_id}", response_model=EditTemplateResponse)
def update_template(
template_id: str,
body: EditTemplateUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""更新模板"""
_require_admin(current_user)
svc = EditTemplateService(db)
# 解析状态
status_enum: Optional[EditTemplateStatus] = None
if body.status is not None:
try:
status_enum = EditTemplateStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {body.status},可选值: active, inactive",
)
# 标准化 config(如果提供了)
config_to_update = normalize_template_config(body.config) if body.config is not None else None
try:
result = svc.update_template(
template_id,
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=config_to_update,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
status=status_enum,
)
except ValueError as exc:
err_msg = str(exc)
if "不存在" in err_msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
)
logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id)
return _to_response(result)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> Response:
"""删除模板(软删除 → 设为 inactive)"""
_require_admin(current_user)
svc = EditTemplateService(db)
try:
svc.deactivate_template(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id)
return Response(status_code=204)
+9 -8
View File
@@ -19,10 +19,11 @@ from typing import Optional
from app.api.routes.auth import _verify_internal_api_key
from app.config import settings
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from packages.adapters.redis.feature_flag_store import (
FEATURE_FLAG_REDIS_PREFIX,
FeatureFlagConfig,
RedisFeatureFlagStore,
)
@@ -89,7 +90,7 @@ def _validate_flag_name(name: str) -> None:
async def list_feature_flags(
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> list[FeatureFlagResponse]:
):
"""列出所有 Feature Flag。"""
try:
flags = store.list_all()
@@ -113,7 +114,7 @@ async def get_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> FeatureFlagResponse:
):
"""获取单个 Feature Flag 配置。"""
try:
config = store.get(name)
@@ -129,7 +130,7 @@ async def check_feature_flag(
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> FeatureFlagCheckResponse:
):
"""检查某个标识符是否命中 Feature Flag。"""
try:
active = store.is_active(name, identifier=identifier)
@@ -145,7 +146,7 @@ async def update_feature_flag(
request: FeatureFlagUpdateRequest,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> FeatureFlagResponse:
):
"""更新 Feature Flag 配置。
只允许修改 ALLOWED_FLAGS 列表中的 flag。
@@ -173,12 +174,12 @@ async def update_feature_flag(
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)
async def delete_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) :
):
"""删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -188,7 +189,7 @@ async def delete_feature_flag(
try:
deleted = store.delete(name)
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
pass
return None
except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
+123
View File
@@ -0,0 +1,123 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository, get_project_repository
from app.schemas.generated_video import (
GeneratedVideoDownloadUrlResponse,
GeneratedVideoResponse,
ListGeneratedVideosResponse,
UpdateGeneratedVideoReviewRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
GetGeneratedVideoDownloadUrlUseCase,
GetGeneratedVideoUseCase,
ListGeneratedVideosUseCase,
)
router = APIRouter()
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
return GeneratedVideoResponse(
id=item.id,
project_id=item.project_id,
generation_task_id=item.generation_task_id,
name=item.name,
file_url=item.file_url,
file_size=item.file_size,
duration=item.duration,
thumbnail_url=item.thumbnail_url,
width=item.width,
height=item.height,
fps=item.fps,
status=item.status,
review_status=item.review_status,
generation_params=item.generation_params,
download_url=download_url,
)
@router.get("", response_model=ListGeneratedVideosResponse)
def list_generated_videos(
project_id: str | None = Query(None),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ListGeneratedVideosResponse:
user_id = authenticated_user.user.id
use_case = ListGeneratedVideosUseCase(generated_video_repository)
if project_id:
# If project_id provided, check access and filter by project
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
items = use_case.execute(project_id)
else:
# If no project_id, list all videos from accessible projects
accessible_projects = project_repository.find_accessible_projects(user_id)
all_items = []
for proj in accessible_projects:
all_items.extend(use_case.execute(proj.id))
items = all_items
# Generate download URLs for each video
responses = []
for item in items:
download_url = storage_service.get_download_url(item.file_url)
responses.append(_to_generated_video_response(item, download_url=download_url))
return ListGeneratedVideosResponse(items=responses)
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
def get_generated_video(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
use_case = GetGeneratedVideoUseCase(generated_video_repository)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
download_url = storage_service.get_download_url(item.file_url)
return _to_generated_video_response(item, download_url=download_url)
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
def update_generated_video_review_status(
video_id: str,
request: UpdateGeneratedVideoReviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
video.review_status = request.review_status
updated = generated_video_repository.update(video)
download_url = storage_service.get_download_url(updated.file_url)
return _to_generated_video_response(updated, download_url=download_url)
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
def get_generated_video_download_url(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoDownloadUrlResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
file_url = use_case.execute(video_id)
if file_url is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
download_url = storage_service.get_download_url(file_url)
return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url)
+3 -3
View File
@@ -3,7 +3,6 @@ import random
import uuid
from typing import Any
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import (
@@ -11,6 +10,7 @@ from app.core.task_enqueue import (
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
check_queue_limits,
safe_enqueue_generation_task,
)
from app.dependencies import (
@@ -32,6 +32,8 @@ from app.schemas.generation_task import (
)
from fastapi import APIRouter, Depends, HTTPException
from app.api.routes._helpers import check_project_access
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -267,8 +269,6 @@ def create_generation_task(
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max,
)
)
try:
+325
View File
@@ -0,0 +1,325 @@
"""Job API 路由 — Phase 8 任务 2.10.
提供统一异步任务管理 RESTful 接口:
- POST /api/v1/jobs 创建任务
- GET /api/v1/jobs/{job_id} 任务详情
- GET /api/v1/projects/{project_id}/jobs 项目任务列表
- GET /api/v1/projects/{project_id}/jobs/stats 任务统计
- PUT /api/v1/jobs/{job_id}/progress 更新进度
- POST /api/v1/jobs/{job_id}/complete 标记完成
- POST /api/v1/jobs/{job_id}/fail 标记失败
- POST /api/v1/jobs/{job_id}/retry 重试任务
- POST /api/v1/jobs/{job_id}/cancel 取消任务
- POST /api/v1/jobs/{job_id}/submit 提交执行
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_job_repository, get_project_repository
from app.schemas.job import (
CompleteJobRequest,
CreateJobRequest,
FailJobRequest,
JobResponse,
JobStatisticsResponse,
ListJobsResponse,
UpdateProgressRequest,
job_to_response,
)
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
CompleteJobUseCase,
CreateJobCommand,
CreateJobUseCase,
FailJobCommand,
FailJobUseCase,
GetJobStatisticsUseCase,
GetJobUseCase,
ListJobsUseCase,
RetryJobUseCase,
SubmitJobUseCase,
UpdateJobProgressCommand,
UpdateJobProgressUseCase,
)
from packages.domain.job import JobType
from app.api.routes._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# 任务类型 → Celery task name 映射
_JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
JobType.VIDEO_COMPOSE: "worker.compose_video",
JobType.RENDER_EDIT_PLAN: "worker.render_edit_plan",
JobType.ASSET_INGEST: "worker.ingest_asset",
JobType.CLASSIFICATION: "worker.classify_asset",
JobType.VOICE_EXTRACTION: "worker.extract_voice",
JobType.GENERATION: "worker.generate_video",
}
# ── 创建任务 ──────────────────────────────────────────────────────────────────
@router.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
def create_job(
request: CreateJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobResponse:
"""创建异步任务。
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
"""
check_project_access(request.project_id, authenticated_user.user.id, project_repository)
# 校验 job_type
try:
JobType(request.job_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"不支持的任务类型: {request.job_type}" f"可选值: {[t.value for t in JobType]}",
)
use_case = CreateJobUseCase(job_repo)
job = use_case.execute(
CreateJobCommand(
project_id=request.project_id,
job_type=request.job_type,
payload=request.payload,
source_id=request.source_id,
created_by_user_id=authenticated_user.user.id,
max_retries=request.max_retries,
)
)
return job_to_response(job)
# ── 提交执行 ──────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/submit", response_model=JobResponse)
def submit_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""提交任务执行。
将任务状态从 pending 切换为 running,并 dispatch Celery 异步任务。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = SubmitJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Dispatch Celery 任务
celery_task_name = _JOB_TYPE_TO_CELERY_TASK.get(job.job_type.value)
if celery_task_name:
result = celery_app.send_task(celery_task_name, args=[job.id], kwargs=job.payload)
job.celery_task_id = result.id
job_repo.update(job)
logger.info("已提交 Celery 任务: job_id=%s celery_task_id=%s", job.id, result.id)
return job_to_response(job)
# ── 查询接口 ──────────────────────────────────────────────────────────────────
@router.get("/jobs/{job_id}", response_model=JobResponse)
def get_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""获取任务详情。"""
use_case = GetJobUseCase(job_repo)
job = use_case.execute(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
return job_to_response(job)
@router.get("/projects/{project_id}/jobs", response_model=ListJobsResponse)
def list_project_jobs(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
job_type: str | None = Query(default=None, description="按任务类型过滤"),
status_filter: str | None = Query(default=None, alias="status", description="按状态过滤"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ListJobsResponse:
"""获取项目下的任务列表。"""
check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = ListJobsUseCase(job_repo)
jobs = use_case.execute(
project_id=project_id,
job_type=job_type,
status=status_filter,
limit=limit,
offset=offset,
)
items = [job_to_response(j) for j in jobs]
return ListJobsResponse(items=items, total=len(items))
@router.get("/projects/{project_id}/jobs/stats", response_model=JobStatisticsResponse)
def get_job_statistics(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobStatisticsResponse:
"""获取项目任务统计摘要。"""
check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = GetJobStatisticsUseCase(job_repo)
stats = use_case.execute(project_id)
return JobStatisticsResponse(**stats)
# ── 进度更新 ──────────────────────────────────────────────────────────────────
@router.put("/jobs/{job_id}/progress", response_model=JobResponse)
def update_job_progress(
job_id: str,
request: UpdateProgressRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""更新任务进度。"""
use_case = UpdateJobProgressUseCase(job_repo)
try:
job = use_case.execute(
UpdateJobProgressCommand(
job_id=job_id,
progress=request.progress,
current_stage=request.current_stage,
)
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 完成 / 失败 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/complete", response_model=JobResponse)
def complete_job(
job_id: str,
request: CompleteJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务完成。"""
use_case = CompleteJobUseCase(job_repo)
try:
job = use_case.execute(CompleteJobCommand(job_id=job_id, result=request.result))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/fail", response_model=JobResponse)
def fail_job(
job_id: str,
request: FailJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务失败。"""
use_case = FailJobUseCase(job_repo)
try:
job = use_case.execute(FailJobCommand(job_id=job_id, error_message=request.error_message))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 重试 / 取消 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/retry", response_model=JobResponse)
def retry_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""重试失败任务。
将任务重置为 pendingretry_count + 1,但不自动 dispatch。
需要再次调用 /submit 提交执行。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = RetryJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/cancel", response_model=JobResponse)
def cancel_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""取消任务。"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = CancelJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
+4 -4
View File
@@ -7,7 +7,7 @@ from app.schemas.project import (
ListProjectsResponse,
ProjectResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, status
from packages.application import (
CreateProjectCommand,
@@ -72,12 +72,12 @@ def create_project(
return _to_project_response(project)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{project_id}")
def delete_project(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> dict:
):
use_case = DeleteProjectUseCase(project_repository)
try:
deleted = use_case.execute(project_id, authenticated_user.user.id)
@@ -88,4 +88,4 @@ def delete_project(
)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return
return {"message": "Project deleted successfully"}
+207
View File
@@ -0,0 +1,207 @@
"""Recipe CRUD + use routes."""
from __future__ import annotations
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.recipe import (
CreateRecipeRequest,
ListRecipesResponse,
RecipeItemResponse,
RecipeResponse,
UpdateRecipeRequest,
UseRecipeResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.application.recipe.use_cases import (
CreateRecipeUseCase,
DeleteRecipeUseCase,
FeatureDisabledError,
GetRecipeUseCase,
ListRecipesUseCase,
NotFoundError,
UpdateRecipeUseCase,
UseRecipeUseCase,
)
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyRecipeRepository:
return SQLAlchemyRecipeRepository(session)
def _item_to_response(item) -> RecipeItemResponse:
return RecipeItemResponse(
id=item.id,
recipe_id=item.recipe_id,
item_type=item.item_type,
item_id=item.item_id,
position=item.position,
metadata=item.metadata_,
)
def _to_response(recipe) -> RecipeResponse:
return RecipeResponse(
id=recipe.id,
user_id=recipe.user_id,
name=recipe.name,
description=recipe.description,
template_id=recipe.template_id,
generation_params=recipe.generation_params,
items=[_item_to_response(i) for i in getattr(recipe, "items", [])],
is_active=recipe.is_active,
metadata=recipe.metadata_,
created_at=recipe.created_at,
updated_at=recipe.updated_at,
)
@router.get("", response_model=ListRecipesResponse)
def list_recipes(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> ListRecipesResponse:
user_id = authenticated_user.user.id
use_case = ListRecipesUseCase(recipe_repository)
recipes = use_case.execute(user_id, skip=skip, limit=limit)
total = recipe_repository.count_by_user(user_id)
return ListRecipesResponse(
items=[_to_response(r) for r in recipes],
total=total,
)
@router.get("/{recipe_id}", response_model=RecipeResponse)
def get_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
use_case = GetRecipeUseCase(recipe_repository)
recipe = use_case.execute(recipe_id, user_id)
if recipe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.post("", response_model=RecipeResponse, status_code=status.HTTP_201_CREATED)
def create_recipe(
request: CreateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = CreateRecipeCommand(
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
],
metadata_=request.metadata_,
)
use_case = CreateRecipeUseCase(recipe_repository)
recipe = use_case.execute(command)
return _to_response(recipe)
@router.patch("/{recipe_id}", response_model=RecipeResponse)
def update_recipe(
recipe_id: str,
request: UpdateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = UpdateRecipeCommand(
recipe_id=recipe_id,
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=(
[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
]
if request.items is not None
else None
),
metadata_=request.metadata_,
)
use_case = UpdateRecipeUseCase(recipe_repository)
try:
recipe = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteRecipeUseCase(recipe_repository)
deleted = use_case.execute(recipe_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return Response(status_code=204)
@router.post("/{recipe_id}/use", response_model=UseRecipeResponse)
def use_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> UseRecipeResponse:
user_id = authenticated_user.user.id
plan_name = get_user_plan(user_id, user_repository)
use_case = UseRecipeUseCase(recipe_repository)
try:
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
except FeatureDisabledError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return UseRecipeResponse(
recipe=_to_response(result.recipe),
warnings=[{"item_type": w.item_type, "item_id": w.item_id, "position": w.position} for w in result.warnings],
)
+6 -6
View File
@@ -92,7 +92,7 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
@router.get("/current", response_model=SubscriptionInfo)
async def get_current_subscription(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SubscriptionInfo:
):
"""获取当前订阅信息"""
return _build_subscription_info(current_user)
@@ -100,7 +100,7 @@ async def get_current_subscription(
@router.get("/billing-records", response_model=List[BillingRecord])
async def get_billing_records(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> List[BillingRecord]:
):
"""获取账单记录列表"""
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal
@@ -134,7 +134,7 @@ async def change_plan(
request: ChangePlanRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> ChangePlanResponse:
):
"""变更订阅套餐(升级/降级)"""
# TODO: 接入支付验证(支付宝/微信支付)
valid_plans = {"free", "standard", "pro", "enterprise"}
@@ -186,7 +186,7 @@ async def change_plan(
async def cancel_subscription(
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> SimpleResponse:
):
"""取消订阅"""
user = current_user.user
if user.subscription_plan == "free":
@@ -212,7 +212,7 @@ async def payment_callback(
amount: float,
payment_method: str = "alipay",
payment_id: str = "",
) -> dict:
):
"""支付回调 - 在事务中更新账单和订阅状态
注意:生产环境需要验证支付签名
@@ -263,7 +263,7 @@ async def payment_callback(
async def toggle_auto_renew(
request: ToggleAutoRenewRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SimpleResponse:
):
"""切换自动续费"""
# TODO: 实际需要在数据库中存储 auto_renew 字段
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
+2 -2
View File
@@ -10,7 +10,7 @@ from app.schemas.tag import (
ListTagsResponse,
TagResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException
from packages.domain import Tag
@@ -52,7 +52,7 @@ def create_tag(
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
@router.delete("/{tag_id}", status_code=204, response_class=Response)
@router.delete("/{tag_id}", status_code=204)
def delete_tag(
tag_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+79 -136
View File
@@ -21,12 +21,11 @@ from app.schemas.task_center import (
ProjectTaskResponse,
UserTaskResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
RetryGenerationTaskUseCase,
SubmitIngestJobCommand,
SubmitIngestJobUseCase,
)
@@ -35,10 +34,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 200
def _humanize_task_error(error_message: str) -> str:
raw = (error_message or "").strip()
if not raw:
@@ -68,8 +63,6 @@ def _generation_step(task) -> str:
return "生成完成"
if s == "failed":
return "生成失败"
if s == "cancelled":
return "已取消"
return s
@@ -86,26 +79,6 @@ def _ingest_step(job) -> str:
return s
def _generation_task_to_user_response(task) -> UserTaskResponse:
return UserTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
template_id=task.template_id,
status=_status_value(task.status),
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
error_info=task.error_info or {},
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
retry_count=task.retry_count or 0,
source_id=task.id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
def _generation_task_to_project_response(task) -> ProjectTaskResponse:
return ProjectTaskResponse(
id=f"generation:{task.id}",
@@ -115,10 +88,8 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
error_info=task.error_info or {},
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
retry_count=task.retry_count or 0,
source_id=task.id,
template_id=task.template_id,
created_at=task.created_at,
@@ -126,66 +97,40 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
)
def _validate_status(status: str | None) -> str | None:
"""校验状态值合法性。"""
if status is None:
return None
valid = {"pending", "running", "completed", "failed", "cancelled"}
if status not in valid:
raise HTTPException(
status_code=400,
detail=f"无效的状态筛选值: {status},允许值: {', '.join(sorted(valid))}",
)
return status
def _clamp_page_size(page_size: int) -> int:
if page_size <= 0:
return DEFAULT_PAGE_SIZE
if page_size > MAX_PAGE_SIZE:
return MAX_PAGE_SIZE
return page_size
# ── 用户级端点(放在项目级端点之前,避免路由冲突) ──
@router.get("/tasks", response_model=ListTasksResponse)
def list_user_tasks(
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListTasksResponse:
"""用户级任务列表(跨 project),支持状态/类型筛选和分页"""
status = _validate_status(status)
page_size = _clamp_page_size(page_size)
"""用户级任务列表(跨 project),合并 ingest + generation 任务"""
user_id = authenticated_user.user.id
offset = (page - 1) * page_size
items: list[UserTaskResponse] = []
# 生成任务
if task_type is None or task_type == "generation":
gen_result = generation_task_repository.list_by_user_filtered(
user_id,
status=status,
limit=page_size + 1, # 多取一条判断是否还有下一页(简单起见这里用offset)
offset=offset,
for task in generation_task_repository.list_by_user(user_id):
items.append(
UserTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
template_id=task.template_id,
status=_status_value(task.status),
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
source_id=task.id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
for task in gen_result:
items.append(_generation_task_to_user_response(task))
# 按时间倒序
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
# 总数(仅generation,ingest暂不计入总数以保持简单)
total = generation_task_repository.count_by_user_filtered(user_id, status=status)
return ListTasksResponse(items=items[:page_size], total=total)
return ListTasksResponse(items=items)
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
@@ -194,7 +139,7 @@ def retry_task_by_id(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> UserTaskResponse:
"""原地重试失败的生成任务(复用同一个task_idretry_count+1"""
"""简化重试:通过 task_id 直接重试失败的生成任务"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
@@ -204,7 +149,6 @@ def retry_task_by_id(
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
@@ -219,11 +163,20 @@ def retry_task_by_id(
detail="系统繁忙,请稍后再试",
)
# 原地重试
use_case = RetryGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(task_id)
# 重新入队
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
@@ -239,8 +192,18 @@ def retry_task_by_id(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
return _generation_task_to_user_response(retried)
return UserTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
project_id=retried.project_id,
template_id=retried.template_id,
status=_status_value(retried.status),
progress=retried.progress,
current_step=_generation_step(retried),
source_id=retried.id,
created_at=retried.created_at,
updated_at=retried.created_at,
)
# ── 项目级端点 ──
@@ -249,64 +212,37 @@ def retry_task_by_id(
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
def list_project_tasks(
project_id: str,
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListProjectTasksResponse:
"""项目级任务列表,支持状态/类型筛选和分页。"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
status = _validate_status(status)
page_size = _clamp_page_size(page_size)
offset = (page - 1) * page_size
items: list[ProjectTaskResponse] = []
# 导入任务
if task_type is None or task_type == "ingest":
for job in ingest_job_repository.list_by_project(project_id):
if status and _status_value(job.status) != status:
continue
items.append(
ProjectTaskResponse(
id=f"ingest:{job.id}",
task_type="ingest",
project_id=job.project_id,
status=_status_value(job.status),
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
current_step=_ingest_step(job),
error_message=job.error_message,
user_message=_humanize_task_error(job.error_message),
retryable=_status_value(job.status) == "failed",
source_id=job.id,
created_at=job.created_at,
updated_at=job.updated_at,
)
for job in ingest_job_repository.list_by_project(project_id):
items.append(
ProjectTaskResponse(
id=f"ingest:{job.id}",
task_type="ingest",
project_id=job.project_id,
status=_status_value(job.status),
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
current_step=_ingest_step(job),
error_message=job.error_message,
user_message=_humanize_task_error(job.error_message),
retryable=_status_value(job.status) == "failed",
source_id=job.id,
created_at=job.created_at,
updated_at=job.updated_at,
)
# 生成任务
if task_type is None or task_type == "generation":
gen_items = generation_task_repository.list_by_project_filtered(
project_id,
status=status,
limit=page_size + 1,
offset=offset,
)
for task in gen_items:
items.append(_generation_task_to_project_response(task))
for task in generation_task_repository.list_by_project(project_id):
items.append(_generation_task_to_project_response(task))
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
total = generation_task_repository.count_by_project_filtered(project_id, status=status)
return ListProjectTasksResponse(items=items[:page_size], total=total)
return ListProjectTasksResponse(items=items)
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
@@ -317,7 +253,6 @@ def retry_project_task(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ProjectTaskResponse:
"""项目级任务重试。"""
if task_type == "generation":
task = generation_task_repository.get(source_id)
if task is None:
@@ -326,7 +261,6 @@ def retry_project_task(
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
@@ -341,10 +275,20 @@ def retry_project_task(
detail="系统繁忙,请稍后再试",
)
# 原地重试
use_case = RetryGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(source_id)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
@@ -361,7 +305,6 @@ def retry_project_task(
detail="系统繁忙,请稍后再试",
) from None
return _generation_task_to_project_response(retried)
if task_type == "ingest":
job = ingest_job_repository.get(source_id)
if job is None:
+8 -96
View File
@@ -8,16 +8,13 @@ from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.template import (
CategoryResponse,
CopyTemplateRequest,
CreateCategoryRequest,
CreateTemplateRequest,
GenerateWarningResponse,
ListCategoriesResponse,
ListTagsResponse,
ListTemplatesResponse,
SegmentResponse,
TemplateResponse,
TemplateUsageResponse,
ToggleFavoriteResponse,
UpdateTemplateRequest,
ValidateTemplateRequest,
@@ -30,25 +27,19 @@ logger = logging.getLogger(__name__)
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
from packages.application.template.commands import (
CopyTemplateCommand,
CreateCategoryCommand,
CreateTemplateCommand,
ListTemplatesFilter,
SegmentCommand,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.application.template.use_cases import (
CopyTemplateUseCase,
CountTemplatesUseCase,
CreateCategoryUseCase,
CreateTemplateUseCase,
DeleteCategoryUseCase,
DeleteTemplateUseCase,
GetTemplateUsageUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTagsUseCase,
ListTemplatesUseCase,
NotFoundError,
UpdateTemplateUseCase,
@@ -76,7 +67,7 @@ def _segment_to_response(seg) -> SegmentResponse:
)
def _to_response(template, usage_count: int = 0) -> TemplateResponse:
def _to_response(template) -> TemplateResponse:
return TemplateResponse(
id=template.id,
user_id=template.user_id,
@@ -90,7 +81,6 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
estimated_duration=template.estimated_duration,
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
is_active=template.is_active,
usage_count=usage_count,
created_at=template.created_at,
updated_at=template.updated_at,
)
@@ -103,36 +93,19 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
def list_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
category: str | None = Query(None, description="按分类筛选"),
tag: str | None = Query(None, description="按标签筛选"),
keyword: str | None = Query(None, description="按名称关键词搜索"),
mode: str | None = Query(None, description="按剪辑模式筛选"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
user_id = authenticated_user.user.id
try:
tpl_filter = ListTemplatesFilter(
category=category,
tag=tag,
keyword=keyword,
mode=mode,
)
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
count_use_case = CountTemplatesUseCase(template_repository)
total = count_use_case.execute(user_id, filter=tpl_filter)
# 批量查询使用次数
items = []
for t in templates:
usage = template_repository.get_usage_count(t.id)
items.append(_to_response(t, usage_count=usage))
templates = use_case.execute(user_id, skip=skip, limit=limit)
total = template_repository.count_by_user(user_id)
except Exception:
logger.exception("list_templates 查询失败: user_id=%s", user_id)
return ListTemplatesResponse(items=[], total=0)
return ListTemplatesResponse(
items=items,
items=[_to_response(t) for t in templates],
total=total,
)
@@ -147,13 +120,12 @@ def get_template(
try:
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id)
except Exception:
logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage)
return _to_response(template)
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
@@ -234,7 +206,7 @@ def update_template(
return _to_response(template)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -245,48 +217,7 @@ def delete_template(
deleted = use_case.execute(template_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return
@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def copy_template(
template_id: str,
request: CopyTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
"""复制模板(含所有片段配置)"""
user_id = authenticated_user.user.id
command = CopyTemplateCommand(
template_id=template_id,
user_id=user_id,
new_name=request.new_name,
)
use_case = CopyTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template)
@router.get("/{template_id}/usage", response_model=TemplateUsageResponse)
def get_template_usage(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateUsageResponse:
"""获取模板使用次数(关联的剪辑计划数量)"""
user_id = authenticated_user.user.id
# 鉴权:确保模板存在且属于当前用户
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
usage = template_repository.get_usage_count(template_id)
return TemplateUsageResponse(template_id=template_id, usage_count=usage)
return Response(status_code=204)
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
@@ -376,7 +307,7 @@ def create_category(
)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_category(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -388,22 +319,3 @@ def delete_category(
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return Response(status_code=204)
# ── Tags ──
@router.get("/tags/list", response_model=ListTagsResponse)
def list_tags(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTagsResponse:
"""获取用户所有模板标签(去重排序)"""
user_id = authenticated_user.user.id
try:
use_case = ListTagsUseCase(template_repository)
tags = use_case.execute(user_id)
except Exception:
logger.exception("list_tags 查询失败: user_id=%s", user_id)
return ListTagsResponse(items=[])
return ListTagsResponse(items=tags)
+5 -44
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.title_library import (
@@ -17,23 +16,20 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
from packages.application.title_library.commands import (
CreateTitleLibraryCommand,
PickTitleCommand,
UpdateTitleLibraryCommand,
)
from packages.application.title_library.commands import CreateTitleLibraryCommand, UpdateTitleLibraryCommand
from packages.application.title_library.use_cases import (
CreateTitleLibraryUseCase,
DeleteTitleLibraryUseCase,
GetTitleLibraryUseCase,
ListTitleLibraryUseCase,
NotFoundError,
PickTitleUseCase,
QuotaExceededError,
UpdateTitleLibraryUseCase,
)
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -75,41 +71,6 @@ def list_titles(
)
@router.post("/pick", response_model=TitleLibraryItemResponse)
def pick_title(
category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"),
exclude_ids: Optional[str] = Query(
None,
description="排除的标题ID(逗号分隔),用于批量生成时避免重复",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
"""智能选择一个标题。
策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。
"""
user_id = authenticated_user.user.id
exclude_list: list[str] = []
if exclude_ids:
exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()]
use_case = PickTitleUseCase(title_repository)
item = use_case.execute(
PickTitleCommand(
user_id=user_id,
category=category,
exclude_ids=exclude_list,
)
)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="标题库为空,请先添加标题",
)
return _to_response(item)
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
def get_title(
title_id: str,
@@ -177,7 +138,7 @@ def update_title(
return _to_response(item)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_title(
title_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -188,4 +149,4 @@ def delete_title(
deleted = use_case.execute(title_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return
return Response(status_code=204)
+5 -34
View File
@@ -46,7 +46,6 @@ from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
from packages.domain.voice_presets import list_voices
from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
@@ -54,34 +53,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/presets", summary="获取预设音色列表")
def list_preset_voices(
gender: Optional[str] = Query(None, description="按性别筛选: male/female/child"),
style: Optional[str] = Query(None, description="按风格筛选: stable/lively/customer_service/narration/news/story"),
keyword: Optional[str] = Query(None, description="按关键词搜索"),
_user: AuthenticatedUser = Depends(get_current_user),
) -> list[dict]:
"""获取可用的预设音色列表。
用于配音功能的音色选择。
"""
voices = list_voices(gender=gender, style=style, keyword=keyword)
return [
{
"voice_id": v.voice_id,
"name": v.name,
"gender": v.gender.value,
"style": v.style.value,
"description": v.description,
"default_speed": v.default_speed,
"default_pitch": v.default_pitch,
"sample_rate": v.sample_rate,
"language": v.language,
}
for v in voices
]
def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository:
return SQLAlchemyTTSJobRepository(session)
@@ -270,7 +241,7 @@ def get_tts_job_status(
)
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_tts_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -282,7 +253,7 @@ def delete_tts_job(
deleted = use_case.execute(job_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return
return Response(status_code=204)
@router.post(
@@ -401,10 +372,10 @@ async def tts_websocket_stream(
streaming_service = TTSStreamingService(cosyvoice_service)
await streaming_service.synthesize_and_stream(websocket, params)
except WebSocketDisconnect:
logger.info("WebSocket 客户端主动断开连接")
logger.info("WebSocket 客户端断开连接")
except Exception as e:
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
try:
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
except Exception as send_err:
logger.warning("WebSocket 错误消息发送失败(连接可能已断开): %s", send_err)
except Exception:
pass
+2 -1
View File
@@ -2,7 +2,6 @@ import logging
from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.auth import AuthenticatedUser, get_current_user
from app.config import get_settings
from app.core.celery_app import celery_app
@@ -24,6 +23,8 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
logger = logging.getLogger(__name__)
router = APIRouter()
-179
View File
@@ -1,179 +0,0 @@
import logging
import uuid
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository
from app.schemas.video_center import (
BatchDownloadRequest,
BatchDownloadResponse,
ListVideosResponse,
UpdateVideoReviewRequest,
VideoItemResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
GetGeneratedVideoUseCase,
GetVideosByIdsUseCase,
ListGeneratedVideosPaginatedUseCase,
UpdateVideoReviewStatusUseCase,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoItemResponse:
download_url = None
if storage and item.file_url:
try:
download_url = storage.get_download_url(item.file_url)
except Exception:
download_url = item.file_url
return VideoItemResponse(
id=item.id,
project_id=item.project_id,
generation_task_id=item.generation_task_id,
name=item.name,
file_url=item.file_url,
file_size=item.file_size,
duration=item.duration,
thumbnail_url=item.thumbnail_url,
width=item.width,
height=item.height,
fps=item.fps,
status=item.status,
review_status=item.review_status,
generation_params=item.generation_params,
download_url=download_url,
generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "",
)
@router.get("/videos", response_model=ListVideosResponse)
def list_videos(
project_id: str | None = Query(None, description="项目ID,不传则返回所有项目"),
status: str | None = Query(None, description="按状态筛选"),
review_status: str | None = Query(None, description="按复核状态筛选"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
use_case = ListGeneratedVideosPaginatedUseCase(repo)
items, total = use_case.execute(
project_id=project_id,
status=status,
review_status=review_status,
page=page,
page_size=page_size,
)
return ListVideosResponse(
items=[_to_video_response(item, storage) for item in items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/videos/{video_id}", response_model=VideoItemResponse)
def get_video(
video_id: str,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""获取单个成片详情。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
return _to_video_response(item, storage)
@router.patch("/videos/{video_id}/review", response_model=VideoItemResponse)
def update_video_review_status(
video_id: str,
request: UpdateVideoReviewRequest,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""更新成片复核状态:pending_review / approved / rejected。"""
use_case = UpdateVideoReviewStatusUseCase(repo)
item = use_case.execute(video_id, request.review_status)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id)
return _to_video_response(item, storage)
@router.post("/videos/batch-download", response_model=BatchDownloadResponse)
def batch_download_videos(
request: BatchDownloadRequest,
repo=Depends(get_generated_video_repository),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""批量下载成片,异步打包 zip。
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
"""
if not request.video_ids:
raise HTTPException(status_code=400, detail="video_ids cannot be empty")
if len(request.video_ids) > 50:
raise HTTPException(status_code=400, detail="Maximum 50 videos per batch download")
# 校验视频都存在
use_case = GetVideosByIdsUseCase(repo)
videos = use_case.execute(request.video_ids)
if len(videos) != len(request.video_ids):
raise HTTPException(status_code=404, detail="Some videos not found")
# 发送 celery 任务
task = celery_app.send_task(
"worker.batch_download_videos",
args=[request.video_ids, current_user.user_id],
)
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
return BatchDownloadResponse(job_id=task.id, status="pending")
@router.get("/videos/batch-download/{job_id}", response_model=BatchDownloadResponse)
def get_batch_download_status(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""查询批量下载任务状态。"""
from celery.result import AsyncResult
task = AsyncResult(job_id, app=celery_app)
status_map = {
"PENDING": "pending",
"STARTED": "running",
"SUCCESS": "success",
"FAILURE": "failed",
"RETRY": "pending",
"REVOKED": "cancelled",
}
api_status = status_map.get(task.state, "pending")
download_url = None
if task.state == "SUCCESS" and task.result:
if isinstance(task.result, dict):
download_url = task.result.get("download_url")
elif isinstance(task.result, str):
download_url = task.result
return BatchDownloadResponse(
job_id=job_id,
status=api_status,
download_url=download_url,
)
+1 -2
View File
@@ -172,7 +172,6 @@ def get_voice_clone_status(
"/{clone_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_class=Response,
)
def delete_voice_clone(
clone_id: str,
@@ -185,7 +184,7 @@ def delete_voice_clone(
deleted = use_case.execute(clone_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return
return Response(status_code=204)
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
+4 -3
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.schemas.voice import (
@@ -40,6 +39,8 @@ from packages.application.voice_library.use_cases import (
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -322,7 +323,7 @@ def update_voice(
return _to_response(item, sign_url)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_voice(
voice_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -333,4 +334,4 @@ def delete_voice(
deleted = use_case.execute(voice_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return
return Response(status_code=204)
+9 -1
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
# Container bind address; external expose is controlled by Docker/Nginx.
API_HOST: str = "0.0.0.0" # nosec: B104
API_PORT: int = 8000
API_PREFIX: str = "/api/v1"
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
DATABASE_POOL_SIZE: int = 20
@@ -29,10 +30,16 @@ class Settings(BaseSettings):
AUTO_CREATE_SCHEMA: bool = False
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_MAX_CONNECTION: int = 50
ENABLE_REDIS_SESSIONS: bool = False
# JWT secret key - MUST be set via environment variable, no default allowed
JWT_SECRET_KEY: Optional[str] = None
# 旧的 JWT secret key(用于密钥轮换期间验证旧 token)
# 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期
JWT_SECRET_KEY_OLD: Optional[str] = None
# 密钥轮换天数(到达此天数后建议更换密钥)
SECRET_ROTATION_DAYS: int = 90
# JWT 算法与过期时间(与 .env.example 对齐)
JWT_ALGORITHM: str = "HS256"
@@ -73,7 +80,7 @@ class Settings(BaseSettings):
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
# OSS 七牛云相关
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.com"
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
@@ -104,6 +111,7 @@ class Settings(BaseSettings):
)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
LOG_LEVEL: str = "INFO"
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
+280 -11
View File
@@ -1,14 +1,283 @@
"""Backward-compatible re-export from shared storage.
"""阿里云 OSS 存储服务"""
All storage logic now lives in ``packages.shared.storage``.
This module keeps old import paths working so existing code
does not need to change.
"""
import base64
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
from urllib.parse import urlparse
from packages.shared.storage import SharedStorageService as OSSStorageService
from packages.shared.storage import (
get_shared_storage_service,
get_storage_service,
)
try:
import oss2
except ImportError: # pragma: no cover - exercised in minimal local/test environments
oss2 = None
from app.config import get_settings
__all__ = ["OSSStorageService", "get_storage_service", "get_shared_storage_service"]
logger = logging.getLogger(__name__)
class OSSStorageService:
"""阿里云 OSS 存储服务"""
def __init__(self):
settings = get_settings()
self.bucket_name = settings.OSS_BUCKET_NAME
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
if has_key_id and has_key_secret:
if oss2 is not None:
try:
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
# 否则 sign_url 默认生成 HTTP URL。
bucket_endpoint = settings.OSS_ENDPOINT
if not bucket_endpoint.startswith(("http://", "https://")):
bucket_endpoint = f"https://{bucket_endpoint}"
auth = oss2.Auth(
settings.OSS_ACCESS_KEY_ID,
settings.OSS_ACCESS_KEY_SECRET,
)
self.bucket = oss2.Bucket(
auth,
bucket_endpoint,
settings.OSS_BUCKET_NAME,
)
logger.info(
"OSS initialized: endpoint=%s bucket=%s",
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
except Exception as error:
logger.error("Failed to initialize OSS bucket client: %s", error)
else:
logger.error("oss2 SDK is not installed — OSS operations will fail")
else:
missing = []
if not has_key_id:
missing.append("OSS_ACCESS_KEY_ID")
if not has_key_secret:
missing.append("OSS_ACCESS_KEY_SECRET")
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
self.access_key_id = settings.OSS_ACCESS_KEY_ID
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
self.endpoint = settings.OSS_ENDPOINT
def diagnose(self) -> None:
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
key_id_display = (
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
)
logger.info(
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
self.endpoint,
self.bucket_name,
key_id_display,
)
if self.bucket is None:
logger.error(
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env"
)
else:
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
return path.startswith(f"{self.local_url_prefix}/")
def create_direct_upload_post(
self,
storage_key: str,
content_type: str,
max_size_bytes: int,
expires_seconds: int,
) -> dict[str, object]:
"""创建浏览器直传 OSS 的 PostObject 表单。"""
if not self.access_key_id or not self.access_key_secret:
raise RuntimeError("OSS storage is not configured")
normalized_key = self._normalize_storage_key(storage_key)
if not normalized_key.startswith("uploads/"):
raise ValueError("direct upload key must be under uploads/")
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
)
policy = {
"expiration": expiration,
"conditions": [
{"bucket": self.bucket_name},
{"key": normalized_key},
["content-length-range", 1, max_size_bytes],
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
],
}
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
signature = base64.b64encode(
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
return {
"url": self.public_url,
"method": "POST",
"storage_key": normalized_key,
"expires_at": expiration,
"fields": {
"key": normalized_key,
"OSSAccessKeyId": self.access_key_id,
"policy": encoded_policy,
"Signature": signature,
"success_action_status": "201",
"Content-Type": content_type,
},
}
def upload_file(
self,
file_or_path,
storage_key: str,
content_type: str = "application/octet-stream",
) -> str:
"""
上传文件到 OSS
Args:
file_or_path: 文件对象或本地文件路径
storage_key: 存储键(文件路径)
content_type: 内容类型
Returns:
文件公网 URL
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
# 如果是字符串路径,从本地文件上传
if isinstance(file_or_path, str):
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
else:
# 文件对象
file_or_path.seek(0)
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}"
except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str:
"""获取文件公网 URL"""
return f"{self.public_url}/{storage_key}"
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""
获取文件下载签名 URL(用于私有文件)
Args:
storage_key_or_url: 存储键或完整 URL
expires_seconds: 过期时间(秒)
Returns:
签名 URL
"""
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
logger.warning(
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
storage_key_or_url[:200],
)
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
logger.info(
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
storage_key[:80],
signed[:60],
)
return signed
except Exception:
logger.exception(
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
storage_key[:200],
)
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
"""从 URL 中提取存储键"""
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
parsed = urlparse(storage_key_or_url)
# 移除开头的 /
return parsed.path.lstrip("/")
return storage_key_or_url.lstrip("/")
def download_file(self, storage_key: str, local_path: str):
"""
从 OSS 下载文件到本地
Args:
storage_key: 存储键
local_path: 本地文件路径
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}")
def delete_file(self, storage_key: str):
"""
删除 OSS 文件
Args:
storage_key: 存储键
"""
if self.bucket is None:
return
try:
self.bucket.delete_object(storage_key)
except Exception as error:
logger.warning(
"Failed to delete file from OSS",
extra={"storage_key": storage_key, "error": str(error)},
)
def file_exists(self, storage_key: str) -> bool:
"""
检查文件是否存在
Args:
storage_key: 存储键
Returns:
是否存在
"""
if self.bucket is None:
return False
return self.bucket.object_exists(storage_key)
_storage_service = None
def get_storage_service() -> OSSStorageService:
"""获取存储服务实例(全局单例)"""
global _storage_service
if _storage_service is None:
_storage_service = OSSStorageService()
_storage_service.diagnose()
return _storage_service
+1 -9
View File
@@ -7,16 +7,12 @@ common auth dependencies for backward compatibility.
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser
from app.auth import get_current_user as get_authenticated_user
from app.dependencies import get_user_repository
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
logger = logging.getLogger(__name__)
from packages.domain.entities import User
from packages.ports.user_repository import UserRepository
@@ -37,10 +33,6 @@ async def get_current_user_optional(
return None
try:
authenticated_user = await get_authenticated_user(credentials, user_repository)
except HTTPException as exc:
if exc.status_code >= 500:
# 服务端错误不应被静默吞掉,记录日志
logger.error("可选认证遇到服务端错误,status=%s", exc.status_code, exc_info=True)
# 4xx 认证失败(如 token 无效、用户不存在)属于正常流程,返回 None
except HTTPException:
return None
return authenticated_user.user
Executable → Regular
+6 -34
View File
@@ -54,45 +54,17 @@ class AssetResponse(BaseModel):
tag_ids: list[str] = Field(default_factory=list)
MAX_BATCH_SIZE = 200
class BatchDeleteRequest(BaseModel):
"""批量删除请求(软删除)"""
"""批量删除请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="要删除的素材 ID 列表")
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
class BatchOperationResponse(BaseModel):
"""批量操作通用响应。"""
class BatchDeleteResponse(BaseModel):
"""批量删除响应。"""
success_count: int = Field(..., ge=0, description="成功数量")
failed_ids: list[str] = Field(default_factory=list, description="失败的 ID 列表")
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情 {asset_id: reason}")
class BatchTagRequest(BaseModel):
"""批量打标签请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
tag_ids: list[str] = Field(..., min_length=1, max_length=50, description="标签 ID 列表")
mode: str = Field(default="add", pattern="^(add|replace)$", description="add=添加合并,replace=全量替换")
class BatchClassifyRequest(BaseModel):
"""批量修改分类请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
category: str = Field(..., min_length=1, max_length=50, description="内容分类,如 person/scenic/product")
class BatchMarkRequest(BaseModel):
"""批量设置智能视图标记请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
smart_view: str = Field(
..., pattern="^(recommended|caution|high_risk)$", description="智能视图标记:recommended/caution/high_risk"
)
deleted_count: int = Field(..., ge=0, description="实际删除数量")
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
class ListAssetsResponse(BaseModel):
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from pydantic import BaseModel, Field
class RecentTaskItem(BaseModel):
id: str
task_type: str = "generation"
status: str
current_step: str = ""
error_message: str = ""
updated_at: datetime | None = None
class SubscriptionInfo(BaseModel):
"""用户订阅信息。"""
plan: str = "free"
is_active: bool = False
class DashboardOverviewResponse(BaseModel):
"""Dashboard 概览数据。"""
total_assets: int = 0
used_storage_bytes: int = 0
total_titles: int = 0
total_voices: int = 0
total_tasks: int = 0
total_products: int = 0
subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo)
recent_tasks: list[RecentTaskItem] = Field(default_factory=list)
-15
View File
@@ -33,17 +33,6 @@ class CreateGenerationTaskRequest(BaseModel):
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
)
# ── 自动重试 ──
auto_retry_enabled: bool = Field(
default=False,
description="是否开启失败自动重试,默认关闭",
)
auto_retry_max: int = Field(
default=0,
ge=0,
le=5,
description="最大自动重试次数,0表示不自动重试,最大5次",
)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -75,10 +64,6 @@ class GenerationTaskResponse(BaseModel):
progress: float
result_count: int
error_message: str
error_info: dict = Field(default_factory=dict)
retry_count: int = 0
auto_retry_enabled: bool = False
auto_retry_max: int = 0
logs: list[dict] = Field(default_factory=list)
@field_validator("logs", mode="before")
+109
View File
@@ -0,0 +1,109 @@
"""Job API schemas — Phase 8 任务 2.10."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class CreateJobRequest(BaseModel):
"""创建任务请求体。"""
project_id: str = Field(..., min_length=1, description="项目 ID")
job_type: str = Field(
...,
description="任务类型: video_compose / render_edit_plan / asset_ingest / classification / voice_extraction / generation",
)
payload: dict[str, Any] = Field(default_factory=dict, description="任务输入参数")
source_id: str = Field(default="", description="关联的业务实体 ID(如 edit_plan_id")
max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数")
class UpdateProgressRequest(BaseModel):
"""更新任务进度请求体。"""
progress: float = Field(..., ge=0.0, le=100.0, description="进度百分比")
current_stage: str = Field(default="", description="当前阶段描述")
class CompleteJobRequest(BaseModel):
"""完成任务请求体。"""
result: dict[str, Any] = Field(default_factory=dict, description="任务结果")
class FailJobRequest(BaseModel):
"""标记任务失败请求体。"""
error_message: str = Field(..., min_length=1, description="错误信息")
class JobResponse(BaseModel):
"""任务响应体。"""
id: str
project_id: str
job_type: str
status: str
progress: float
current_stage: str
payload: dict[str, Any]
result: dict[str, Any]
error_message: str
retry_count: int
max_retries: int
celery_task_id: str
source_id: str
created_by_user_id: str
is_retryable: bool
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ListJobsResponse(BaseModel):
"""任务列表响应体。"""
items: list[JobResponse]
total: int
class JobStatisticsResponse(BaseModel):
"""任务统计响应体。"""
project_id: str
total: int
pending: int
running: int
success: int
failed: int
def job_to_response(job) -> JobResponse:
"""将 Job 领域对象转换为 API 响应。"""
return JobResponse(
id=job.id,
project_id=job.project_id,
job_type=job.job_type.value if hasattr(job.job_type, "value") else str(job.job_type),
status=job.status.value if hasattr(job.status, "value") else str(job.status),
progress=job.progress,
current_stage=job.current_stage,
payload=job.payload,
result=job.result,
error_message=job.error_message,
retry_count=job.retry_count,
max_retries=job.max_retries,
celery_task_id=job.celery_task_id,
source_id=job.source_id,
created_by_user_id=job.created_by_user_id,
is_retryable=job.is_retryable,
started_at=job.started_at,
completed_at=job.completed_at,
created_at=job.created_at,
updated_at=job.updated_at,
)
+86
View File
@@ -0,0 +1,86 @@
"""Recipe API schemas."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Response ──
class RecipeItemResponse(BaseModel):
id: str
recipe_id: str
item_type: str
item_id: str
position: int
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class RecipeResponse(BaseModel):
id: str
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemResponse] = Field(default_factory=list)
is_active: bool = True
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
class ListRecipesResponse(BaseModel):
items: List[RecipeResponse]
total: int = 0
class UseRecipeResponse(BaseModel):
recipe: RecipeResponse
warnings: List[Dict[str, Any]] = Field(default_factory=list)
# ── Request ──
class RecipeItemRequest(BaseModel):
item_type: str
item_id: str
position: int = 0
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class CreateRecipeRequest(BaseModel):
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemRequest] = Field(default_factory=list)
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class UpdateRecipeRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
template_id: Optional[str] = None
generation_params: Optional[Dict[str, Any]] = None
items: Optional[List[RecipeItemRequest]] = None
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata")
class Config:
populate_by_name = True
-6
View File
@@ -11,10 +11,8 @@ class ProjectTaskResponse(BaseModel):
progress: float
current_step: str
error_message: str = ""
error_info: dict = Field(default_factory=dict)
user_message: str = ""
retryable: bool = False
retry_count: int = 0
source_id: str = ""
template_id: str = ""
created_at: datetime | None = None
@@ -23,7 +21,6 @@ class ProjectTaskResponse(BaseModel):
class ListProjectTasksResponse(BaseModel):
items: list[ProjectTaskResponse] = Field(default_factory=list)
total: int = 0
class UserTaskResponse(BaseModel):
@@ -37,10 +34,8 @@ class UserTaskResponse(BaseModel):
progress: float
current_step: str
error_message: str = ""
error_info: dict = Field(default_factory=dict)
user_message: str = ""
retryable: bool = False
retry_count: int = 0
source_id: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -50,4 +45,3 @@ class ListTasksResponse(BaseModel):
"""用户级任务列表响应(GET /api/v1/tasks)。"""
items: list[UserTaskResponse] = Field(default_factory=list)
total: int = 0
Executable → Regular
-23
View File
@@ -45,7 +45,6 @@ class TemplateResponse(BaseModel):
segments: List[SegmentResponse] = Field(default_factory=list)
is_active: bool = True
is_favorite: bool = False
usage_count: int = 0
created_at: datetime
updated_at: datetime
@@ -121,25 +120,3 @@ class CreateCategoryRequest(BaseModel):
class ListCategoriesResponse(BaseModel):
items: List[CategoryResponse]
# ── Copy Template ──
class CopyTemplateRequest(BaseModel):
new_name: str
# ── Tags ──
class ListTagsResponse(BaseModel):
items: List[str]
# ── Usage Stats ──
class TemplateUsageResponse(BaseModel):
template_id: str
usage_count: int
-45
View File
@@ -1,45 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
VideoReviewStatus = Literal["pending_review", "approved", "rejected"]
class VideoItemResponse(BaseModel):
id: str
project_id: str
generation_task_id: str
name: str
file_url: str
file_size: int
duration: float
thumbnail_url: str | None = None
width: int
height: int
fps: float
status: str = "completed"
review_status: str = "pending_review"
generation_params: dict = Field(default_factory=dict)
download_url: str | None = None
generated_at: str = ""
class ListVideosResponse(BaseModel):
items: list[VideoItemResponse]
total: int
page: int
page_size: int
class UpdateVideoReviewRequest(BaseModel):
review_status: VideoReviewStatus
class BatchDownloadRequest(BaseModel):
video_ids: list[str]
class BatchDownloadResponse(BaseModel):
job_id: str
status: str = "pending"
download_url: str | None = None
-19
View File
@@ -281,8 +281,6 @@ class EditPlanService:
start_time: float = 0.0,
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
playback_speed: float = 1.0,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""创建片段
@@ -303,8 +301,6 @@ class EditPlanService:
start_time=start_time,
duration=duration,
transition_effect=transition_effect,
transition_duration=transition_duration,
playback_speed=playback_speed,
config=config,
)
created = self._clip_repo.create(clip)
@@ -328,8 +324,6 @@ class EditPlanService:
start_time: Optional[float] = None,
duration: Optional[float] = None,
transition_effect: Optional[str] = None,
transition_duration: Optional[float] = None,
playback_speed: Optional[float] = None,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""更新片段
@@ -339,15 +333,6 @@ class EditPlanService:
"""
existing = self.get_clip_or_raise(clip_id)
# 速度边界钳制
if playback_speed is not None:
if playback_speed <= 0:
playback_speed = 1.0
elif playback_speed < 0.25:
playback_speed = 0.25
elif playback_speed > 4.0:
playback_speed = 4.0
updated = EditPlanClip(
id=existing.id,
plan_id=existing.plan_id,
@@ -361,10 +346,6 @@ class EditPlanService:
transition_effect=(
transition_effect.strip() if transition_effect is not None else existing.transition_effect
),
transition_duration=(
transition_duration if transition_duration is not None else existing.transition_duration
),
playback_speed=playback_speed if playback_speed is not None else existing.playback_speed,
status=existing.status,
config=config if config is not None else existing.config,
created_at=existing.created_at,
+1
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import logging
from typing import Any
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
-6
View File
@@ -26,9 +26,6 @@ export default defineConfig({
use: {
...devices["Desktop Chrome"],
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
launchOptions: {
args: ["--disable-gpu", "--disable-software-rasterizer"],
},
},
},
{
@@ -54,9 +51,6 @@ export default defineConfig({
use: {
...devices["Desktop Chrome"],
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
launchOptions: {
args: ["--disable-gpu", "--disable-software-rasterizer"],
},
},
},
],
+161
View File
@@ -0,0 +1,161 @@
/**
* 账号管理 Mock API
*
* 模拟多平台账号绑定/解绑操作
* 支持平台:抖音、快手、小红书、微信视频号
*/
/* ── 类型定义 ───────────────────────────────────────────── */
/** 平台 ID */
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
/** 账号状态 */
export type AccountStatus = "active" | "expired" | "limited";
/** 已绑定的账号 */
export interface Account {
id: string;
platform_id: PlatformId;
name: string;
avatar?: string;
status: AccountStatus;
bound_at: string;
}
/** 平台信息 */
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
}
/** 绑定账号请求 */
export interface BindAccountRequest {
platform_id: PlatformId;
name: string;
}
/* ── 平台配置 ───────────────────────────────────────────── */
export const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布平台",
icon: "📱",
gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布平台",
icon: "🎬",
gradient: "linear-gradient(135deg, #ff4906, #ffba00)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记发布平台",
icon: "📕",
gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布平台",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160, #4cd964)",
},
];
/* ── Mock 数据 ───────────────────────────────────────────── */
let MOCK_ACCOUNTS: Account[] = [
{
id: "acc-001",
platform_id: "douyin",
name: "小虾官方号",
avatar: "🦐",
status: "active",
bound_at: "2025-12-01T10:00:00Z",
},
{
id: "acc-002",
platform_id: "douyin",
name: "小虾日常",
avatar: "🐟",
status: "active",
bound_at: "2025-12-15T14:30:00Z",
},
{
id: "acc-003",
platform_id: "kuaishou",
name: "小虾剪辑",
avatar: "🎬",
status: "active",
bound_at: "2026-01-05T09:00:00Z",
},
{
id: "acc-004",
platform_id: "xiaohongshu",
name: "小虾种草",
avatar: "📕",
status: "limited",
bound_at: "2026-02-20T16:00:00Z",
},
];
/* ── 模拟延迟 ───────────────────────────────────────────── */
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ── API 函数 ───────────────────────────────────────────── */
/** 获取指定平台的账号列表 */
export async function getAccountsByPlatform(
platformId: PlatformId,
): Promise<Account[]> {
await delay(300);
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
}
/** 获取所有平台的账号总数 */
export async function getAllAccounts(): Promise<Account[]> {
await delay(200);
return [...MOCK_ACCOUNTS];
}
/** 绑定新账号 */
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
await delay(500);
const newAccount: Account = {
id: `acc-${Date.now()}`,
platform_id: data.platform_id,
name: data.name,
avatar: undefined,
status: "active",
bound_at: new Date().toISOString(),
};
MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount];
return newAccount;
}
/** 解绑账号 */
export async function unbindAccount(accountId: string): Promise<void> {
await delay(400);
MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId);
}
/* ── 状态配置 ───────────────────────────────────────────── */
export const ACCOUNT_STATUS_CONFIG: Record<
AccountStatus,
{ label: string; className: string }
> = {
active: { label: "正常", className: "acc-status--active" },
expired: { label: "已过期", className: "acc-status--expired" },
limited: { label: "受限", className: "acc-status--limited" },
};
@@ -0,0 +1,290 @@
/**
* CloneVoiceModal — 音色克隆弹窗
*
* 三步骤状态:input → uploading → success
* 支持上传音频文件或直接录制(mock,无真实录音)
*
* V21 Design System — 零 antd 直接导入
*/
import React, { useState, useCallback, useRef } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import { uploadAsset } from "@/api/assets";
import "./clone-voice-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalStep = "input" | "uploading" | "success";
export interface CloneVoiceModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 默认音色名称计数器 ─────────────────────────────────── */
let cloneCounter = 1;
const getNextDefaultName = (): string => {
const name = `我的声音 ${cloneCounter}`;
cloneCounter += 1;
return name;
};
/* ── 组件 ───────────────────────────────────────────────── */
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [step, setStep] = useState<ModalStep>("input");
const [voiceName, setVoiceName] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setStep("input");
setVoiceName("");
setSelectedFile(null);
setIsRecording(false);
setDragActive(false);
}, []);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 上传区域点击 */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
/** 文件选择 */
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
// 清除之前的录制状态
setIsRecording(false);
}
// 清空 input 以允许重复选择同一文件
e.target.value = "";
};
/** 拖拽事件 */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext === "mp3" || ext === "wav") {
setSelectedFile(file);
setIsRecording(false);
}
}
};
/** 录制按钮(mock */
const handleRecord = () => {
setIsRecording((prev) => !prev);
if (!isRecording) {
// 开始录制 — 清除已选文件
setSelectedFile(null);
}
};
/** 开始克隆 */
const handleStartClone = async () => {
const name = voiceName.trim() || getNextDefaultName();
setStep("uploading");
try {
// 先上传音频文件获取真实 URL
let audioUrl: string;
if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("kind", "voice");
const uploadResult = await uploadAsset(formData);
audioUrl = uploadResult.url;
} else {
// 录制功能暂未实现,提示用户上传
setStep("input");
return;
}
// 提交克隆请求
const result = await createVoiceClone({
name,
audio_url: audioUrl,
});
setStep("success");
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch {
setStep("input");
}
};
/** 弹窗打开时初始化默认名称 */
const handleAfterOpenChange = (visible: boolean) => {
if (visible) {
setVoiceName(getNextDefaultName());
}
};
const canStart = selectedFile || isRecording;
return (
<Modal
open={open}
onCancel={handleClose}
title="🎤 克隆新音色"
width={520}
footer={null}
destroyOnClose
afterOpenChange={handleAfterOpenChange}
>
{/* ── 输入步骤 ──────────────────────────────────── */}
{step === "input" && (
<div className="cvm-body">
{/* 音色名称 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<input
type="text"
className="cvm-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称"
/>
</div>
{/* 上传区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="cvm-upload-icon">🎵</div>
<p className="cvm-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
</p>
<p className="cvm-upload-hint"> MP3WAV </p>
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,audio/mpeg,audio/wav"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="cvm-divider">
<div className="cvm-divider-line" />
<span className="cvm-divider-text"></span>
<div className="cvm-divider-line" />
</div>
{/* 录制区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div className="cvm-record-area">
<p className="cvm-record-hint">
{isRecording
? "录制中…再次点击停止"
: "点击按钮开始录制你的声音"}
</p>
<button
type="button"
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
onClick={handleRecord}
>
🎙
</button>
</div>
</div>
{/* 提示 */}
<div className="cvm-tip">
<span className="cvm-tip-icon">💡</span>
<span>
10~3
</span>
</div>
{/* 底部按钮 */}
<div className="cvm-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button
buttonType="primary"
disabled={!canStart}
onClick={handleStartClone}
>
🎤
</Button>
</div>
</div>
)}
{/* ── 上传中步骤 ────────────────────────────────── */}
{step === "uploading" && (
<div className="cvm-uploading">
<div className="cvm-uploading-spinner" />
<p className="cvm-uploading-text"></p>
<p className="cvm-uploading-sub">AI </p>
</div>
)}
{/* ── 成功步骤 ──────────────────────────────────── */}
{step === "success" && (
<div className="cvm-success">
<div className="cvm-success-icon"></div>
<h3 className="cvm-success-title"></h3>
<p className="cvm-success-desc">
</p>
</div>
)}
</Modal>
);
};
export default CloneVoiceModal;
@@ -0,0 +1,325 @@
/**
* CloneVoiceModal — V21 Design System
*
* 音色克隆弹窗样式
* 三步骤状态:input → uploading → success
*/
/* ── 弹窗内容区 ─────────────────────────────────────────── */
.cvm-body {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── 表单区 ─────────────────────────────────────────────── */
.cvm-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.cvm-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #475467);
}
.cvm-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-sm);
background: var(--bg-surface, #fff);
color: var(--text-primary, #101828);
font-size: 14px;
line-height: 1.5;
transition:
border-color 0.2s,
box-shadow 0.2s;
outline: none;
}
.cvm-input:focus {
border-color: var(--primary, #6366f1);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.cvm-input::placeholder {
color: var(--muted, #98a2b3);
}
/* ── 上传区域 ───────────────────────────────────────────── */
.cvm-upload-zone {
border: 2px dashed var(--line, #e4e7ec);
border-radius: var(--radius-md);
padding: 28px 20px;
text-align: center;
background: var(--bg-subtle, #f8fafc);
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
}
.cvm-upload-zone:hover {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
}
.cvm-upload-zone.cvm-upload-zone--active {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
}
.cvm-upload-icon {
font-size: 36px;
margin-bottom: 8px;
line-height: 1;
}
.cvm-upload-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #101828);
margin: 0 0 4px;
}
.cvm-upload-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 或分隔线 ───────────────────────────────────────────── */
.cvm-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 4px 0;
}
.cvm-divider-line {
flex: 1;
height: 1px;
background: var(--line, #e4e7ec);
}
.cvm-divider-text {
font-size: 13px;
color: var(--muted, #98a2b3);
flex-shrink: 0;
}
/* ── 录制区域 ───────────────────────────────────────────── */
.cvm-record-area {
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-md);
padding: 24px;
text-align: center;
}
.cvm-record-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0 0 14px;
}
.cvm-record-btn {
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
cursor: pointer;
font-size: 32px;
line-height: 1;
padding: 0;
background: linear-gradient(
135deg,
var(--error-color, #ef4444),
var(--error-dark, #dc2626)
);
color: var(--text-inverse);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
transition:
transform 0.15s,
box-shadow 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cvm-record-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 20px
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
}
.cvm-record-btn:active {
transform: scale(0.96);
}
.cvm-record-btn--recording {
animation: cvm-pulse 1.2s ease-in-out infinite;
}
@keyframes cvm-pulse {
0%,
100% {
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
}
50% {
box-shadow: 0 4px 28px
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
}
}
/* ── 提示条 ─────────────────────────────────────────────── */
.cvm-tip {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
background: var(--warning-soft, #fef3c7);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--warning-color, #92400e);
line-height: 1.5;
}
.cvm-tip-icon {
flex-shrink: 0;
font-size: 14px;
line-height: 1.5;
}
/* ── 底部按钮 ───────────────────────────────────────────── */
.cvm-footer {
display: flex;
gap: 12px;
margin-top: 4px;
}
.cvm-footer .xx-btn {
flex: 1;
}
/* ── 上传中状态 ─────────────────────────────────────────── */
.cvm-uploading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 16px;
}
.cvm-uploading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line, #e4e7ec);
border-top-color: var(--primary, #6366f1);
border-radius: 50%;
animation: cvm-spin 0.8s linear infinite;
}
@keyframes cvm-spin {
to {
transform: rotate(360deg);
}
}
.cvm-uploading-text {
font-size: 15px;
font-weight: 500;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-uploading-sub {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 成功状态 ───────────────────────────────────────────── */
.cvm-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 12px;
}
.cvm-success-icon {
font-size: 56px;
line-height: 1;
}
.cvm-success-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-success-desc {
font-size: 14px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 响应式 ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.cvm-overlay {
padding: var(--space-md);
}
.cvm-modal {
width: 100%;
max-width: 100%;
padding: var(--space-lg);
}
}
@media (max-width: 576px) {
.cvm-upload-zone {
padding: 20px 14px;
}
.cvm-record-btn {
width: 64px;
height: 64px;
font-size: 26px;
}
.cvm-footer {
flex-direction: column;
}
}
@media (max-width: 480px) {
.cvm-record-btn {
width: 60px;
height: 60px;
}
.cvm-tip {
font-size: 12px;
padding: var(--space-sm);
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* V21 Form 表单
* 封装 Ant Design Form,应用 V21 设计系统样式
*/
import React from "react";
import { Form as AntForm } from "antd";
import type { FormProps as AntFormProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface FormProps extends AntFormProps {
/** 紧凑模式(减小表单项间距) */
compact?: boolean;
}
const Form = ({ className, compact, children, ...rest }: FormProps) => {
const v21Class = classNames(
"xx-form",
compact && "xx-form-compact",
className,
);
return (
<AntForm
className={v21Class}
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
>
{children as React.ReactNode}
</AntForm>
);
};
/** 导出 Form 的子组件(保持 antd API 一致) */
export const FormItem = AntForm.Item;
export const FormList = AntForm.List;
export const FormProvider = AntForm.Provider;
export default Form;
+25
View File
@@ -0,0 +1,25 @@
/**
* V21 Pagination 分页
* 封装 Ant Design Pagination,应用 V21 设计系统样式
*/
import React from "react";
import { Pagination as AntPagination } from "antd";
import type { PaginationProps as AntPaginationProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface PaginationProps extends AntPaginationProps {
/** 使用 V21 样式 */
v21?: boolean;
}
const Pagination: React.FC<PaginationProps> = ({
className,
v21 = true,
...rest
}) => {
const v21Class = classNames(v21 && "xx-pagination", className);
return <AntPagination className={v21Class} {...rest} />;
};
export default Pagination;
+29
View File
@@ -0,0 +1,29 @@
/**
* V21 Table 表格
* 封装 Ant Design Table,应用 V21 设计系统样式
*/
import React from "react";
import { Table as AntTable } from "antd";
import type { TableProps as AntTableProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface TableProps<
RecordType = unknown,
> extends AntTableProps<RecordType> {
/** 使用 V21 样式 */
v21?: boolean;
}
function Table<RecordType extends object = Record<string, unknown>>({
className,
v21 = true,
...rest
}: TableProps<RecordType>) {
const v21Class = classNames(v21 && "xx-table", className);
return <AntTable<RecordType> className={v21Class} {...rest} />;
}
export default Table as <RecordType extends object = Record<string, unknown>>(
props: TableProps<RecordType> & React.RefAttributes<HTMLDivElement>,
) => React.ReactElement;
+9
View File
@@ -15,6 +15,9 @@ export type { SelectProps } from "./Select";
export { default as Modal } from "./Modal";
export type { ModalProps } from "./Modal";
export { default as Table } from "./Table";
export type { TableProps } from "./Table";
export { default as Card } from "./Card";
export type { CardProps } from "./Card";
@@ -23,3 +26,9 @@ export type { TagProps, TagVariant } from "./Tag";
export { Tooltip, Popover } from "./Tooltip";
export type { TooltipProps, PopoverProps } from "./Tooltip";
export { default as Form, FormItem, FormList, FormProvider } from "./Form";
export type { FormProps } from "./Form";
export { default as Pagination } from "./Pagination";
export type { PaginationProps } from "./Pagination";
+110 -20
View File
@@ -258,6 +258,51 @@
font-size: 24px !important;
}
/* ============================================================
Table 表格
============================================================ */
.xx-table .ant-table {
background: var(--bg-primary) !important;
color: var(--text-primary) !important;
border-radius: var(--radius-md) !important;
overflow: hidden;
}
.xx-table .ant-table-thead > tr > th {
background: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
font-weight: var(--font-weight-semibold) !important;
border-bottom: 1px solid var(--border-color) !important;
font-size: var(--font-size-sm) !important;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
}
.xx-table .ant-table-tbody > tr > td {
border-bottom: 1px solid var(--border-light) !important;
color: var(--text-primary) !important;
transition: var(--transition-fast) !important;
}
.xx-table .ant-table-tbody > tr:hover > td {
background: var(--primary-soft) !important;
}
.xx-table .ant-table-tbody > tr:last-child > td {
border-bottom: none !important;
}
/* 排序图标 */
.xx-table .ant-table-column-sorter-up.active,
.xx-table .ant-table-column-sorter-down.active {
color: var(--primary-color) !important;
}
/* 分页 */
.xx-table .ant-pagination {
padding: var(--space-md) 0 !important;
}
/* ============================================================
Card 卡片
============================================================ */
@@ -384,6 +429,68 @@
border-bottom: 1px solid var(--border-light) !important;
}
/* ============================================================
Form 表单
============================================================ */
.xx-form .ant-form-item-label > label {
color: var(--text-primary) !important;
font-weight: var(--font-weight-medium) !important;
font-size: var(--font-size-base) !important;
}
.xx-form .ant-form-item-explain-error {
color: var(--error-color) !important;
font-size: var(--font-size-sm) !important;
}
.xx-form .ant-form-item {
margin-bottom: var(--space-lg) !important;
}
/* 表单项间距紧凑 */
.xx-form-compact .ant-form-item {
margin-bottom: var(--space-md) !important;
}
/* ============================================================
Pagination 分页
============================================================ */
.xx-pagination .ant-pagination-item {
border-radius: var(--radius-xs) !important;
border-color: var(--border-color) !important;
transition: var(--transition-fast) !important;
}
.xx-pagination .ant-pagination-item a {
color: var(--text-primary) !important;
}
.xx-pagination .ant-pagination-item:hover {
border-color: var(--primary-color) !important;
}
.xx-pagination .ant-pagination-item:hover a {
color: var(--primary-color) !important;
}
.xx-pagination .ant-pagination-item-active {
background: var(--gradient-primary) !important;
border-color: transparent !important;
}
.xx-pagination .ant-pagination-item-active a {
color: var(--text-inverse) !important;
}
.xx-pagination .ant-pagination-prev .ant-pagination-item-link,
.xx-pagination .ant-pagination-next .ant-pagination-item-link {
border-radius: var(--radius-xs) !important;
color: var(--text-secondary) !important;
}
.xx-pagination .ant-pagination-disabled .ant-pagination-item-link {
color: var(--text-disabled) !important;
}
/* ============================================================
响应式
@@ -410,6 +517,9 @@
padding: var(--space-md) !important;
}
.xx-form .ant-form-item {
margin-bottom: var(--space-md) !important;
}
}
@media (max-width: 480px) {
@@ -422,23 +532,3 @@
padding: 8px 16px !important;
}
}
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
.xx-card .ant-card-head {
border-bottom: 1px solid var(--border-color);
padding: 20px 24px;
}
.xx-card .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--text-primary);
}
.xx-card .ant-card-body {
padding: 24px;
}
+198 -71
View File
@@ -2,62 +2,194 @@
* 账号管理页面 — V21 Design System
*
* 展示多平台账号绑定状态(抖音/快手/小红书/微信视频号)
* 后端账号管理 API 尚未就绪,当前展示占位状态
* 支持绑定/解绑操作
*
* 零 antd 直接导入,全部使用 CSS 变量
*/
import React from "react";
import React, { useState, useCallback } from "react";
import { useQueries, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
PLATFORMS,
getAccountsByPlatform,
unbindAccount,
bindAccount,
ACCOUNT_STATUS_CONFIG,
type Platform,
type Account,
type PlatformId,
} from "@/api/accounts";
import "./accounts.css";
/* ── 类型定义 ───────────────────────────────────────────── */
/* ── Toast 系统 ─────────────────────────────────────────── */
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
interface Toast {
id: number;
message: string;
type: "success" | "error";
}
/** 支持的平台列表 */
const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布",
icon: "🎵",
gradient: "linear-gradient(135deg, #000 0%, #333 100%)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布",
icon: "📹",
gradient: "linear-gradient(135deg, #ff6600 0%, #ff9933 100%)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记 + 视频",
icon: "📕",
gradient: "linear-gradient(135deg, #fe2c55 0%, #ff6680 100%)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160 0%, #38d97a 100%)",
},
];
let toastIdCounter = 0;
/* ── 平台卡片组件 ───────────────────────────────────────── */
interface PlatformCardProps {
platform: Platform;
accounts: Account[];
isLoading: boolean;
onBind: (platformId: PlatformId) => void;
onUnbind: (accountId: string, accountName: string) => void;
}
const PlatformCard: React.FC<PlatformCardProps> = ({
platform,
accounts,
isLoading,
onBind,
onUnbind,
}) => {
return (
<div className="acc-card">
{/* 平台头部 */}
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
{/* 账号列表 */}
<div className="acc-account-list">
{isLoading ? (
<div className="acc-empty">
<p className="acc-empty-text"></p>
</div>
) : accounts.length > 0 ? (
accounts.map((account) => {
const statusCfg = ACCOUNT_STATUS_CONFIG[account.status];
return (
<div key={account.id} className="acc-account-row">
<div
className="acc-account-avatar"
style={{ background: platform.gradient }}
>
{account.avatar || platform.icon}
</div>
<div className="acc-account-info">
<div className="acc-account-name">{account.name}</div>
<span className={`acc-status-pill ${statusCfg.className}`}>
{statusCfg.label}
</span>
</div>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onUnbind(account.id, account.name)}
>
</Button>
</div>
);
})
) : (
<div className="acc-empty">
<div className="acc-empty-icon">🔓</div>
<p className="acc-empty-text"></p>
</div>
)}
</div>
{/* 绑定按钮 */}
<Button buttonType="ghost" onClick={() => onBind(platform.id)}>
+
</Button>
</div>
);
};
/* ── 主页面 ─────────────────────────────────────────────── */
const Accounts: React.FC = () => {
const queryClient = useQueryClient();
const [toasts, setToasts] = useState<Toast[]>([]);
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
/** 查询所有平台的账号 */
const _queriesResults = useQueries({
queries: PLATFORMS.map((platform) => ({
queryKey: ["accounts", platform.id] as const,
queryFn: () => getAccountsByPlatform(platform.id),
})),
});
const accountQueries = PLATFORMS.map((platform, i) => ({
platform,
..._queriesResults[i],
}));
/** 解绑 mutation */
const unbindMutation = useMutation({
mutationFn: unbindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("已解绑账号", "success");
},
onError: () => {
showToast("解绑失败", "error");
},
});
/** 绑定 mutationmock */
const bindMutation = useMutation({
mutationFn: bindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("账号绑定成功", "success");
},
onError: () => {
showToast("绑定失败", "error");
},
});
/** 绑定新账号(mock:直接创建) */
const handleBind = (platformId: PlatformId) => {
const platform = PLATFORMS.find((p) => p.id === platformId);
if (!platform) return;
const name = window.prompt(`请输入要绑定的${platform.name}账号名称:`);
if (name && name.trim()) {
bindMutation.mutate({ platform_id: platformId, name: name.trim() });
}
};
/** 解绑账号 */
const handleUnbind = (accountId: string, accountName: string) => {
if (window.confirm(`确定解绑账号「${accountName}」吗?`)) {
unbindMutation.mutate(accountId);
}
};
/** 统计已绑定账号数 */
const totalBound = accountQueries.reduce(
(sum, q) => sum + (q.data?.length ?? 0),
0,
);
const totalPlatforms = PLATFORMS.length;
return (
<div className="acc-page">
<PageHead
@@ -65,34 +197,17 @@ const Accounts: React.FC = () => {
description="绑定您的社交平台账号,用于视频一键发布到各平台"
/>
{/* 平台卡片网格 — 占位状态 */}
{/* 平台卡片网格 */}
<div className="acc-grid">
{PLATFORMS.map((platform) => (
<div key={platform.id} className="acc-card">
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
<div className="acc-account-list">
<div className="acc-empty">
<div className="acc-empty-icon">🔒</div>
<p className="acc-empty-text">线</p>
</div>
</div>
<Button buttonType="ghost" disabled>
</Button>
</div>
{accountQueries.map(({ platform, data, isLoading }) => (
<PlatformCard
key={platform.id}
platform={platform}
accounts={data ?? []}
isLoading={isLoading}
onBind={handleBind}
onUnbind={handleUnbind}
/>
))}
</div>
@@ -100,10 +215,22 @@ const Accounts: React.FC = () => {
<div className="acc-stats-bar">
<span className="acc-stats-icon">📊</span>
<span className="acc-stats-text">
<span className="acc-stats-highlight">{PLATFORMS.length}</span>{" "}
线
<span className="acc-stats-highlight">{totalBound}</span>{" "}
/ {" "}
<span className="acc-stats-highlight">{totalPlatforms}</span>
</span>
</div>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
</div>
);
};
+82
View File
@@ -82,6 +82,77 @@
gap: 10px;
}
/* ── 账号行 ─────────────────────────────────────────────── */
.acc-account-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
transition: var(--transition-fast);
}
.acc-account-row:hover {
background: var(--primary-soft);
}
.acc-account-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--text-inverse);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.acc-account-info {
flex: 1;
min-width: 0;
}
.acc-account-name {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 状态标签 ───────────────────────────────────────────── */
.acc-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
line-height: 1.6;
margin-top: 2px;
}
.acc-status--active {
background: var(--success-soft);
color: var(--secondary-color);
}
.acc-status--expired {
background: var(--error-soft);
color: var(--error-color);
}
.acc-status--limited {
background: var(--warning-soft);
color: var(--accent-color);
}
/* ── 空状态 ─────────────────────────────────────────────── */
.acc-empty {
@@ -164,6 +235,17 @@
font-size: var(--font-size-base);
}
.acc-account-row {
padding: 10px;
gap: 8px;
}
.acc-account-avatar {
width: 32px;
height: 32px;
font-size: var(--font-size-xs);
}
.acc-stats-bar {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
+460 -10
View File
@@ -1,20 +1,442 @@
/* Admin 页面样式Phase 3 精简)
*
* 原始 477 行 → 精简至仅保留实际使用的 class。
* 已迁移至 global.css / ui.css 的样式不再重复定义:
* .xx-page-head → global.css
* .xx-primary-btn → global.css
* .xx-tag / .xx-card → ui.css / global.css
*
* 以下 class 仅被 AdminComingSoon.tsx 使用。
*/
/* V21 Admin 页面样式 */
/* 页面容器 */
.dashboard-page,
.analytics-page,
.user-management-page,
.log-viewer-page,
.system-monitor-page,
.admin-coming-soon-page {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
/* 页面头部 */
.xx-page-head {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-page-head-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-page-head h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0;
letter-spacing: -0.02em;
}
.xx-page-head p {
font-size: 15px;
color: var(--muted, #64748b);
margin: 0;
}
.xx-page-head-actions {
display: flex;
gap: 12px;
align-items: center;
}
/* 简化页面头部(无操作按钮) */
.xx-page-head-simple {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
}
.xx-page-head-simple h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0 0 8px;
letter-spacing: -0.02em;
}
.xx-page-head-simple p {
font-size: 15px;
color: var(--muted, #64748b);
margin: 0;
}
/* 统计卡片网格 - 4列 */
.xx-grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 32px;
}
@media (max-width: 1200px) {
.xx-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-grid-4 {
grid-template-columns: 1fr;
}
}
/* 统计卡片 */
.xx-stat-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
transition: all 0.3s;
}
.xx-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.xx-stat-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.xx-stat-card-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.xx-stat-card-icon.primary {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.xx-stat-card-icon.success {
background: linear-gradient(135deg, #34d399, #10b981);
color: white;
}
.xx-stat-card-icon.warning {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
}
.xx-stat-card-icon.purple {
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
color: white;
}
.xx-stat-card-icon.info {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
color: white;
}
.xx-stat-card-icon.orange {
background: linear-gradient(135deg, #fb923c, #f97316);
color: white;
}
.xx-stat-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.xx-stat-card-value {
font-size: 32px;
font-weight: 900;
color: var(--slate, #0f172a);
line-height: 1.2;
letter-spacing: -0.02em;
}
.xx-stat-card-value.primary {
color: var(--indigo, #4f46e5);
}
.xx-stat-card-value.success {
color: var(--green, #10b981);
}
.xx-stat-card-value.warning {
color: var(--amber, #f59e0b);
}
.xx-stat-card-value.purple {
color: #8b5cf6;
}
.xx-stat-card-growth {
font-size: 13px;
color: var(--green, #10b981);
font-weight: 600;
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
/* 数据表格 */
.xx-table-wrapper {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.xx-table-wrapper .ant-table {
background: transparent;
}
.xx-table-wrapper .ant-table-thead > tr > th {
background: rgba(248, 250, 252, 0.8);
font-weight: 800;
font-size: 13px;
color: var(--slate, #0f172a);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 16px 20px;
}
.xx-table-wrapper .ant-table-tbody > tr > td {
padding: 16px 20px;
font-size: 14px;
}
.xx-table-wrapper .ant-table-tbody > tr:hover > td {
background: rgba(79, 70, 229, 0.03);
}
/* V21 按钮 */
.xx-primary-btn {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
border: none !important;
border-radius: var(--radius-md) !important;
font-weight: 700 !important;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25) !important;
transition: all 0.2s !important;
}
.xx-primary-btn:hover {
box-shadow: 0 12px 28px rgba(79, 70, 229, 0.3) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: white !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
color: var(--slate, #0f172a) !important;
border-radius: var(--radius-md) !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.xx-ghost-btn:hover {
border-color: var(--indigo, #4f46e5) !important;
color: var(--indigo, #4f46e5) !important;
}
/* V21 输入框 */
.xx-search-input {
border-radius: var(--radius-md) !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
padding: 8px 16px !important;
}
.xx-search-input:hover,
.xx-search-input:focus {
border-color: var(--indigo, #4f46e5) !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
}
/* V21 Tag */
.xx-tag {
border-radius: var(--radius-xs) !important;
font-weight: 600 !important;
font-size: 12px !important;
padding: 4px 10px !important;
}
.xx-tag.info {
background: rgba(59, 130, 246, 0.1) !important;
color: #3b82f6 !important;
border: 1px solid rgba(59, 130, 246, 0.2) !important;
}
.xx-tag.success {
background: rgba(16, 185, 129, 0.1) !important;
color: #10b981 !important;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
}
.xx-tag.warning {
background: rgba(245, 158, 11, 0.1) !important;
color: #f59e0b !important;
border: 1px solid rgba(245, 158, 11, 0.2) !important;
}
.xx-tag.error {
background: rgba(239, 68, 68, 0.1) !important;
color: #ef4444 !important;
border: 1px solid rgba(239, 68, 68, 0.2) !important;
}
.xx-tag.debug {
background: rgba(100, 116, 139, 0.1) !important;
color: #64748b !important;
border: 1px solid rgba(100, 116, 139, 0.2) !important;
}
/* V21 Progress */
.xx-progress-primary .ant-progress-circle .ant-progress-text {
color: var(--indigo, #4f46e5) !important;
font-weight: 700 !important;
}
.xx-progress-success .ant-progress-circle .ant-progress-text {
color: var(--green, #10b981) !important;
font-weight: 700 !important;
}
.xx-progress-warning .ant-progress-circle .ant-progress-text {
color: var(--amber, #f59e0b) !important;
font-weight: 700 !important;
}
/* 图表容器 */
.xx-chart-container {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
}
.xx-chart-container .ant-card-head {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-chart-container .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--slate, #0f172a);
}
/* 筛选器区域 */
.xx-filter-bar {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 20px 24px;
margin-bottom: 24px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
/* 资源监控卡片 */
.xx-resource-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
text-align: center;
}
.xx-resource-card-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
}
.xx-resource-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 600;
margin-bottom: 16px;
}
/* 日期选择器 */
.xx-date-picker {
border-radius: var(--radius-md) !important;
}
/* Drawer */
.xx-drawer .ant-drawer-header {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-drawer .ant-drawer-title {
font-weight: 800;
font-size: 18px;
color: var(--slate, #0f172a);
}
/* 日志详情 */
.xx-log-detail {
padding: 4px 0;
}
.xx-log-detail-label {
font-size: 13px;
font-weight: 700;
color: var(--slate, #0f172a);
margin-bottom: 4px;
}
.xx-log-detail-value {
font-size: 14px;
color: var(--muted, #64748b);
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.xx-log-detail-code {
font-family: "JetBrains Mono", "Fira Code", monospace;
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
word-break: break-all;
}
/* Result 页面居中 */
.xx-result-center {
display: flex;
justify-content: center;
@@ -25,3 +447,31 @@
.xx-result-center .ant-result {
padding: 48px;
}
/* 刷新时间显示 */
.xx-refresh-time {
font-size: 13px;
color: var(--muted, #64748b);
margin-right: 12px;
}
/* 图表配色覆盖 */
.recharts-text {
fill: #64748b !important;
font-size: 12px !important;
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: rgba(226, 232, 240, 0.8) !important;
}
/* 图表 Legend */
.recharts-legend-wrapper {
padding-top: 16px !important;
}
.recharts-legend-item-text {
color: #64748b !important;
font-size: 13px !important;
}
+423 -69
View File
@@ -1,97 +1,451 @@
/**
* 控制台页面 — V21 设计系统
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* CSS 变量,V21 组件
* 使用 mock 数据,CSS 变量,V21 组件
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui";
import { DatabaseOutlined } from "@ant-design/icons";
import { Button, Tag } from "@/components/ui";
import {
VideoCameraOutlined,
AppstoreOutlined,
ThunderboltOutlined,
DatabaseOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import "./dashboard.css";
/* ── 主组件 ─────────────────────────────────────────────── */
/* ============================================================
* Mock 数据
* ============================================================ */
interface KpiItem {
key: string;
icon: string;
iconGradient: string;
value: string;
label: string;
trend: string;
trendDirection: "up" | "down" | "neutral";
accent: string;
}
const kpiData: KpiItem[] = [
{
key: "projects",
icon: "video",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
value: "12",
label: "项目总数",
trend: "↑ 2 本月新增",
trendDirection: "up",
accent: "#6366f1",
},
{
key: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
value: "486",
label: "素材总数",
trend: "↑ 38 本月上传",
trendDirection: "up",
accent: "#0ea5e9",
},
{
key: "generations",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
value: "156",
label: "本月生成数",
trend: "↑ 23% 较上月",
trendDirection: "up",
accent: "#10b981",
},
{
key: "storage",
icon: "database",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
value: "2.4GB",
label: "存储空间",
trend: "已用 24%",
trendDirection: "neutral",
accent: "#f59e0b",
},
];
interface QuickEntry {
id: string;
icon: string;
iconGradient: string;
title: string;
description: string;
path: string;
}
const quickEntries: QuickEntry[] = [
{
id: "titles",
icon: "filetext",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
title: "标题库",
description: "24条标题 · 5个分类",
path: "/app/titles",
},
{
id: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
title: "素材库",
description: "486个素材 · 3个素材库",
path: "/app/assets",
},
{
id: "generate",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
title: "一键生成",
description: "开始创作新视频",
path: "/app/generate",
},
{
id: "products",
icon: "video",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
title: "成片库",
description: "89个成片 · 3个待复核",
path: "/app/products",
},
];
type TaskStatus = "completed" | "processing" | "pending" | "failed";
interface RecentTask {
id: string;
name: string;
type: string;
template: string;
status: TaskStatus;
date: string;
duration?: string;
}
const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
};
const recentTasks: RecentTask[] = [
{
id: "t-1",
name: "产品介绍视频_春季促销",
type: "视频生成",
template: "商品展示模板",
status: "completed",
date: "2026-07-01 09:30",
duration: "2分18秒",
},
{
id: "t-2",
name: "品牌宣传片_终版",
type: "视频生成",
template: "品牌宣传模板",
status: "processing",
date: "2026-07-01 10:15",
},
{
id: "t-3",
name: "用户评价合集",
type: "视频生成",
template: "评价展示模板",
status: "completed",
date: "2026-06-30 16:42",
duration: "1分45秒",
},
{
id: "t-4",
name: "新品发布预告",
type: "视频生成",
template: "新品预告模板",
status: "pending",
date: "2026-06-30 14:20",
},
{
id: "t-5",
name: "活动回顾_618大促",
type: "视频生成",
template: "活动回顾模板",
status: "failed",
date: "2026-06-29 11:05",
},
];
interface ChartItem {
label: string;
value: number;
}
const weeklyData: ChartItem[] = [
{ label: "周一", value: 18 },
{ label: "周二", value: 25 },
{ label: "周三", value: 32 },
{ label: "周四", value: 28 },
{ label: "周五", value: 42 },
{ label: "周六", value: 15 },
{ label: "周日", value: 8 },
];
interface Announcement {
id: string;
tag: "update" | "notice" | "activity";
tagLabel: string;
title: string;
date: string;
}
const announcements: Announcement[] = [
{
id: "a-1",
tag: "update",
tagLabel: "更新",
title: "系统已升级至 v2.0,新增批量生成功能",
date: "2026-07-01",
},
{
id: "a-2",
tag: "activity",
tagLabel: "活动",
title: "7月创作挑战赛已开启,参与赢积分奖励",
date: "2026-06-28",
},
{
id: "a-3",
tag: "notice",
tagLabel: "公告",
title: "7月3日凌晨 2:00-4:00 系统维护通知",
date: "2026-06-25",
},
];
/* ============================================================
* 工具函数
* ============================================================ */
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return "夜深了";
if (hour < 12) return "早上好";
if (hour < 14) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
};
const formatDate = () => {
const d = new Date();
const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${weekDays[d.getDay()]}`;
};
/** 图标名称 → Ant Design 组件映射 */
const iconMap: Record<string, React.ReactNode> = {
video: <VideoCameraOutlined />,
appstore: <AppstoreOutlined />,
thunderbolt: <ThunderboltOutlined />,
database: <DatabaseOutlined />,
filetext: <FileTextOutlined />,
};
/* ============================================================
* 组件
* ============================================================ */
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const maxChart = Math.max(...weeklyData.map((d) => d.value));
return (
<div className="xx-dashboard-page">
{/* KPI 卡片网格 */}
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
<div className="xx-dashboard-welcome">
<h2>{getGreeting()}</h2>
<p>{formatDate()} </p>
</div>
{/* ── KPI 卡片网格 ─────────────────────────────────────── */}
<div className="xx-kpi-grid">
<div className="xx-dashboard-empty">
<p></p>
{kpiData.map((item) => (
<div
key={item.key}
className="xx-kpi-card"
style={{ "--kpi-accent": item.accent } as React.CSSProperties}
>
<div
className="xx-kpi-icon"
style={{ background: item.iconGradient }}
>
{iconMap[item.icon] ?? item.icon}
</div>
<div className="xx-kpi-value">{item.value}</div>
<div className="xx-kpi-label">{item.label}</div>
<span
className={`xx-kpi-trend xx-kpi-trend--${item.trendDirection}`}
>
{item.trend}
</span>
</div>
))}
</div>
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
<div className="xx-dashboard-main">
{/* 左列 */}
<div className="xx-dashboard-left-col">
{/* 最近任务 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<button onClick={() => navigate("/app/history")}></button>
</div>
<div className="xx-task-list">
{recentTasks.map((task) => (
<div key={task.id} className="xx-task-item">
<div className="xx-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
<Tag
variant={
task.status === "completed"
? "success"
: task.status === "processing"
? "info"
: task.status === "failed"
? "error"
: "warning"
}
>
{statusLabel[task.status]}
</Tag>
<div className="xx-task-time">
<span>{task.date}</span>
{task.status === "completed"
? `耗时 ${task.duration}`
: task.status === "processing"
? "生成中..."
: task.status === "failed"
? "请重试"
: "等待中"}
</div>
<div className="xx-task-action">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/app/history")}
>
</Button>
</div>
</div>
))}
</div>
</div>
{/* 使用统计图表 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<span className="xx-chart-total">
{weeklyData.reduce((s, d) => s + d.value, 0)}
</span>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-bar-wrapper">
<div
className="xx-chart-bar"
style={{
height: `${(d.value / maxChart) * 100}%`,
}}
>
<span className="xx-chart-bar-value">{d.value}</span>
</div>
</div>
))}
</div>
<div className="xx-chart-labels">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-label">
{d.label}
</div>
))}
</div>
</div>
</div>
</div>
{/* 右列 — 公告 + 存储用量 */}
<div className="xx-dashboard-section xx-dashboard-section--start">
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
{announcements.map((a) => (
<div key={a.id} className="xx-announcement-item">
<span
className={`xx-announcement-tag xx-announcement-tag--${a.tag}`}
>
{a.tagLabel}
</span>
<div className="xx-announcement-content">
<h4>{a.title}</h4>
<time>{a.date}</time>
</div>
</div>
))}
</div>
{/* 存储用量 */}
<div className="xx-storage-section">
<div className="xx-storage-section-title"></div>
<div className="xx-storage-bar">
<div className="xx-storage-bar-track">
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
</div>
<div className="xx-storage-bar-label">
<span>2.4 GB </span>
<span>10 GB </span>
</div>
</div>
</div>
</div>
</div>
{/* 快速入口 */}
{/* ── 快速入口 ─────────────────────────────────────────── */}
<div className="xx-quick-entry-section">
<div className="xx-quick-entry-header">
<h3 className="xx-quick-entry-title"></h3>
</div>
<div className="xx-quick-grid">
<div className="xx-dashboard-empty">
<p></p>
</div>
{quickEntries.map((entry) => (
<div
key={entry.id}
className="xx-quick-card"
onClick={() => navigate(entry.path)}
>
<div
className="xx-quick-card-icon"
style={{ background: entry.iconGradient }}
>
{iconMap[entry.icon] ?? entry.icon}
</div>
<h3>{entry.title}</h3>
<p>{entry.description}</p>
</div>
))}
</div>
</div>
{/* 最近任务 */}
<section className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/app/history")}
>
</Button>
</div>
<div className="xx-task-list">
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
</section>
{/* 使用统计 */}
<section
className="xx-dashboard-section"
style={{ marginTop: "var(--space-md)" }}
>
<div className="xx-dashboard-section-header">
<h3>使</h3>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
<div className="xx-dashboard-empty" style={{ width: "100%" }}>
<DatabaseOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<p></p>
</div>
</div>
</div>
</section>
{/* 公告 */}
<section
className="xx-dashboard-section"
style={{ marginTop: "var(--space-md)" }}
>
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
<div className="xx-announcement-item">
<span className="xx-announcement-tag xx-announcement-tag--notice">
</span>
<div className="xx-announcement-content">
<h4>使 SaaS </h4>
<time></time>
</div>
</div>
</div>
</section>
</div>
);
};
+364 -34
View File
@@ -1,6 +1,6 @@
/**
* 控制台页面 - V21 设计系统样式
* KPI 卡片网格 + 快速入口 + 最近任务 + 使用统计图表 + 公告
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
@@ -13,6 +13,26 @@
padding: var(--space-xl);
}
/* ============================================================
欢迎头部
============================================================ */
.xx-dashboard-welcome {
margin-bottom: var(--space-lg);
}
.xx-dashboard-welcome h2 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-dashboard-welcome p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
KPI 卡片网格
============================================================ */
@@ -23,6 +43,76 @@
margin-bottom: var(--space-lg);
}
.xx-kpi-card {
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
transition: 0.18s ease;
position: relative;
overflow: hidden;
}
.xx-kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.xx-kpi-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-lg);
margin-bottom: var(--space-sm);
}
.xx-kpi-value {
font-size: 30px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
margin-bottom: var(--space-xs);
}
.xx-kpi-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--space-sm);
}
.xx-kpi-trend {
font-size: 12px;
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-kpi-trend--up {
color: var(--success-color);
}
.xx-kpi-trend--down {
color: var(--error-color);
}
.xx-kpi-trend--neutral {
color: var(--text-tertiary);
}
/* ============================================================
主内容区两栏布局
============================================================ */
.xx-dashboard-main {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
/* ============================================================
区块卡片
============================================================ */
@@ -71,25 +161,7 @@
}
/* ============================================================
空状态
============================================================ */
.xx-dashboard-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl) var(--space-md);
text-align: center;
color: var(--text-tertiary);
}
.xx-dashboard-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ============================================================
最近任务列表
最近任务卡片列表
============================================================ */
.xx-task-list {
display: flex;
@@ -97,6 +169,86 @@
gap: var(--space-sm);
}
.xx-task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: var(--space-md);
align-items: center;
transition: var(--transition-all);
}
.xx-task-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.xx-task-info h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.xx-task-info span {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-task-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 3px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
white-space: nowrap;
}
.xx-task-status--completed {
color: var(--success-color);
background: var(--success-soft);
border: 1px solid var(--success-border);
}
.xx-task-status--processing {
color: var(--info-color);
background: var(--primary-soft);
border: 1px solid var(--color-primary-200);
}
.xx-task-status--pending {
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.xx-task-status--failed {
color: var(--error-color);
background: var(--error-soft);
border: 1px solid var(--error-border);
}
.xx-task-time {
text-align: right;
font-size: var(--font-size-xs);
color: var(--text-secondary);
min-width: 100px;
}
.xx-task-time span {
display: block;
margin-bottom: var(--space-xxs);
}
.xx-task-action {
font-size: var(--font-size-xs);
}
/* ============================================================
使用统计图表(纯 CSS 柱状图)
============================================================ */
@@ -112,27 +264,69 @@
padding-top: var(--space-sm);
}
/* ============================================================
快速入口
============================================================ */
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
.xx-chart-bar-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
height: 100%;
justify-content: flex-end;
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
.xx-chart-bar {
width: 100%;
max-width: 36px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--gradient-primary);
transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
min-height: 4px;
cursor: pointer;
}
.xx-chart-bar:hover {
opacity: 0.85;
}
.xx-chart-bar:active {
opacity: 0.7;
transform: scaleY(0.97);
transform-origin: bottom;
}
.xx-chart-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
opacity: 0;
transition: var(--transition-opacity);
}
.xx-chart-bar:hover .xx-chart-bar-value {
opacity: 1;
}
.xx-chart-labels {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.xx-chart-label {
flex: 1;
text-align: center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* ============================================================
快速入口卡片网格
============================================================ */
.xx-quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -140,6 +334,53 @@
margin-bottom: var(--space-lg);
}
.xx-quick-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
cursor: pointer;
transition: var(--transition-all);
text-align: center;
}
.xx-quick-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.xx-quick-card:active {
transform: translateY(0) scale(0.98);
box-shadow: var(--shadow-sm);
transition-duration: 0.1s;
}
.xx-quick-card-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
margin: 0 auto var(--space-md);
color: var(--text-inverse);
}
.xx-quick-card h3 {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-quick-card p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* ============================================================
公告区域
============================================================ */
@@ -210,10 +451,90 @@
color: var(--text-secondary);
}
/* ============================================================
存储用量条
============================================================ */
.xx-storage-bar {
margin-top: var(--space-sm);
}
.xx-storage-bar-track {
height: var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--space-xs);
overflow: hidden;
}
.xx-storage-bar-fill {
height: 100%;
border-radius: var(--space-xs);
background: var(--gradient-primary);
transition: width 0.6s ease;
}
.xx-storage-bar-label {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--space-xs);
}
/* ============================================================
迁移自内联样式的工具类
============================================================ */
.xx-dashboard-left-col {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.xx-chart-total {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-dashboard-section--start {
align-self: start;
}
.xx-storage-section {
margin-top: var(--space-md);
}
.xx-storage-section-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-dashboard-main {
grid-template-columns: 1fr;
}
.xx-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
@@ -236,6 +557,15 @@
grid-template-columns: 1fr;
}
.xx-task-item {
grid-template-columns: 1fr;
gap: var(--space-sm);
}
.xx-task-time {
text-align: left;
}
.xx-chart-bars {
height: 120px;
}
+24 -12
View File
@@ -69,6 +69,22 @@ const VOICE_GENDER_ICON: Record<string, string> = {
neutral: "✨",
};
/* ── 时间线 Mock ──
* TODO: 后端暂无时间线场景数据 API,当前使用硬编码预览数据。
* 待后端提供 timeline/scene 接口后替换为真实 API 调用。
*/
interface TimelineScene {
scene: string;
time: string;
duration: number;
}
const MOCK_TIMELINE: TimelineScene[] = [
{ scene: "主讲口播 · 开场钩子", time: "0-8s", duration: 8 },
{ scene: "产品特写 · B-roll", time: "8-20s", duration: 12 },
{ scene: "用户反馈 · 结尾", time: "20-30s", duration: 10 },
];
/* ── 步骤定义 ── */
const STEPS = [
{ key: 1, label: "选择模板" },
@@ -1742,19 +1758,15 @@ const GeneratePage: React.FC = () => {
<div className="xx-preview-title"></div>
{/* 时间线列表 */}
{generated ? (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
<div className="xx-preview-timeline">
{MOCK_TIMELINE.map((item, idx) => (
<div key={idx} className="xx-timeline-item">
<div className="num">{idx + 1}</div>
<span className="scene-name">{item.scene}</span>
<span>{item.time}</span>
</div>
</div>
) : (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
</div>
</div>
)}
))}
</div>
{/* 生成操作按钮 */}
<div className="xx-generate-actions">
+120 -25
View File
@@ -36,24 +36,40 @@ type TitleType = "hot" | "normal" | "creative";
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel";
type Frequency = "all" | "high" | "medium" | "low";
interface CategoryItem {
id: string;
name: string;
count: number;
}
interface TitleData {
id: string;
content: string;
type: TitleType;
industry: Industry;
category: string;
usageCount: number;
isFavorited: boolean;
createdAt: string;
}
/* ============================================================
* Mock
* ============================================================ */
const MOCK_CATEGORIES: CategoryItem[] = [
{ id: "cat-all", name: "全部标题", count: 15 },
{ id: "cat-1", name: "美食探店", count: 4 },
{ id: "cat-2", name: "科技数码", count: 3 },
{ id: "cat-3", name: "生活日常", count: 4 },
{ id: "cat-4", name: "美妆穿搭", count: 2 },
{ id: "cat-5", name: "教育学习", count: 2 },
];
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
@@ -234,8 +250,9 @@ const TitleCard: React.FC<{
const TitleLibrary: React.FC = () => {
const queryClient = useQueryClient();
/* 分类数据 — 从真实标题数据动态派生 */
const [activeCatId, setActiveCatId] = useState<string>("cat-all");
/* 分类数据 */
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
@@ -248,23 +265,6 @@ const TitleLibrary: React.FC = () => {
[apiTitles],
);
/* 从真实标题数据动态派生分类(无需后端分类 API) */
const categories = useMemo(() => {
const cats = new Map<string, number>();
apiTitles.forEach((t) => {
const cat = t.category || "未分类";
cats.set(cat, (cats.get(cat) || 0) + 1);
});
return [
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
];
}, [apiTitles]);
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
@@ -301,6 +301,10 @@ const TitleLibrary: React.FC = () => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editText, setEditText] = useState("");
/* 新建分类 */
const [createCatModalOpen, setCreateCatModalOpen] = useState(false);
const [newCatName, setNewCatName] = useState("");
/* 新建标题 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false);
const [newTitleContent, setNewTitleContent] = useState("");
@@ -318,11 +322,19 @@ const TitleLibrary: React.FC = () => {
const filteredTitles = useMemo(() => {
let list = titles;
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
/* 按分类过滤("全部标题" 不过滤) */
if (activeCatId !== "cat-all") {
const catName = activeCategory?.name || "";
if (catName) {
list = list.filter((t) => t.category === catName);
const catToIndustry: Record<string, Industry> = {
: "food",
: "tech",
: "general",
穿: "beauty",
: "education",
};
const mappedIndustry = catToIndustry[catName];
if (mappedIndustry) {
list = list.filter((t) => t.industry === mappedIndustry);
}
}
@@ -416,6 +428,33 @@ const TitleLibrary: React.FC = () => {
[deleteMutation],
);
/* 新建分类 */
const handleCreateCategory = () => {
if (!newCatName.trim()) {
message.warning("请输入分类名称");
return;
}
const cat: CategoryItem = {
id: `cat-${Date.now()}`,
name: newCatName.trim(),
count: 0,
};
setCategories((prev) => [...prev, cat]);
setActiveCatId(cat.id);
setCreateCatModalOpen(false);
setNewCatName("");
message.success(`分类 "${cat.name}" 创建成功`);
};
/* 删除分类 */
const handleDeleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
if (activeCatId === id) {
setActiveCatId("cat-all");
}
message.success("分类已删除");
};
/* 新建标题 */
const handleCreateTitle = () => {
if (!newTitleContent.trim()) {
@@ -502,11 +541,38 @@ const TitleLibrary: React.FC = () => {
</h4>
<span>{cat.count} </span>
</div>
{cat.id !== "cat-all" && (
<Popconfirm
title={`确定删除分类 "${cat.name}"`}
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteCategory(cat.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-title-category-delete"
onClick={(e) => e.stopPropagation()}
title="删除分类"
>
<DeleteOutlined />
</button>
</Popconfirm>
)}
</div>
</div>
))}
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
{/* 新建分类 */}
<div
className="xx-title-category-add"
onClick={() => setCreateCatModalOpen(true)}
>
<PlusOutlined />
</div>
</div>
{/* ─── 右侧:内容区 ─── */}
@@ -612,6 +678,35 @@ const TitleLibrary: React.FC = () => {
</div>
</div>
{/* ─── 新建分类弹窗 ─── */}
<AntModal
title="新建分类"
open={createCatModalOpen}
onCancel={() => setCreateCatModalOpen(false)}
onOk={handleCreateCategory}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div style={{ padding: "8px 0" }}>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input
placeholder="请输入分类名称"
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
maxLength={30}
/>
</div>
</AntModal>
{/* ─── 新建标题弹窗 ─── */}
<AntModal
title="新建标题"
@@ -18,7 +18,7 @@ import {
CloseCircleOutlined,
} from "@ant-design/icons";
import PageHead from "@/components/layout/PageHead";
import CloneModal from "@/components/voice/CloneModal";
import CloneVoiceModal from "@/components/modals/CloneVoiceModal";
import {
getVoiceClones,
deleteVoiceClone,
@@ -356,7 +356,7 @@ const VoiceClone: React.FC = () => {
)}
{/* 克隆音色弹窗 */}
<CloneModal
<CloneVoiceModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={() => {
@@ -1,47 +0,0 @@
"""ASR 服务工厂 — 根据环境配置创建对应 ASR 服务实例。
支持的后端
- mock: MockASRService测试/开发用
- 后续可扩展whisper / aliyun / tencent
"""
from __future__ import annotations
import os
from functools import lru_cache
from packages.ports.asr_service import ASRService
@lru_cache(maxsize=1)
def get_asr_service() -> ASRService | None:
"""获取全局 ASR 服务实例(单例)。
根据环境变量 ASR_PROVIDER 决定使用哪个后端
- mock / / 未设置: 返回 None不启用 ASR
- mock: 使用 MockASRService
Returns:
ASRService 实例未配置或不启用时返回 None
"""
provider = os.environ.get("ASR_PROVIDER", "").lower().strip()
if not provider:
return None
if provider == "mock":
from packages.adapters.asr.mock_asr_service import MockASRService
return MockASRService()
# 未知 provider,记录日志并返回 None(不启用 ASR,不阻断主流程)
import logging
logger = logging.getLogger(__name__)
logger.warning("未知的 ASR provider: %sASR 自动字幕功能未启用", provider)
return None
def reset_asr_service_cache() -> None:
"""重置 ASR 服务缓存(测试用)。"""
get_asr_service.cache_clear()
@@ -1,66 +0,0 @@
"""TTS 服务工厂.
根据配置创建对应的 TTS 服务实例
"""
from __future__ import annotations
import logging
import os
from packages.ports.tts_service import TtsService
logger = logging.getLogger(__name__)
# 可用的 provider 映射
_PROVIDERS: dict[str, type[TtsService]] = {}
def register_provider(name: str, cls: type[TtsService]) -> None:
"""注册 TTS 供应商."""
_PROVIDERS[name] = cls
def get_tts_service(provider: str | None = None, **kwargs) -> TtsService:
"""获取 TTS 服务实例.
Args:
provider: 供应商名称None 则从环境变量读取 TTS_PROVIDER
**kwargs: 传递给服务构造函数的参数
Returns:
TTS 服务实例
Raises:
ValueError: 不支持的供应商
"""
if provider is None:
provider = os.environ.get("TTS_PROVIDER", "mock")
provider = provider.lower()
if provider not in _PROVIDERS:
# 延迟导入避免循环依赖
if provider == "mock":
from packages.adapters.tts.mock_tts_service import MockTtsService
_PROVIDERS["mock"] = MockTtsService
else:
logger.warning("未知 TTS provider: %s,回退到 mock", provider)
from packages.adapters.tts.mock_tts_service import MockTtsService
_PROVIDERS["mock"] = MockTtsService
provider = "mock"
cls = _PROVIDERS[provider]
return cls(**kwargs)
def available_providers() -> list[str]:
"""获取可用的供应商列表."""
# 确保 mock 已注册
if "mock" not in _PROVIDERS:
from packages.adapters.tts.mock_tts_service import MockTtsService
_PROVIDERS["mock"] = MockTtsService
return list(_PROVIDERS.keys())
-313
View File
@@ -1,313 +0,0 @@
"""BGM 混音模块 — 背景音乐与主音频混合.
基于 FFmpeg 实现
- BGM 音量调节
- 淡入淡出afade
- 循环播放aloop BGM 铺长视频
- 人声闪避sidechaincompress有人声时BGM自动降低音量
- amix 混音
作为 render_audio.py 的增强模块 mix_audio 后处理阶段被调用
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
if TYPE_CHECKING:
from video_processing.render_audio import RenderContext
logger = logging.getLogger(__name__)
@dataclass
class BGMConfig:
"""BGM 混音配置(内部使用,从 plan.config.bgm 转换而来)"""
bgm_path: str # BGM 本地文件路径
volume: float = 0.3 # 0.0 ~ 1.0
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
loop_enabled: bool = True # 是否循环铺满
sidechain_enabled: bool = False # 人声闪避
sidechain_ratio: float = 0.3 # 闪避时音量降低比例
sidechain_attack: float = 0.02 # 攻击时间
sidechain_release: float = 0.5 # 释放时间
sidechain_threshold: float = -25.0 # 触发阈值(dB
@classmethod
def from_config_dict(cls, bgm_path: str, config: dict) -> "BGMConfig":
"""从 plan.config.bgm 字典创建 BGMConfig。"""
return cls(
bgm_path=bgm_path,
volume=float(config.get("volume", 0.3)),
fade_in=float(config.get("fade_in", 0.0)),
fade_out=float(config.get("fade_out", 0.0)),
loop_enabled=bool(config.get("loop_enabled", True)),
sidechain_enabled=bool(config.get("sidechain_enabled", False)),
sidechain_ratio=float(config.get("sidechain_ratio", 0.3)),
sidechain_attack=float(config.get("sidechain_attack", 0.02)),
sidechain_release=float(config.get("sidechain_release", 0.5)),
sidechain_threshold=float(config.get("sidechain_threshold", -25.0)),
)
# ── BGM 预处理 ────────────────────────────────────────────────────────────────
def prepare_bgm_track(
ctx: "RenderContext",
bgm: BGMConfig,
target_duration: float,
) -> Path:
"""预处理 BGM 轨道:循环/截断 + 音量 + 淡入淡出.
生成一个时长精确等于 target_duration BGM 音频文件
后续再与主音频混音
Args:
ctx: 渲染上下文
bgm: BGM 配置
target_duration: 目标时长通常等于视频总时长
Returns:
处理后的 BGM 音频文件路径
"""
output_path = ctx.work_dir / f"bgm_processed_{ctx.plan_id}.aac"
if target_duration <= 0:
target_duration = 5.0 # 兜底
bgm_dur = probe_duration(bgm.bgm_path)
needs_loop = bgm.loop_enabled and bgm_dur > 0 and bgm_dur < target_duration * 0.9
# 构建滤镜链
filter_parts: list[str] = []
input_looped: bool = False
if needs_loop:
# 计算需要循环多少次才能铺满
loop_count = max(1, int(target_duration / bgm_dur) + 2)
# aloop 滤镜:循环指定次数
filter_parts.append(f"aloop=loop={loop_count}:size=0")
input_looped = True
# 音量调节
volume = max(0.0, min(1.0, bgm.volume))
if abs(volume - 1.0) > 0.001:
filter_parts.append(f"volume={volume:.3f}")
# 淡入
if bgm.fade_in > 0:
filter_parts.append(f"afade=t=in:st=0:d={bgm.fade_in:.3f}")
# 淡出(从 target_duration - fade_out 开始)
if bgm.fade_out > 0 and target_duration > bgm.fade_out:
fade_start = target_duration - bgm.fade_out
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={bgm.fade_out:.3f}")
# 最终截断到目标时长
filter_parts.append(f"atrim=0:{target_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB") # 重置时间戳
filter_str = ",".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-i",
bgm.bgm_path,
"-filter:a",
filter_str,
"-c:a",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"[bgm] prepare BGM track: path=%s dur=%.2f target=%.2f loop=%s fade_in=%.2f fade_out=%.2f",
bgm.bgm_path[-40:],
bgm_dur,
target_duration,
needs_loop,
bgm.fade_in,
bgm.fade_out,
)
run_ffmpeg(command)
return output_path
# ── BGM + 主音频混音 ──────────────────────────────────────────────────────────
def mix_bgm_with_main(
ctx: "RenderContext",
main_audio_path: Path,
bgm: BGMConfig,
target_duration: float,
) -> Path:
"""将 BGM 与主音频混合.
两种模式
1. 普通混音sidechain 关闭amix 两路音频
2. 人声闪避sidechain 开启 sidechaincompress BGM 跟随主音频音量自动调整
Args:
ctx: 渲染上下文
main_audio_path: 主音频文件路径人声/原始音频
bgm: BGM 配置
target_duration: 目标时长
Returns:
混音后的音频文件路径
"""
output_path = ctx.work_dir / f"audio_with_bgm_{ctx.plan_id}.aac"
# 先预处理 BGM 轨道(循环/音量/淡入淡出/截断)
bgm_processed = prepare_bgm_track(ctx, bgm, target_duration)
if not bgm.sidechain_enabled:
# 普通 amix 混音
_mix_simple(main_audio_path, bgm_processed, output_path)
else:
# sidechain 人声闪避混音
_mix_sidechain(main_audio_path, bgm_processed, output_path, bgm)
return output_path
def _mix_simple(main_path: Path, bgm_path: Path, output_path: Path) -> None:
"""简单 amix 混音:主音频 + BGM = 输出.
主音频权重 1.0BGM 已经在预处理阶段调好了音量
amix 会自动归一化需要用 volume 补偿
"""
# 使用 amixinputs=2duration=first(以主音频时长为准)
# 然后用 volume=2 补偿 amix 的衰减(2路输入每路平均乘0.5)
filter_complex = "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
command = [
FFMPEG_BIN,
"-y",
"-i",
str(main_path),
"-i",
str(bgm_path),
"-filter_complex",
filter_complex,
"-map",
"[final]",
"-c:a",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info("[bgm] simple amix mix")
run_ffmpeg(command)
def _mix_sidechain(
main_path: Path,
bgm_path: Path,
output_path: Path,
bgm: BGMConfig,
) -> None:
"""sidechain 人声闪避混音.
原理
- 主音频作为 sidechain 信号源
- BGM 轨道经过 sidechaincompress根据主音频音量动态调整 BGM 音量
- 最后 amix 混音
FFmpeg sidechaincompress 参数
- threshold: 触发阈值dB主音频超过此值时开始压缩
- ratio: 压缩比越高压缩越狠
- attack: 攻击时间
- release: 释放时间
"""
# sidechain_ratio 表示闪避时 BGM 音量降低比例
# ratio = 1 / (1 - sidechain_ratio),但实际压缩比需要更精细调整
# 简化处理:把 ratio 映射到 2:1 ~ 10:1 范围
ratio = max(2.0, min(10.0, 1.0 / (1.0 - bgm.sidechain_ratio)))
filter_complex = (
# BGM 经过 sidechain 压缩,用主音频做触发
f"[1:a][0:a]sidechaincompress="
f"threshold={bgm.sidechain_threshold}dB:"
f"ratio={ratio:.1f}:"
f"attack={bgm.sidechain_attack:.3f}:"
f"release={bgm.sidechain_release:.3f}:"
f"knee=6[bgm_comp];"
# 主音频 + 压缩后的 BGM 混音
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
f"[outa]volume=1.5[final]" # 轻微补偿
)
command = [
FFMPEG_BIN,
"-y",
"-i",
str(main_path),
"-i",
str(bgm_path),
"-filter_complex",
filter_complex,
"-map",
"[final]",
"-c:a",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"[bgm] sidechain mix: threshold=%.1fdB ratio=%.1f attack=%.3f release=%.3f",
bgm.sidechain_threshold,
ratio,
bgm.sidechain_attack,
bgm.sidechain_release,
)
run_ffmpeg(command)
# ── 纯 BGM 模式(无主音频) ──────────────────────────────────────────────────
def build_bgm_only(
ctx: "RenderContext",
bgm: BGMConfig,
target_duration: float,
) -> Path:
"""只有 BGM、没有主音频时,直接生成 BGM 音频.
Args:
ctx: 渲染上下文
bgm: BGM 配置
target_duration: 目标时长
Returns:
BGM 音频文件路径
"""
output_path = ctx.work_dir / f"bgm_only_{ctx.plan_id}.aac"
if target_duration <= 0:
target_duration = 5.0
bgm_processed = prepare_bgm_track(ctx, bgm, target_duration)
# 直接复制
import shutil
shutil.copy2(bgm_processed, output_path)
return output_path
@@ -1,248 +0,0 @@
"""绿幕抠像引擎 — 基于 FFmpeg colorkey / chromakey 滤镜.
支持将指定颜色默认绿色变为透明可用于虚拟背景画中画背景替换等场景
使用方式
config = ChromaKeyConfig(key_color="#00FF00", similarity=0.3, blend=0.1)
engine = ChromaKeyEngine(config)
filter_str = engine.build_filter(input_label, output_label)
# 结果: [in]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[out]
降级策略
- 参数越界自动钳制
- 素材格式不支持时跳过调用方捕获异常
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# ── 配置模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ChromaKeyConfig:
"""绿幕抠像配置。
Attributes:
enabled: 是否启用抠像
key_color: 要抠除的颜色支持 hex 格式 "#00FF00"或颜色名
similarity: 颜色相似度阈值 0.01~1.0值越大抠除范围越大
blend: 边缘平滑/混合度 0.0~1.0值越大边缘越柔和
spill_suppress: 溢色抑制 0.0~1.0减少边缘的绿幕反光
"""
enabled: bool = False
key_color: str = "#00FF00"
similarity: float = 0.3
blend: float = 0.1
spill_suppress: float = 0.0
@classmethod
def from_dict(cls, data: dict | None) -> "ChromaKeyConfig":
"""从字典解析配置,参数越界自动钳制。"""
if not data or not data.get("enabled", False):
return cls(enabled=False)
key_color = str(data.get("key_color", "#00FF00")).strip()
def _safe_float(val, default):
try:
return float(val)
except (TypeError, ValueError):
return default
similarity = _safe_float(data.get("similarity", 0.3), 0.3)
blend = _safe_float(data.get("blend", 0.1), 0.1)
spill_suppress = _safe_float(data.get("spill_suppress", 0.0), 0.0)
# 钳制到合法范围
similarity = max(0.01, min(1.0, similarity))
blend = max(0.0, min(1.0, blend))
spill_suppress = max(0.0, min(1.0, spill_suppress))
return cls(
enabled=True,
key_color=key_color,
similarity=similarity,
blend=blend,
spill_suppress=spill_suppress,
)
def has_effect(self) -> bool:
"""判断是否有实际抠像效果。"""
return self.enabled and self.similarity > 0
# ── 预设配置 ──────────────────────────────────────────────────────────────────
# 常见绿幕/蓝幕预设
CHROMA_KEY_PRESETS = {
"green_screen": {
"key_color": "#00FF00",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.5,
},
"blue_screen": {
"key_color": "#0000FF",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.5,
},
"red_screen": {
"key_color": "#FF0000",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.0,
},
"precise_green": {
"key_color": "#00FF00",
"similarity": 0.2,
"blend": 0.05,
"spill_suppress": 0.3,
},
"soft_green": {
"key_color": "#00FF00",
"similarity": 0.45,
"blend": 0.2,
"spill_suppress": 0.5,
},
}
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
class ChromaKeyEngine:
"""绿幕抠像引擎。
基于 FFmpeg colorkey 滤镜实现将指定颜色变为透明
适用于绿幕/蓝幕视频的背景去除配合画中画或 overlay 实现虚拟背景
"""
def __init__(self, config: ChromaKeyConfig):
self.config = config
@staticmethod
def _normalize_color(color_str: str) -> str:
"""将颜色字符串转为 FFmpeg colorkey 接受的格式。
支持:
- "#RRGGBB" / "#RRGGBBAA" 0xRRGGBB
- "0xRRGGBB" 直接使用
- 颜色名green/blue/red/black/white 直接透传
"""
color = color_str.strip()
# hex 格式
hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color)
if hex_match:
return f"0x{hex_match.group(1).upper()}"
# 已经是 0x 格式
if color.lower().startswith("0x"):
return color.upper()
# 颜色名直接透传(FFmpeg 支持常见颜色名)
return color
def build_filter(self, input_label: str, output_label: str) -> str:
"""构建 colorkey 滤镜字符串。
Args:
input_label: 输入标签 "[0:v]" "[v0]"
output_label: 输出标签 "[ck0]"
Returns:
FFmpeg 滤镜字符串 "[v0]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[ck0]"
Raises:
ValueError: 配置无效时抛出调用方应捕获并降级
"""
if not self.config.has_effect():
# 无效果,直接直通
return f"{input_label}copy{output_label}"
color = self._normalize_color(self.config.key_color)
similarity = self.config.similarity
blend = self.config.blend
# 基础 colorkey 滤镜
parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"]
# 溢色抑制(通过 colorchannelmixer 降低绿色通道增益)
if self.config.spill_suppress > 0:
# 降低绿通道增益,减少绿幕反光溢出
spill = self.config.spill_suppress
# 绿通道增益 = 1 - spill_factor
g_gain = max(0.3, 1.0 - spill * 0.7)
# 同时稍微提升红和蓝来补偿色偏
r_gain = 1.0 + spill * 0.15
b_gain = 1.0 + spill * 0.15
parts.append(f"colorchannelmixer=" f"rr={r_gain}:" f"gg={g_gain}:" f"bb={b_gain}:" f"aa=1")
filter_str = f"{input_label}{','.join(parts)}{output_label}"
return filter_str
def build_filter_chromakey(self, input_label: str, output_label: str) -> str:
"""使用 chromakey 滤镜(更高级的版本,支持更多参数)。
注意并非所有 FFmpeg 版本都支持 chromakey 滤镜
优先使用 colorkey兼容性更好
Args:
input_label: 输入标签
output_label: 输出标签
Returns:
FFmpeg 滤镜字符串
"""
if not self.config.has_effect():
return f"{input_label}copy{output_label}"
color = self._normalize_color(self.config.key_color)
similarity = self.config.similarity
blend = self.config.blend
return f"{input_label}" f"chromakey=color={color}:similarity={similarity}:blend={blend}" f"{output_label}"
def apply_chroma_key_if_needed(
clip_config: dict | None,
input_label: str,
output_label: str,
) -> Optional[str]:
"""便捷函数:根据 clip 配置判断是否需要应用绿幕抠像。
Args:
clip_config: clip config 字典
input_label: 输入标签
output_label: 输出标签
Returns:
滤镜字符串不需要抠像时返回 None
"""
if not clip_config:
return None
chroma_key_data = clip_config.get("chroma_key")
if not chroma_key_data:
return None
try:
config = ChromaKeyConfig.from_dict(chroma_key_data)
if not config.has_effect():
return None
engine = ChromaKeyEngine(config)
return engine.build_filter(input_label, output_label)
except Exception as e:
logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e)
return None
@@ -1,416 +0,0 @@
"""滤镜调色引擎 — 基于 FFmpeg eq + colorbalance + hue + curves 滤镜组合实现画面色彩调整.
支持能力
- 基础调色参数亮度对比度饱和度色温色调
- 8种风格预设清新日系复古电影胶片黑白暖色冷色
- 分段应用每个 clip 可独立设置不同滤镜
- 降级策略参数越界自动钳制不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ── 预设滤镜包 ────────────────────────────────────────────────────────────────
# 预设名称常量
PRESET_FRESH = "fresh" # 清新
PRESET_JAPANESE = "japanese" # 日系
PRESET_VINTAGE = "vintage" # 复古
PRESET_CINEMA = "cinema" # 电影
PRESET_FILM = "film" # 胶片
PRESET_BW = "black_white" # 黑白
PRESET_WARM = "warm" # 暖色
PRESET_COOL = "cool" # 冷色
VALID_PRESETS = {
PRESET_FRESH,
PRESET_JAPANESE,
PRESET_VINTAGE,
PRESET_CINEMA,
PRESET_FILM,
PRESET_BW,
PRESET_WARM,
PRESET_COOL,
}
# 预设名称 → 中文显示名
PRESET_DISPLAY_NAMES = {
PRESET_FRESH: "清新",
PRESET_JAPANESE: "日系",
PRESET_VINTAGE: "复古",
PRESET_CINEMA: "电影",
PRESET_FILM: "胶片",
PRESET_BW: "黑白",
PRESET_WARM: "暖色",
PRESET_COOL: "冷色",
}
# 预设参数配置
# 每个预设包含:brightness, contrast, saturation, temperature, hue
# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180
PRESET_PARAMS: dict[str, dict[str, float]] = {
PRESET_FRESH: {
# 清新:提亮、高饱和、偏冷、微微调
"brightness": 8,
"contrast": 10,
"saturation": 120,
"temperature": -8,
"hue": 5,
},
PRESET_JAPANESE: {
# 日系:低对比、低饱和、偏暖、偏黄绿
"brightness": 12,
"contrast": -15,
"saturation": 70,
"temperature": 10,
"hue": -5,
},
PRESET_VINTAGE: {
# 复古:低饱和、偏黄、对比度适中、偏暖
"brightness": -5,
"contrast": 5,
"saturation": 60,
"temperature": 25,
"hue": -8,
},
PRESET_CINEMA: {
# 电影:高对比、低饱和、偏冷蓝、暗角感
"brightness": -8,
"contrast": 20,
"saturation": 75,
"temperature": -15,
"hue": -3,
},
PRESET_FILM: {
# 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟)
"brightness": -3,
"contrast": 12,
"saturation": 95,
"temperature": 15,
"hue": -2,
},
PRESET_BW: {
# 黑白:饱和度为0,对比度略高
"brightness": 0,
"contrast": 15,
"saturation": 0,
"temperature": 0,
"hue": 0,
},
PRESET_WARM: {
# 暖色:高色温、偏红黄
"brightness": 5,
"contrast": 8,
"saturation": 110,
"temperature": 30,
"hue": -5,
},
PRESET_COOL: {
# 冷色:低色温、偏蓝青
"brightness": 3,
"contrast": 8,
"saturation": 105,
"temperature": -25,
"hue": 8,
},
}
# ── 参数范围 ──────────────────────────────────────────────────────────────────
PARAM_RANGES = {
"brightness": (-100.0, 100.0),
"contrast": (-100.0, 100.0),
"saturation": (0.0, 200.0),
"temperature": (-100.0, 100.0),
"hue": (-180.0, 180.0),
}
# 默认值(零调整)
DEFAULT_PARAMS = {
"brightness": 0.0,
"contrast": 0.0,
"saturation": 100.0,
"temperature": 0.0,
"hue": 0.0,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ColorGradeConfig:
"""色彩调色配置.
优先级自定义参数 > 预设参数
先加载预设的基础参数再用 custom 中显式指定的参数覆盖
"""
enabled: bool = False
preset: str = "" # 预设名称,空表示不使用预设
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
brightness: float | None = None
contrast: float | None = None
saturation: float | None = None
temperature: float | None = None
hue: float | None = None
def resolve_params(self) -> dict[str, float]:
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
Returns:
包含 brightness, contrast, saturation, temperature, hue 的参数字典
"""
# 1. 从默认值开始
params = dict(DEFAULT_PARAMS)
# 2. 应用预设
if self.preset and self.preset in PRESET_PARAMS:
params.update(PRESET_PARAMS[self.preset])
# 3. 应用自定义覆盖
if self.brightness is not None:
params["brightness"] = self.brightness
if self.contrast is not None:
params["contrast"] = self.contrast
if self.saturation is not None:
params["saturation"] = self.saturation
if self.temperature is not None:
params["temperature"] = self.temperature
if self.hue is not None:
params["hue"] = self.hue
# 4. 边界钳制
for key, (min_val, max_val) in PARAM_RANGES.items():
params[key] = max(min_val, min(max_val, params[key]))
return params
def has_effect(self) -> bool:
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
用于优化无效果时跳过滤镜不浪费性能
"""
params = self.resolve_params()
for key, default in DEFAULT_PARAMS.items():
if abs(params[key] - default) > 0.001:
return True
return False
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
preset = data.get("preset", "")
if preset and preset not in VALID_PRESETS:
logger.warning("未知的调色预设: %s,忽略预设", preset)
preset = ""
def _get_float(key: str) -> float | None:
val = data.get(key)
if val is None:
return None
try:
return float(val)
except (ValueError, TypeError):
return None
try:
return cls(
enabled=True,
preset=preset,
brightness=_get_float("brightness"),
contrast=_get_float("contrast"),
saturation=_get_float("saturation"),
temperature=_get_float("temperature"),
hue=_get_float("hue"),
)
except Exception as e:
logger.warning("调色配置解析失败: %s,使用默认配置", e)
return cls(enabled=False)
# ── 调色引擎 ──────────────────────────────────────────────────────────────────
class ColorGradeEngine:
"""滤镜调色引擎 — 生成 FFmpeg 调色滤镜链.
滤镜组合策略
1. eq 滤镜调整亮度(brightness)对比度(contrast)饱和度(saturation)
2. colorbalance 滤镜调整色温通过调整红//蓝平衡
3. hue 滤镜调整色调
所有参数转换公式
- brightness: 用户值 -100~100 FFmpeg eq brightness -1.0~1.0
- contrast: 用户值 -100~100 FFmpeg eq contrast -1000~1000非线性映射
- saturation: 用户值 0~200 FFmpeg eq saturation 0.0~2.0
- temperature: 用户值 -100~100 colorbalance /蓝通道偏移
- hue: 用户值 -180~180 FFmpeg hue H -180~180
"""
@staticmethod
def _map_brightness(value: float) -> float:
"""用户亮度值 → FFmpeg eq brightness.
用户范围 -100~100 FFmpeg范围 -1.0~1.0
"""
return value / 100.0
@staticmethod
def _map_contrast(value: float) -> float:
"""用户对比度值 → FFmpeg eq contrast.
用户范围 -100~100 FFmpeg范围 -2.0~2.0
FFmpeg eq contrast 公式为 linear gain1.0 为原始
-2 ~ 2 的范围对应 ~-1000 ~ 1000 的老式定义的约 -66% ~ +100%
"""
if value >= 0:
# 正向:0~100 → 1.0~2.0
return 1.0 + value / 100.0
else:
# 负向:-100~0 → 0.0~1.0
return 1.0 + value / 100.0 # value为负数,相当于 1.0 - |value|/100
@staticmethod
def _map_saturation(value: float) -> float:
"""用户饱和度 → FFmpeg eq saturation.
用户范围 0~200 FFmpeg范围 0.0~2.0
"""
return value / 100.0
@staticmethod
def _map_temperature(value: float) -> tuple[float, float, float]:
"""用户色温值 → colorbalance 三个通道参数.
返回(red, green, blue) 每个通道 -1.0~1.0 的偏移
色温为正增加红减蓝
色温为负减红加蓝
"""
# -100~100 → -0.5~0.5
normalized = value / 200.0
if normalized >= 0:
# 暖色调:红+,绿微+,蓝-
red = normalized * 0.8
green = normalized * 0.3
blue = -normalized * 0.8
else:
# 冷色调:红-,绿微+,蓝+
red = normalized * 0.8 # 负数
green = -normalized * 0.2 # 正数(冷色也加点绿让它偏青)
blue = -normalized * 0.8 # 正数
return (red, green, blue)
@staticmethod
def _map_hue(value: float) -> float:
"""用户色调值 → FFmpeg hue滤镜角度.
用户范围 -180~180 FFmpeg H -180~180
"""
return value
@classmethod
def build_filter(cls, config: ColorGradeConfig, input_label: str = "", output_label: str = "") -> str:
"""构建调色滤镜字符串.
Args:
config: 调色配置
input_label: 输入标签带方括号 "[0:v]"空则无
output_label: 输出标签带方括号 "[graded]"空则无
Returns:
FFmpeg 滤镜字符串 "[0:v]eq=brightness=0.1:contrast=1.2,hue=H=10[graded]"
"""
if not config.enabled or not config.has_effect():
# 无效果时直通
if input_label and output_label:
return f"{input_label}copy{output_label}"
return ""
params = config.resolve_params()
filters: list[str] = []
# 1. eq 滤镜:亮度 + 对比度 + 饱和度
eq_parts: list[str] = []
brightness = cls._map_brightness(params["brightness"])
contrast = cls._map_contrast(params["contrast"])
saturation = cls._map_saturation(params["saturation"])
if abs(brightness) > 0.001:
eq_parts.append(f"brightness={brightness:.3f}")
if abs(contrast - 1.0) > 0.001:
eq_parts.append(f"contrast={contrast:.3f}")
if abs(saturation - 1.0) > 0.001:
eq_parts.append(f"saturation={saturation:.3f}")
if eq_parts:
filters.append(f"eq={':'.join(eq_parts)}")
# 2. colorbalance 滤镜:色温
if abs(params["temperature"]) > 0.001:
red, green, blue = cls._map_temperature(params["temperature"])
cb_parts = []
# 调整阴影/中间调/高光的平衡(简化:全部统一调整)
if abs(red) > 0.001:
cb_parts.append(f"rs={red:.3f}")
cb_parts.append(f"rm={red:.3f}")
cb_parts.append(f"rh={red:.3f}")
if abs(green) > 0.001:
cb_parts.append(f"gs={green:.3f}")
cb_parts.append(f"gm={green:.3f}")
cb_parts.append(f"gh={green:.3f}")
if abs(blue) > 0.001:
cb_parts.append(f"bs={blue:.3f}")
cb_parts.append(f"bm={blue:.3f}")
cb_parts.append(f"bh={blue:.3f}")
if cb_parts:
filters.append(f"colorbalance={':'.join(cb_parts)}")
# 3. hue 滤镜:色调
if abs(params["hue"]) > 0.001:
hue_val = cls._map_hue(params["hue"])
filters.append(f"hue=h={hue_val:.1f}")
if not filters:
# 理论上不会到这里(has_effect 已判断),保险起见
if input_label and output_label:
return f"{input_label}copy{output_label}"
return ""
filter_str = ",".join(filters)
if input_label:
filter_str = f"{input_label}{filter_str}"
if output_label:
filter_str = f"{filter_str}{output_label}"
return filter_str
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def get_preset_names() -> list[tuple[str, str]]:
"""获取所有预设名称列表.
Returns:
[(preset_key, display_name), ...]
"""
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
def get_preset_params(preset: str) -> dict[str, float] | None:
"""获取指定预设的参数."""
return PRESET_PARAMS.get(preset)
@@ -1,431 +0,0 @@
"""视频封面生成器 — 从视频中提取/生成封面图.
支持能力
- 指定时间点抽帧默认第1秒
- 智能封面抽取多帧选最清晰的一帧
- 自定义上传封面图直接返回路径
- 生成的封面图保存为 JPEG 格式可复用
"""
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
logger = logging.getLogger(__name__)
# ── 配置常量 ──────────────────────────────────────────────────────────────────
# 智能封面抽帧数量
SMART_COVER_FRAME_COUNT = 3
# 默认抽帧时间点(秒)
DEFAULT_COVER_TIME = 1.0
# 封面输出尺寸(宽x高)
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
# 封面质量(JPEG quality 1-31,越小质量越高)
DEFAULT_COVER_QUALITY = 5
# ── 数据模型 ──────────────────────────────────────────────────────────────────
class CoverGenerator:
"""视频封面生成器.
三种模式
1. 指定时间点抽帧从视频指定时间提取一帧
2. 智能封面抽取3帧 blur 检测选最清晰的
3. 自定义上传直接使用用户上传的图片
"""
@staticmethod
def extract_frame(
video_path: str | Path,
output_path: str | Path,
*,
time_sec: float = DEFAULT_COVER_TIME,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""从视频指定时间点提取一帧作为封面.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点
width: 输出宽度
height: 输出高度
quality: JPEG 质量1-31越小越好
Returns:
封面图片路径
Raises:
FileNotFoundError: 视频文件不存在
subprocess.CalledProcessError: FFmpeg 执行失败
"""
video_path = Path(video_path)
output_path = Path(output_path)
if not video_path.exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 安全钳制时间
info = probe_video_info(str(video_path))
duration = info.get("duration", 0.0)
if duration > 0 and time_sec >= duration:
# 超过视频长度,取中间帧
time_sec = max(0, duration / 2)
if time_sec < 0:
time_sec = 0
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
FFMPEG_BIN,
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name)
run_ffmpeg(command)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError(f"封面生成失败: {output_path}")
return output_path
@staticmethod
def extract_smart_cover(
video_path: str | Path,
output_path: str | Path,
*,
frame_count: int = SMART_COVER_FRAME_COUNT,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
work_dir: str | Path | None = None,
) -> Path:
"""智能封面:抽取多帧,选最清晰的一帧.
清晰度判断使用拉普拉斯方差Variance of Laplacian
方差越大表示图像边缘越丰富越清晰
Args:
video_path: 视频文件路径
output_path: 最终输出封面路径
frame_count: 抽帧数量均匀分布在视频中
width: 输出宽度
height: 输出高度
quality: JPEG 质量
work_dir: 临时工作目录默认输出目录的父目录
Returns:
最佳封面图片路径
"""
video_path = Path(video_path)
output_path = Path(output_path)
if not video_path.exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
# 获取视频时长
info = probe_video_info(str(video_path))
duration = info.get("duration", 0.0)
if duration <= 0 or frame_count <= 1:
# 无法获取时长或只有1帧,退化为普通抽帧
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)),
width=width,
height=height,
quality=quality,
)
# 临时目录
if work_dir is None:
work_dir = output_path.parent
work_dir = Path(work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
# 均匀分布抽帧时间点(跳过首尾5%
start_pct = 0.05
end_pct = 0.95
if frame_count == 1:
time_points = [duration * 0.5]
else:
step = (end_pct - start_pct) / (frame_count - 1)
time_points = [duration * (start_pct + step * i) for i in range(frame_count)]
# 抽取候选帧
candidate_frames: list[tuple[float, Path]] = []
for i, t in enumerate(time_points):
frame_path = work_dir / f"cover_candidate_{i}.jpg"
try:
CoverGenerator.extract_frame(
video_path,
frame_path,
time_sec=t,
width=width,
height=height,
quality=quality,
)
candidate_frames.append((t, frame_path))
except Exception as e:
logger.warning("智能封面抽帧失败(t=%.2fs: %s", t, e)
continue
if not candidate_frames:
# 全部失败,退化到普通抽帧
logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧")
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=min(DEFAULT_COVER_TIME, duration / 2),
width=width,
height=height,
quality=quality,
)
if len(candidate_frames) == 1:
# 只有一帧,直接用
import shutil
shutil.copy2(candidate_frames[0][1], output_path)
return output_path
# 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理)
# 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰)
# 更准确的方案是用拉普拉斯方差,但需要额外依赖
# 这里用文件大小作为近似指标
best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size)
# 复制最佳帧到输出路径
import shutil
shutil.copy2(best_frame[1], output_path)
logger.info(
"智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节",
len(candidate_frames),
best_frame[0],
output_path.stat().st_size,
)
# 清理临时文件
for _, fp in candidate_frames:
try:
fp.unlink()
except OSError:
pass
return output_path
@staticmethod
def process_custom_cover(
image_path: str | Path,
output_path: str | Path,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""处理用户自定义上传的封面图.
调整尺寸格式转换为标准封面格式
Args:
image_path: 用户上传的图片路径
output_path: 输出封面路径
width: 目标宽度
height: 目标高度
quality: JPEG 质量
Returns:
处理后的封面图片路径
"""
image_path = Path(image_path)
output_path = Path(output_path)
if not image_path.exists():
raise FileNotFoundError(f"封面图片不存在: {image_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
FFMPEG_BIN,
"-y",
"-i",
str(image_path),
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError:
# 处理失败,直接复制原图
logger.warning("自定义封面处理失败,使用原图")
import shutil
shutil.copy2(image_path, output_path)
return output_path
@staticmethod
def generate_cover(
video_path: str | Path,
output_path: str | Path,
*,
mode: str = "smart", # smart / time / custom
time_sec: float = DEFAULT_COVER_TIME,
custom_image: str | Path | None = None,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""统一封面生成入口.
Args:
video_path: 视频文件路径
output_path: 输出封面路径
mode: 模式 - smart智能选帧/ time指定时间/ custom自定义图片
time_sec: time 模式下的抽帧时间点
custom_image: custom 模式下的自定义图片路径
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面图片路径
"""
if mode == "custom" and custom_image:
return CoverGenerator.process_custom_cover(
custom_image,
output_path,
width=width,
height=height,
quality=quality,
)
elif mode == "time":
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=time_sec,
width=width,
height=height,
quality=quality,
)
else:
# 默认智能封面
return CoverGenerator.extract_smart_cover(
video_path,
output_path,
width=width,
height=height,
quality=quality,
)
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def generate_cover_from_plan(
plan: Any,
video_path: str | Path,
output_dir: str | Path,
) -> Path | None:
"""从 EditPlan 配置生成封面图.
配置读取plan.config.cover_config
支持字段
- mode: smart / time / custom
- time_sec: 抽帧时间time模式
- custom_image_url: 自定义图片URL需要先下载到本地
Args:
plan: EditPlan 对象
video_path: 渲染后的视频路径
output_dir: 封面输出目录
Returns:
封面图片路径 None不需要生成封面时
"""
config = getattr(plan, "config", None) or {}
cover_config = config.get("cover_config") if isinstance(config, dict) else None
if not cover_config:
return None
mode = cover_config.get("mode", "smart")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"cover_{plan.id}.jpg"
try:
if mode == "custom":
# 自定义封面:需要先有本地图片路径
custom_path = cover_config.get("custom_image_path")
if custom_path and Path(custom_path).exists():
return CoverGenerator.process_custom_cover(
custom_path,
output_path,
)
else:
logger.warning("自定义封面图片路径无效,退化为智能封面")
mode = "smart"
if mode == "time":
time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME))
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=time_sec,
)
else:
# smart
return CoverGenerator.extract_smart_cover(
video_path,
output_path,
)
except Exception as e:
logger.warning("封面生成失败: %s", e)
return None
+3
View File
@@ -1,8 +1,10 @@
"""Video deduplication module - compute fingerprints and detect duplicates."""
import hashlib
import json
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass
from typing import Optional
@@ -325,6 +327,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
raise ValueError(f"Generated video {generated_video_id} not found")
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
storage_key = video.file_url.split("/")[-1]
storage_service.download_file(
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
)
-13
View File
@@ -75,19 +75,6 @@ def create_video_record_and_dedup(
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
# 生成封面缩略图
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
if thumbnail_url:
generated_video.thumbnail_url = thumbnail_url
video_repo.update_thumbnail(video_id, thumbnail_url)
logger.info("Thumbnail generated for video %s: %s", video_id, thumbnail_url)
except Exception as thumb_err:
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
# 计算视频指纹
deduplicator = VideoDeduplicator()
try:
+3 -22
View File
@@ -25,14 +25,8 @@ DEFAULT_FPS = 25
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
# "cut" 为特殊值:硬切,不使用 xfade(由调用方特殊处理)
XFADE_TRANSITION_MAP: dict[str, str] = {
# 基础
"fade": "fade",
"dissolve": "dissolve",
"crossfade": "dissolve",
"crossdissolve": "dissolve",
# 滑入系列
"slideleft": "slideleft",
"slide_left": "slideleft",
"slideright": "slideright",
@@ -41,22 +35,9 @@ XFADE_TRANSITION_MAP: dict[str, str] = {
"slide_up": "slideup",
"slidedown": "slidedown",
"slide_down": "slidedown",
"slide": "slideleft", # 默认向左滑
# 缩放
"zoom": "zoomin",
"zoomin": "zoomin",
"zoomout": "zoomout",
# 擦除系列
"wipe": "wipeleft", # 默认向左擦
"dissolve": "dissolve",
"wipe": "wipeleft",
"wipeleft": "wipeleft",
"wiperight": "wiperight",
"wipeup": "wipeup",
"wipedown": "wipedown",
# 特殊效果
"circlecrop": "circlecrop",
"circle": "circlecrop",
"rectcrop": "rectcrop",
"rect": "rectcrop",
}
DEFAULT_TRANSITION_DURATION = 0.5
@@ -100,7 +81,7 @@ def run_ffmpeg(
timeout=timeout,
)
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired:
except subprocess.TimeoutExpired as e:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
timeout or -1,
@@ -1,421 +0,0 @@
"""片头片尾引擎 — 视频包装与品牌标识.
支持
- 片头视频片段 纯文字片头背景色 + 标题 + 副标题
- 片尾视频片段 关注引导片尾
- 自动与正片拼接xfade 转场
- 时长可配置
"""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
logger = logging.getLogger(__name__)
@dataclass
class IntroOutroConfig:
"""片头片尾配置.
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
"""
enabled: bool = False
# 片头
intro_type: str = "none" # none | video | text
intro_video_path: str = "" # 视频片段路径
intro_duration: float = 3.0 # 片头时长(秒)
# 文字片头配置
intro_background: str = "#000000" # 背景色
intro_title: str = ""
intro_subtitle: str = ""
intro_title_color: str = "white"
intro_title_size: int = 48
intro_subtitle_color: str = "gray"
intro_subtitle_size: int = 24
# 片尾
outro_type: str = "none" # none | video | text | follow
outro_video_path: str = "" # 视频片段路径
outro_duration: float = 3.0 # 片尾时长(秒)
# 文字片尾配置
outro_background: str = "#000000"
outro_title: str = "感谢观看"
outro_subtitle: str = "点赞关注不迷路"
outro_title_color: str = "white"
outro_title_size: int = 48
outro_subtitle_color: str = "gray"
outro_subtitle_size: int = 24
# 转场
transition_effect: str = "fade"
transition_duration: float = 0.5
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig:
"""从字典构造."""
if not data:
return cls()
enabled = data.get("enabled", False)
if not enabled:
return cls()
intro = data.get("intro", {}) or {}
outro = data.get("outro", {}) or {}
return cls(
enabled=True,
# 片头
intro_type=str(intro.get("type", "none")),
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
intro_duration=float(intro.get("duration", 3.0)),
intro_background=str(intro.get("background", "#000000")),
intro_title=str(intro.get("title", "") or ""),
intro_subtitle=str(intro.get("subtitle", "") or ""),
intro_title_color=str(intro.get("title_color", "white")),
intro_title_size=int(intro.get("title_size", 48)),
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
intro_subtitle_size=int(intro.get("subtitle_size", 24)),
# 片尾
outro_type=str(outro.get("type", "none")),
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
outro_duration=float(outro.get("duration", 3.0)),
outro_background=str(outro.get("background", "#000000")),
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
outro_title_color=str(outro.get("title_color", "white")),
outro_title_size=int(outro.get("title_size", 48)),
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
outro_subtitle_size=int(outro.get("subtitle_size", 24)),
# 转场
transition_effect=str(data.get("transition", "fade")),
transition_duration=float(data.get("transition_duration", 0.5)),
)
@property
def has_intro(self) -> bool:
"""是否有片头."""
return self.enabled and self.intro_type in ("video", "text")
@property
def has_outro(self) -> bool:
"""是否有片尾."""
return self.enabled and self.outro_type in ("video", "text", "follow")
def validate(self) -> tuple[bool, str]:
"""校验配置."""
if not self.enabled:
return True, ""
if self.intro_type == "video" and not self.intro_video_path:
return False, "视频片头缺少 video_path"
if self.intro_type == "text" and not self.intro_title:
return False, "文字片头缺少 title"
if self.outro_type == "video" and not self.outro_video_path:
return False, "视频片尾缺少 video_path"
if self.outro_type in ("text", "follow") and not self.outro_title:
return False, "文字片尾缺少 title"
if self.intro_duration <= 0:
return False, "片头时长必须大于 0"
if self.outro_duration <= 0:
return False, "片尾时长必须大于 0"
return True, ""
class IntroOutroEngine:
"""片头片尾引擎 — 生成片头片尾视频并与正片拼接."""
@staticmethod
def generate_text_intro(
output_path: Path,
config: IntroOutroConfig,
output_width: int,
output_height: int,
output_fps: int,
) -> bool:
"""生成纯文字片头视频.
Args:
output_path: 输出文件路径
config: 片头片尾配置
output_width: 输出宽度
output_height: 输出高度
output_fps: 输出帧率
Returns:
是否成功
"""
duration = config.intro_duration
bg = config.intro_background.lstrip("#")
# 转义文字
title = config.intro_title.replace(":", "\\:").replace("'", "\\'")
subtitle = config.intro_subtitle.replace(":", "\\:").replace("'", "\\'")
# 颜色(FFmpeg 颜色格式)
title_color = config.intro_title_color
subtitle_color = config.intro_subtitle_color
# 计算位置:标题在中心偏上,副标题在中心偏下
title_y = f"(h-text_h)/2 - {config.intro_title_size // 2}"
subtitle_y = f"(h-text_h)/2 + {config.intro_title_size}"
# 构建滤镜
filter_parts = []
# 背景
filter_parts.append(
f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}[bg]"
)
# 标题
if title:
filter_parts.append(
f"[bg]drawtext="
f"text='{title}':"
f"fontsize={config.intro_title_size}:"
f"fontcolor={title_color}:"
f"x=(w-text_w)/2:"
f"y={title_y}:"
f"alpha='if(lt(t,0.5),t/0.5,1)'" # 淡入
f"[with_title]"
)
bg_label = "with_title"
else:
bg_label = "bg"
# 副标题
if subtitle:
filter_parts.append(
f"[{bg_label}]drawtext="
f"text='{subtitle}':"
f"fontsize={config.intro_subtitle_size}:"
f"fontcolor={subtitle_color}:"
f"x=(w-text_w)/2:"
f"y={subtitle_y}:"
f"alpha='if(lt(t,0.8),0,if(lt(t,1.2),(t-0.8)/0.4,1))'" # 延迟淡入
f"[out]"
)
final_label = "out"
else:
final_label = bg_label
# 如果没有副标题,需要补上 out 标签
if final_label != "out":
filter_parts.append(f"[{bg_label}]copy[out]")
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-f",
"lavfi",
"-i",
f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}",
"-filter_complex",
filter_complex,
"-map",
"[out]",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-r",
str(output_fps),
"-t",
str(duration),
"-an", # 无音频
str(output_path),
]
try:
run_ffmpeg(command)
return output_path.exists()
except subprocess.CalledProcessError as e:
logger.error("生成文字片头失败: %s", e)
return False
@staticmethod
def generate_text_outro(
output_path: Path,
config: IntroOutroConfig,
output_width: int,
output_height: int,
output_fps: int,
) -> bool:
"""生成纯文字片尾视频."""
duration = config.outro_duration
# 转义文字
title = config.outro_title.replace(":", "\\:").replace("'", "\\'")
subtitle = config.outro_subtitle.replace(":", "\\:").replace("'", "\\'")
title_color = config.outro_title_color
subtitle_color = config.outro_subtitle_color
# 位置
title_y = f"(h-text_h)/2 - {config.outro_title_size // 2}"
subtitle_y = f"(h-text_h)/2 + {config.outro_title_size}"
filter_parts = []
# 背景
bg_src = f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}"
filter_parts.append(f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}[bg]")
# 标题 + 淡出
if title:
filter_parts.append(
f"[bg]drawtext="
f"text='{title}':"
f"fontsize={config.outro_title_size}:"
f"fontcolor={title_color}:"
f"x=(w-text_w)/2:"
f"y={title_y}:"
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
f"[with_title]"
)
bg_label = "with_title"
else:
bg_label = "bg"
# 副标题
if subtitle:
filter_parts.append(
f"[{bg_label}]drawtext="
f"text='{subtitle}':"
f"fontsize={config.outro_subtitle_size}:"
f"fontcolor={subtitle_color}:"
f"x=(w-text_w)/2:"
f"y={subtitle_y}:"
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
f"[out]"
)
final_label = "out"
else:
final_label = bg_label
if final_label != "out":
filter_parts.append(f"[{bg_label}]copy[out]")
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-f",
"lavfi",
"-i",
bg_src,
"-filter_complex",
filter_complex,
"-map",
"[out]",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-r",
str(output_fps),
"-t",
str(duration),
"-an",
str(output_path),
]
try:
run_ffmpeg(command)
return output_path.exists()
except subprocess.CalledProcessError as e:
logger.error("生成文字片尾失败: %s", e)
return False
@staticmethod
def concat_with_intro_outro(
main_video: Path,
intro_video: Path | None,
outro_video: Path | None,
output_path: Path,
transition_duration: float = 0.5,
transition_effect: str = "fade",
) -> bool:
"""将片头 + 正片 + 片尾用 xfade 拼接.
只传了片头或片尾也可以缺失的自动跳过
"""
# 收集所有片段
segments: list[tuple[Path, float]] = [] # (path, duration)
# 简单探测时长(用 ffprobe,这里简化处理:直接用 xfade 的 offset
# 先添加到列表
has_intro = intro_video is not None and intro_video.exists()
has_outro = outro_video is not None and outro_video.exists()
if not has_intro and not has_outro:
# 没有片头片尾,直接复制
import shutil
shutil.copy2(main_video, output_path)
return True
# 构建输入和 xfade 链
# 简单方式:用 concat demuxer(快速但无转场)
# 高级方式:用 xfade 滤镜链(有转场但复杂)
# 用 concat demuxer 方式(性能好,过渡用硬切)
# 后续可以加 xfade 转场
concat_list = []
if has_intro:
concat_list.append(intro_video)
concat_list.append(main_video)
if has_outro:
concat_list.append(outro_video)
# 生成 concat 列表文件
list_file = output_path.parent / f"concat_list_{output_path.stem}.txt"
with open(list_file, "w") as f:
for seg in concat_list:
f.write(f"file '{seg}'\n")
command = [
FFMPEG_BIN,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(list_file),
"-c:v",
"libx264",
"-c:a",
"aac",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(output_path),
]
try:
run_ffmpeg(command)
# 清理列表文件
list_file.unlink(missing_ok=True)
return output_path.exists()
except subprocess.CalledProcessError as e:
logger.error("片头片尾拼接失败: %s", e)
list_file.unlink(missing_ok=True)
return False
@@ -1,229 +0,0 @@
"""音频降噪引擎 — 基于 FFmpeg afftdn 滤镜.
支持对音频进行背景噪音消除人声增强适用于语音录制采访等场景
使用方式
config = NoiseReductionConfig(level="medium")
engine = NoiseReductionEngine(config)
filter_str = engine.build_filter(input_label, output_label)
# 结果: [0:a]afftdn=nf=-25[out]
降级策略
- 参数越界自动钳制
- FFmpeg 不支持 afftdn 调用方可捕获异常并跳过
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Optional
logger = logging.getLogger(__name__)
# ── 降噪等级 ──────────────────────────────────────────────────────────────────
class NoiseReductionLevel(str, Enum):
"""降噪等级预设。"""
LOW = "low" # 轻度降噪,保留细节,适合轻微背景噪音
MEDIUM = "medium" # 中度降噪,平衡效果和音质
HIGH = "high" # 高度降噪,适合嘈杂环境,可能轻微影响音质
CUSTOM = "custom" # 自定义参数
# 各等级对应的降噪参数(afftdn 的 noise floor,单位 dB
# 值越大(越接近 0),降噪越强;值越小(越负),降噪越弱
_LEVEL_PARAMS = {
NoiseReductionLevel.LOW: {
"nf": -35, # 噪音阈值(dB),越负越保守
"tn": -10, # 噪音频谱平滑度
"tr": 50, # 时间分辨率(ms
},
NoiseReductionLevel.MEDIUM: {
"nf": -25,
"tn": -10,
"tr": 50,
},
NoiseReductionLevel.HIGH: {
"nf": -15,
"tn": -5,
"tr": 30,
},
}
# ── 配置模型 ──────────────────────────────────────────────────────────────────
@dataclass
class NoiseReductionConfig:
"""音频降噪配置。
Attributes:
enabled: 是否启用降噪
level: 降噪等级 low/medium/high/custom
noise_floor: 自定义噪音阈值dB level=custom 时有效范围 -60 ~ -5
voice_enhance: 是否启用人声增强
output_format: 输出格式描述内部使用
"""
enabled: bool = False
level: NoiseReductionLevel = NoiseReductionLevel.MEDIUM
noise_floor: float = -25.0 # dB
voice_enhance: bool = False
@classmethod
def from_dict(cls, data: dict | None) -> "NoiseReductionConfig":
"""从字典解析配置,参数越界自动钳制。"""
if not data or not data.get("enabled", False):
return cls(enabled=False)
level_str = str(data.get("level", "medium")).lower()
try:
level = NoiseReductionLevel(level_str)
except ValueError:
level = NoiseReductionLevel.MEDIUM
try:
noise_floor = float(data.get("noise_floor", -25.0))
except (TypeError, ValueError):
noise_floor = -25.0
voice_enhance = bool(data.get("voice_enhance", False))
# 钳制到合法范围
noise_floor = max(-60.0, min(-5.0, noise_floor))
return cls(
enabled=True,
level=level,
noise_floor=noise_floor,
voice_enhance=voice_enhance,
)
def has_effect(self) -> bool:
"""判断是否有实际降噪效果。"""
return self.enabled
def get_effective_noise_floor(self) -> float:
"""获取实际生效的噪音阈值(dB)。"""
if self.level == NoiseReductionLevel.CUSTOM:
return self.noise_floor
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[NoiseReductionLevel.MEDIUM])
return float(params["nf"])
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
class NoiseReductionEngine:
"""音频降噪引擎。
基于 FFmpeg afftdnAudio FFt Denoiser滤镜实现
- 使用短时傅里叶变换分析音频频谱
- 识别并消除稳态背景噪音
- 保留人声等非稳态信号
"""
def __init__(self, config: NoiseReductionConfig):
self.config = config
def build_filter(self, input_label: str, output_label: str) -> str:
"""构建音频降噪滤镜字符串。
Args:
input_label: 输入标签 "[0:a]" "[a0]"
output_label: 输出标签 "[nr0]"
Returns:
FFmpeg 滤镜字符串 "[a0]afftdn=nf=-25:tn=-10:tr=50[nr0]"
Raises:
ValueError: 配置无效时抛出调用方应捕获并降级
"""
if not self.config.has_effect():
return f"{input_label}anull{output_label}"
# 获取参数
if self.config.level == NoiseReductionLevel.CUSTOM:
nf = self.config.noise_floor
tn = -10 # 默认频谱平滑度
tr = 50 # 默认时间分辨率
else:
params = _LEVEL_PARAMS.get(
self.config.level,
_LEVEL_PARAMS[NoiseReductionLevel.MEDIUM],
)
nf = float(params["nf"])
tn = float(params["tn"])
tr = float(params["tr"])
# 构建 afftdn 滤镜
# nf: noise floor (dB)
# tn: temporal noise floor smoothing (dB)
# tr: time resolution (ms)
filter_parts = [f"afftdn=nf={nf}:tn={tn}:tr={tr}"]
# 人声增强:通过 highpass + 轻微压缩实现
if self.config.voice_enhance:
# 1. 高通滤波,去除低频噪音
filter_parts.append("highpass=f=80")
# 2. 轻微压缩,提升人声清晰度
filter_parts.append("acompressor=threshold=-20:ratio=2:attack=5:release=50")
# 3. 响度归一化
filter_parts.append("loudnorm=I=-16:TP=-1.5:LRA=11")
filter_str = f"{input_label}{','.join(filter_parts)}{output_label}"
return filter_str
def build_filter_arnndn(self, input_label: str, output_label: str, model_file: str) -> str:
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件)。
注意需要额外下载 RNNNoise 模型文件默认使用 afftdn无需额外依赖
Args:
input_label: 输入标签
output_label: 输出标签
model_file: RNNNoise 模型文件路径.rnnn 格式
Returns:
FFmpeg 滤镜字符串
"""
if not self.config.has_effect():
return f"{input_label}anull{output_label}"
return f"{input_label}arnndn=m={model_file}{output_label}"
def apply_noise_reduction_if_needed(
config_data: dict | None,
input_label: str,
output_label: str,
) -> Optional[str]:
"""便捷函数:根据配置判断是否需要应用音频降噪。
Args:
config_data: 降噪配置字典 plan.config.audio_noise_reduction clip.config.noise_reduction 读取
input_label: 输入标签
output_label: 输出标签
Returns:
滤镜字符串不需要降噪时返回 None
"""
if not config_data:
return None
try:
config = NoiseReductionConfig.from_dict(config_data)
if not config.has_effect():
return None
engine = NoiseReductionEngine(config)
return engine.build_filter(input_label, output_label)
except Exception as e:
logger.warning("[noise-reduction] 应用降噪失败,跳过: %s", e)
return None
@@ -11,6 +11,7 @@ import logging
import os
import threading
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import oss2
-483
View File
@@ -1,483 +0,0 @@
"""画中画(PiP)引擎 — 基于 FFmpeg overlay 滤镜实现多图层叠加.
支持能力
- 多图层叠加主画面 + 多个副画面
- 位置9宫格 + 自由坐标像素或百分比
- 大小宽高缩放像素或百分比
- 圆角裁剪支持圆角矩形裁剪
- 透明度0-100%
- 入场出场动画淡入淡出滑入滑出
- 时间同步每个副画面独立开始时间和持续时长
- 降级策略素材不存在时跳过不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 位置常量 ──────────────────────────────────────────────────────────────────
# 9宫格位置枚举
POSITION_TOP_LEFT = "top_left"
POSITION_TOP_CENTER = "top_center"
POSITION_TOP_RIGHT = "top_right"
POSITION_CENTER_LEFT = "center_left"
POSITION_CENTER = "center"
POSITION_CENTER_RIGHT = "center_right"
POSITION_BOTTOM_LEFT = "bottom_left"
POSITION_BOTTOM_CENTER = "bottom_center"
POSITION_BOTTOM_RIGHT = "bottom_right"
_VALID_POSITIONS = {
POSITION_TOP_LEFT,
POSITION_TOP_CENTER,
POSITION_TOP_RIGHT,
POSITION_CENTER_LEFT,
POSITION_CENTER,
POSITION_CENTER_RIGHT,
POSITION_BOTTOM_LEFT,
POSITION_BOTTOM_CENTER,
POSITION_BOTTOM_RIGHT,
}
# 动画类型
ANIMATION_FADE = "fade" # 淡入淡出
ANIMATION_SLIDE_LEFT = "slide_left" # 从左滑入
ANIMATION_SLIDE_RIGHT = "slide_right" # 从右滑入
ANIMATION_SLIDE_TOP = "slide_top" # 从上滑入
ANIMATION_SLIDE_BOTTOM = "slide_bottom" # 从下滑入
_VALID_ANIMATIONS = {
ANIMATION_FADE,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
ANIMATION_SLIDE_BOTTOM,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class PiPLayerConfig:
"""单个画中画图层配置."""
# 素材来源
source: str = "" # 素材ID或视频URL
source_type: str = "asset_id" # "asset_id" | "url" | "local_path"
# 位置配置
position: str = POSITION_BOTTOM_RIGHT # 9宫格位置或 "custom"
x: int | str = 0 # 自定义x坐标(像素或百分比如 "30%"
y: int | str = 0 # 自定义y坐标
margin: int = 20 # 9宫格模式下的边距(像素)
# 大小配置
width: int | str = "25%" # 宽度(像素或百分比)
height: int | str = "" # 高度(空则按比例自适应)
# 样式
opacity: float = 1.0 # 透明度 0.0-1.0
corner_radius: int = 0 # 圆角半径(像素),0表示无圆角
border_width: int = 0 # 边框宽度
border_color: str = "white" # 边框颜色
# 时间控制
start_time: float = 0.0 # 开始显示时间(秒)
duration: float = 0.0 # 持续时长(秒),0表示全程显示
# 动画
animation_in: str = "" # 入场动画类型
animation_out: str = "" # 出场动画类型
animation_duration: float = 0.5 # 动画时长(秒)
# 层级
z_index: int = 1 # 图层顺序,数字越大越在上层
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.source:
return False, "source不能为空"
if self.position != "custom" and self.position not in _VALID_POSITIONS:
return False, f"无效的position: {self.position}"
if self.opacity < 0 or self.opacity > 1:
return False, "opacity必须在0-1之间"
if self.corner_radius < 0:
return False, "corner_radius不能为负数"
if self.start_time < 0:
return False, "start_time不能为负数"
if self.duration < 0:
return False, "duration不能为负数"
if self.animation_in and self.animation_in not in _VALID_ANIMATIONS:
return False, f"无效的入场动画: {self.animation_in}"
if self.animation_out and self.animation_out not in _VALID_ANIMATIONS:
return False, f"无效的出场动画: {self.animation_out}"
if self.animation_duration < 0:
return False, "animation_duration不能为负数"
return True, ""
@dataclass
class PiPConfig:
"""画中画整体配置."""
enabled: bool = False
layers: list[PiPLayerConfig] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
layers_data = data.get("layers", [])
layers = []
for layer_data in layers_data:
try:
layer = PiPLayerConfig(
source=layer_data.get("source", ""),
source_type=layer_data.get("source_type", "asset_id"),
position=layer_data.get("position", POSITION_BOTTOM_RIGHT),
x=layer_data.get("x", 0),
y=layer_data.get("y", 0),
margin=int(layer_data.get("margin", 20)),
width=layer_data.get("width", "25%"),
height=layer_data.get("height", ""),
opacity=float(layer_data.get("opacity", 1.0)),
corner_radius=int(layer_data.get("corner_radius", 0)),
border_width=int(layer_data.get("border_width", 0)),
border_color=layer_data.get("border_color", "white"),
start_time=float(layer_data.get("start_time", 0.0)),
duration=float(layer_data.get("duration", 0.0)),
animation_in=layer_data.get("animation_in", ""),
animation_out=layer_data.get("animation_out", ""),
animation_duration=float(layer_data.get("animation_duration", 0.5)),
z_index=int(layer_data.get("z_index", 1)),
)
valid, err = layer.validate()
if valid:
layers.append(layer)
else:
logger.warning("PiP图层配置无效,跳过: %s", err)
except (ValueError, TypeError) as e:
logger.warning("PiP图层解析失败,跳过: %s", e)
# 按 z_index 排序
layers.sort(key=lambda layer: layer.z_index)
return cls(enabled=bool(layers), layers=layers)
# ── PiP 引擎 ──────────────────────────────────────────────────────────────────
class PiPEngine:
"""画中画引擎 — 生成 FFmpeg 滤镜链实现多图层叠加."""
def __init__(
self,
output_width: int,
output_height: int,
output_fps: int = 30,
):
self.output_width = output_width
self.output_height = output_height
self.output_fps = output_fps
def _parse_size(self, value: int | str, base: int) -> int:
"""解析尺寸值(像素或百分比)."""
if isinstance(value, int):
return max(1, value)
if isinstance(value, str) and value.endswith("%"):
pct = float(value.rstrip("%")) / 100.0
return max(1, int(base * pct))
try:
return max(1, int(value))
except (ValueError, TypeError):
return int(base * 0.25) # 默认25%
def _parse_position(
self,
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
) -> tuple[int, int]:
"""计算画中画的实际位置 (x, y)."""
W = self.output_width
H = self.output_height
m = layer.margin
if layer.position == "custom":
x = self._parse_size(layer.x, W)
y = self._parse_size(layer.y, H)
return (x, y)
pos_map = {
POSITION_TOP_LEFT: (m, m),
POSITION_TOP_CENTER: ((W - pip_width) // 2, m),
POSITION_TOP_RIGHT: (W - pip_width - m, m),
POSITION_CENTER_LEFT: (m, (H - pip_height) // 2),
POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2),
POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2),
POSITION_BOTTOM_LEFT: (m, H - pip_height - m),
POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m),
POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m),
}
return pos_map.get(layer.position, pos_map[POSITION_BOTTOM_RIGHT])
def _build_pip_pre_filter(
self,
input_label: str,
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
output_label: str,
) -> str:
"""构建单个PiP图层的预处理滤镜链.
处理顺序scale 圆角裁剪可选 边框可选 透明度 动画可选
"""
filters: list[str] = []
# Step 1: scale
filters.append(f"scale={pip_width}:{pip_height}")
filters.append("setsar=1")
# Step 2: 圆角裁剪
if layer.corner_radius > 0:
r = min(layer.corner_radius, pip_width // 2, pip_height // 2)
# 使用 geq + 圆形遮罩实现圆角
# 更简单的方式:用 rounded 滤镜(FFmpeg 5.0+)或 format + alpha
# 这里用更通用的方式:创建圆角遮罩 + overlay 到透明背景
filters.append(
f"format=yuva420p,"
f"geq="
f"lum='lum(X,Y)':"
f"cb='cb(X,Y)':"
f"cr='cr(X,Y)':"
f"a='if(lt(X,{r})*lt(Y,{r}),"
f"gt(hypot({r}-X,{r}-Y),{r})*0+1,"
f"if(gt(X,W-{r})*lt(Y,{r}),"
f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1,"
f"if(lt(X,{r})*gt(Y,H-{r}),"
f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1,"
f"if(gt(X,W-{r})*gt(Y,H-{r}),"
f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'"
)
# Step 3: 边框
if layer.border_width > 0:
bw = layer.border_width
color = layer.border_color
filters.append(f"pad={pip_width + 2*bw}:{pip_height + 2*bw}:{bw}:{bw}:{color}")
# Step 4: 透明度
if layer.opacity < 1.0:
alpha = layer.opacity
filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}")
# Step 5: 入场出场动画
if layer.animation_in or layer.animation_out:
filters.extend(self._build_animation_filters(layer, pip_width, pip_height))
filter_str = f"[{input_label}]{','.join(filters)}[{output_label}]"
return filter_str
def _build_animation_filters(
self,
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
) -> list[str]:
"""构建入场出场动画滤镜."""
filters: list[str] = []
anim_dur = layer.animation_duration
if layer.animation_in == ANIMATION_FADE:
# 淡入
filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1")
elif layer.animation_in == ANIMATION_SLIDE_LEFT:
# 从左滑入 — 用 overlay 动态x实现,这里先标记位置表达式
pass # slide 动画在 overlay 表达式中处理
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
pass
elif layer.animation_in == ANIMATION_SLIDE_TOP:
pass
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
pass
if layer.animation_out == ANIMATION_FADE:
# 淡出需要知道总时长,这里用表达式
if layer.duration > 0:
start_fade = layer.duration - anim_dur
filters.append(f"fade=t=out:st={max(0, start_fade)}:d={anim_dur}:alpha=1")
return filters
def _build_overlay_expr(
self,
layer: PiPLayerConfig,
base_x: int,
base_y: int,
pip_width: int,
pip_height: int,
) -> tuple[str, str]:
"""构建 overlay 滤镜的 x/y 表达式(支持滑动动画).
Returns:
(x_expr, y_expr) FFmpeg表达式字符串
"""
W = self.output_width
H = self.output_height
anim_dur = layer.animation_duration
x_expr = str(base_x)
y_expr = str(base_y)
# 入场滑入动画
if layer.animation_in == ANIMATION_SLIDE_LEFT:
# 从左侧滑入:x 从 -pip_width 变化到 base_x
x_expr = f"'{base_x}+(X)*0+if(lt(t,{anim_dur}),{-pip_width}+t/{anim_dur}*({base_x}+{pip_width}),{base_x})'"
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
# 从右侧滑入:x 从 W 变化到 base_x
x_expr = f"'{base_x}+if(lt(t,{anim_dur}),{W}-t/{anim_dur}*({W}-{base_x}),{base_x})'"
elif layer.animation_in == ANIMATION_SLIDE_TOP:
y_expr = f"'{base_y}+if(lt(t,{anim_dur}),{-pip_height}+t/{anim_dur}*({base_y}+{pip_height}),{base_y})'"
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
y_expr = f"'{base_y}+if(lt(t,{anim_dur}),{H}-t/{anim_dur}*({H}-{base_y}),{base_y})'"
# 出场滑出动画(需要总时长)
if layer.duration > 0 and anim_dur > 0:
out_start = layer.duration - anim_dur
if layer.animation_out == ANIMATION_SLIDE_LEFT:
x_expr = f"'{base_x}+if(gt(t,{out_start}),{base_x}-(t-{out_start})/{anim_dur}*({base_x}+{pip_width}),{base_x})'"
elif layer.animation_out == ANIMATION_SLIDE_RIGHT:
x_expr = f"'{base_x}+if(gt(t,{out_start}),{base_x}+(t-{out_start})/{anim_dur}*({W}-{base_x}+{pip_width}),{base_x})'"
elif layer.animation_out == ANIMATION_SLIDE_TOP:
y_expr = f"'{base_y}+if(gt(t,{out_start}),{base_y}-(t-{out_start})/{anim_dur}*({base_y}+{pip_height}),{base_y})'"
elif layer.animation_out == ANIMATION_SLIDE_BOTTOM:
y_expr = f"'{base_y}+if(gt(t,{out_start}),{base_y}+(t-{out_start})/{anim_dur}*({H}-{base_y}+{pip_height}),{base_y})'"
return (x_expr, y_expr)
def build_pip_filters(
self,
base_label: str,
pip_sources: list[tuple[str, PiPLayerConfig, Path]],
*,
base_input_idx: int = 0,
) -> tuple[str, list[str], str]:
"""构建完整的画中画滤镜链和输入参数.
Args:
base_label: 底层视频的滤镜标签 "final_video" "v0"不带方括号
pip_sources: [(input_label, layer_config, source_path), ...]
base_input_idx: PiP 素材在整个 FFmpeg 输入中的起始索引
Returns:
(filter_parts, input_args, final_label)
- filter_parts: 滤镜字符串列表 ; 连接后成为 filter_complex
- input_args: 额外的输入参数列表 ["-i", path, "-i", path, ...]
- final_label: 最终合成后的输出标签不带方括号
"""
if not pip_sources:
return [], [], base_label
filter_parts: list[str] = []
input_args: list[str] = []
current_label = base_label
for i, (input_label, layer, path) in enumerate(pip_sources):
# 添加输入
input_args.extend(["-i", str(path)])
# 计算实际大小
pip_w = self._parse_size(layer.width, self.output_width)
if layer.height:
pip_h = self._parse_size(layer.height, self.output_height)
else:
# 按宽度等比例(假设16:9,实际会scale时保持比例)
pip_h = int(pip_w * 9 / 16)
# 实际输入索引 = 起始索引 + 当前偏移
actual_input_idx = base_input_idx + i
# 预处理标签
pre_label = f"pip_pre_{i}"
# 构建预处理滤镜
pre_filter = self._build_pip_pre_filter(
input_label=f"{actual_input_idx}:v",
layer=layer,
pip_width=pip_w,
pip_height=pip_h,
output_label=pre_label,
)
filter_parts.append(pre_filter)
# 计算位置
base_x, base_y = self._parse_position(layer, pip_w, pip_h)
# 构建overlay表达式(支持滑动动画)
x_expr, y_expr = self._build_overlay_expr(layer, base_x, base_y, pip_w, pip_h)
# 时间控制(enable表达式)
enable_expr = ""
if layer.start_time > 0 or layer.duration > 0:
start = layer.start_time
if layer.duration > 0:
end = start + layer.duration
enable_expr = f":enable='between(t,{start},{end})'"
else:
enable_expr = f":enable='gte(t,{start})'"
# 合成标签
combined_label = f"pip_combined_{i}"
# overlay 滤镜
overlay_filter = (
f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]"
)
filter_parts.append(overlay_filter)
current_label = combined_label
return filter_parts, input_args, current_label
def validate_layer_source(
self,
layer: PiPLayerConfig,
asset_path_map: dict[str, Path],
) -> Path | None:
"""验证图层素材是否可用,返回本地路径或None(降级跳过)."""
try:
if layer.source_type == "local_path":
path = Path(layer.source)
if path.exists():
return path
elif layer.source_type == "asset_id":
if layer.source in asset_path_map:
return asset_path_map[layer.source]
elif layer.source_type == "url":
# URL类型由调用者负责下载,这里返回标记
return None # 暂时不支持直接URL
except Exception as e:
logger.warning("PiP素材验证失败: %s", e)
return None
+3 -5
View File
@@ -2,16 +2,14 @@
视频处理核心类
"""
import logging
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import List
import ffmpeg
logger = logging.getLogger(__name__)
@dataclass
class VideoResult:
@@ -133,8 +131,8 @@ class VideoProcessor:
if concat_file is not None:
try:
concat_file.close()
except OSError as close_err:
logger.warning("临时文件关闭失败: %s", close_err)
except Exception:
pass # 忽略关闭时的错误
def generate_thumbnail(
self,
@@ -17,15 +17,15 @@ import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from typing import Any, Callable
from sqlalchemy.orm import Session
from video_processing.oss_helpers import download_asset, upload_to_oss
from video_processing.unified_render_service import UnifiedRenderService
from video_processing.unified_render_service import RenderResult, UnifiedRenderService
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
logger = logging.getLogger(__name__)
@@ -210,8 +210,8 @@ class RenderAdapter:
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as cleanup_err:
logger.warning("临时目录清理失败: path=%s error=%s", temp_dir, cleanup_err)
except Exception:
pass
def validate_plan(self, plan_id: str) -> tuple[bool, list[str], list[str], int, int]:
"""校验计划是否可渲染(兼容 VideoComposeService.validate_compose 接口)。
@@ -1,537 +0,0 @@
"""音频混音模块 — 从 unified_render_service.py 拆分.
职责
- 主图层音频 concat 拼接
- 独立音频轨 amix 混音
- 音视频合并mux
所有函数接收 RenderContext 获取共享依赖work_dirplan_id
避免直接依赖 UnifiedRenderService
"""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
# 延迟导入避免循环依赖:unified_render_service 定义 ResolvedClip / RenderLayer
# 本模块提供音频函数供 unified_render_service 调用。
# 使用 from __future__ import annotations + TYPE_CHECKING 解决类型引用。
from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
from video_processing.speed_engine import SpeedEngine
if TYPE_CHECKING:
from video_processing.unified_render_service import RenderLayer, ResolvedClip
logger = logging.getLogger(__name__)
@dataclass
class RenderContext:
"""渲染上下文 — 提供音频混音所需的共享依赖."""
work_dir: Path
plan_id: str
# 音频降噪配置(全局,对最终混音结果应用)
noise_reduction_config: dict | None = None
# 音频探测缓存(避免同一 clip 被多次 ffprobe
_audio_cache: dict[str, bool] = field(default_factory=dict)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长.
UnifiedRenderService._clip_effective_duration 逻辑一致
"""
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
"""探测 clip 是否有音频流(带缓存).
避免同一个 clip 被多次 ffprobe 探测
"""
key = str(clip.local_path)
if key not in ctx._audio_cache:
ctx._audio_cache[key] = probe_has_audio(clip.local_path)
return ctx._audio_cache[key]
# ── 音频混音 ──────────────────────────────────────────────────────────────────
def mix_audio(
ctx: RenderContext,
layers: list[RenderLayer],
video_duration: float,
*,
bgm_path: str | None = None,
bgm_config: dict | None = None,
) -> Path | None:
"""音频后处理混音.
处理逻辑
1. 主音频源按优先级查找main > brollbackground 不参与主音频通常是图片无音轨
2. 主图层音频按顺序 concat 拼接
3. 独立音频轨audio role amix 混入
4. 输出时长截断到 video_duration
5. 无音频流的 clip 会被自动跳过避免 FFmpeg 引用 [i:a] 失败
6. 如果提供了 bgm_path则额外混入 BGM支持淡入淡出循环人声闪避
Args:
ctx: 渲染上下文
layers: 图层列表
video_duration: 视频总时长用于截断音频
bgm_path: BGM 音频本地路径 None 时不混入 BGM
bgm_config: BGM 配置字典volume/fade_in/fade_out/sidechain
Returns:
混音后的音频文件路径无音频时返回 None
"""
# 按优先级精确查找主音频图层:main > broll
# background 不参与主音频(通常是静态图片,无音轨)
layer_map = {layer.role: layer for layer in layers}
main_layer = None
for role in ("main", "broll"):
if role in layer_map and layer_map[role].clips:
main_layer = layer_map[role]
break
main_clips: list[ResolvedClip] = main_layer.clips if main_layer else []
# 没有主视频图层时兜底:检查 overlay/corner_voice 层是否有带音频的素材
if not main_clips:
for role in ("overlay", "corner_voice"):
if role in layer_map and layer_map[role].clips:
main_clips = layer_map[role].clips
break
# 收集独立音频轨
audio_clips: list[ResolvedClip] = []
if "audio" in layer_map:
audio_clips = layer_map["audio"].clips
# ── 防御:过滤掉无音频流的 clip ──
main_clips = [c for c in main_clips if clip_has_audio(ctx, c)]
audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)]
if not main_clips and not audio_clips:
# 没有主音频也没有独立音频 → 检查是否有 BGM
if bgm_path and bgm_config and bgm_config.get("enabled", False):
from video_processing.bgm_mixer import BGMConfig, build_bgm_only
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
try:
return build_bgm_only(ctx, bgm_cfg, video_duration)
except Exception:
logger.exception("[bgm] 纯BGM生成失败: plan_id=%s", ctx.plan_id)
return None
# 构建音频处理命令
output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac"
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
if main_clips and not audio_clips:
concat_main_audio(ctx, main_clips, output_path, video_duration)
else:
# 有独立音频轨 → amix 混音
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
# ── BGM 混音 ──
if bgm_path and bgm_config and bgm_config.get("enabled", False):
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
bgm_output = ctx.work_dir / f"audio_with_bgm_{ctx.plan_id}.aac"
try:
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
final_path = mix_bgm_with_main(ctx, output_path, bgm_cfg, video_duration)
return _apply_noise_reduction_if_needed(ctx, final_path)
except Exception:
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
return _apply_noise_reduction_if_needed(ctx, output_path)
return _apply_noise_reduction_if_needed(ctx, output_path)
def _apply_noise_reduction_if_needed(ctx: RenderContext, audio_path: Path) -> Path:
"""如果配置了音频降噪,对已生成的音频文件应用降噪。
作为后处理步骤对最终混音结果统一降噪
失败时返回原始文件路径不阻断主流程
"""
if not ctx.noise_reduction_config:
return audio_path
try:
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
config = NoiseReductionConfig.from_dict(ctx.noise_reduction_config)
if not config.has_effect():
return audio_path
engine = NoiseReductionEngine(config)
filter_str = engine.build_filter("[0:a]", "[out]")
# 提取滤镜部分(不带标签)
filter_part = filter_str[len("[0:a]") : -len("[out]")]
nr_output_path = audio_path.with_name(f"{audio_path.stem}_nr.aac")
command = [
FFMPEG_BIN,
"-y",
"-i",
str(audio_path),
"-af",
filter_part,
"-acodec",
"aac",
"-b:a",
"128k",
str(nr_output_path),
]
run_ffmpeg(command)
if nr_output_path.exists():
return nr_output_path
logger.warning("[noise-reduction] 降噪输出文件不存在,使用原始音频")
return audio_path
except Exception as e:
logger.warning("[noise-reduction] 音频降噪失败,使用原始音频: %s", e)
return audio_path
def concat_main_audio(
ctx: RenderContext,
clips: list[ResolvedClip],
output_path: Path,
video_duration: float,
) -> None:
"""主图层音频 concat 拼接(对齐链路A行为).
每个 clip 提取音频 trim 按顺序 concat
"""
if len(clips) == 1:
# 单 clip,直接提取音频,截断到 min(clip有效时长, 视频总时长)
clip = clips[0]
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
# 调速后时长
adjusted_duration = effective_duration / speed if abs(speed - 1.0) >= 1e-6 else effective_duration
# 最终时长:取调速后时长和视频总时长的较小值
final_duration = adjusted_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
has_reverse = reverse_config.enabled and reverse_config.reverse_audio
has_speed = abs(speed - 1.0) >= 1e-6
if not has_speed and not has_reverse:
# 无调速无倒放:简单命令行,-ss 裁剪更高效
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
]
if trim_start > 0:
command.extend(["-ss", f"{trim_start:.3f}"])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
else:
# 有调速或倒放:用 filter_complex
speed_engine = SpeedEngine()
audio_filters = []
if effective_duration > 0:
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频调速
if has_speed:
from video_processing.speed_engine import SpeedConfig
config = SpeedConfig(speed=float(speed))
config.clamp()
atempo_filter = speed_engine.build_audio_filter(config)
if atempo_filter:
audio_filters.append(atempo_filter)
# 音频倒放
if has_reverse:
reverse_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
if reverse_filter:
audio_filters.append(reverse_filter)
filter_parts: list[str] = [f"[0:a]{','.join(audio_filters)}[outa]"]
if video_duration > 0 and final_duration < adjusted_duration:
filter_parts.append(f"[outa]atrim=0:{final_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "outa"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
run_ffmpeg(command)
return
# 多 clip,用 filter_complex concat
input_args: list[str] = []
filter_parts: list[str] = []
speed_engine = SpeedEngine()
for i, clip in enumerate(clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
audio_filters: list[str] = []
if effective_duration > 0:
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频调速 — atempo 多级串联
if abs(speed - 1.0) >= 1e-6:
from video_processing.speed_engine import SpeedConfig
config = SpeedConfig(speed=float(speed))
config.clamp()
atempo_filter = speed_engine.build_audio_filter(config)
if atempo_filter:
audio_filters.append(atempo_filter)
else:
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
if reverse_config.enabled and reverse_config.reverse_audio:
reverse_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
if reverse_filter:
audio_filters.append(reverse_filter)
filter_parts.append(f"[{i}:a]{','.join(audio_filters)}[a{i}]")
audio_labels = "".join(f"[a{i}]" for i in range(len(clips)))
filter_parts.append(f"{audio_labels}concat=n={len(clips)}:v=0:a=1[outa]")
# 截断到视频总时长
if video_duration > 0:
filter_parts.append(f"[outa]atrim=0:{video_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "outa"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
run_ffmpeg(command)
def mix_with_independent_audio(
ctx: RenderContext,
main_clips: list[ResolvedClip],
audio_clips: list[ResolvedClip],
output_path: Path,
video_duration: float,
) -> None:
"""主音频 + 独立音频轨 amix 混音.
Args:
ctx: 渲染上下文
main_clips: 主视频 clips提取音频后 concat
audio_clips: 独立音频轨 clips
output_path: 输出路径
video_duration: 视频总时长
"""
input_args: list[str] = []
filter_parts: list[str] = []
mix_labels: list[str] = []
input_idx = 0
# 1. 主图层音频 concat
if main_clips:
for clip in main_clips:
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
if effective_duration > 0:
filter_parts.append(
f"[{input_idx}:a]atrim=start={trim_start:.3f}:duration={effective_duration:.3f},"
f"asetpts=PTS-STARTPTS[ma{input_idx}]"
)
else:
filter_parts.append(f"[{input_idx}:a]asetpts=PTS-STARTPTS[ma{input_idx}]")
input_idx += 1
if len(main_clips) == 1:
mix_labels.append("ma0")
else:
main_labels = "".join(f"[ma{i}]" for i in range(len(main_clips)))
filter_parts.append(f"{main_labels}concat=n={len(main_clips)}:v=0:a=1[main_audio]")
mix_labels.append("main_audio")
# 2. 独立音频轨
for j, clip in enumerate(audio_clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
volume = clip.config.get("volume", 1.0) if clip.config else 1.0
label = f"ia{j}"
filters = []
if effective_duration > 0:
filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
filters.append("asetpts=PTS-STARTPTS")
if volume != 1.0:
filters.append(f"volume={volume}")
filter_parts.append(f"[{input_idx}:a]{','.join(filters)}[{label}]")
mix_labels.append(label)
input_idx += 1
# 3. amix 混音
mix_inputs = "".join(f"[{label}]" for label in mix_labels)
n_inputs = len(mix_labels)
# normalized=0 保持音量,duration=shortest 取最短
filter_parts.append(f"{mix_inputs}amix=inputs={n_inputs}:duration=longest:normalize=0[mixed_audio]")
# 4. 截断到视频时长
if video_duration > 0:
filter_parts.append(f"[mixed_audio]atrim=0:{video_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "mixed_audio"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"音频混音: plan_id=%s main_clips=%d audio_clips=%d",
ctx.plan_id,
len(main_clips),
len(audio_clips),
)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"音频混音失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s",
ctx.plan_id,
e.returncode,
filter_complex[:3000],
)
raise
def merge_audio_video(
ctx: RenderContext,
video_path: Path,
audio_path: Path,
output_path: Path,
) -> None:
"""将音频合并到视频中(视频流拷贝,音频直接复用).
Args:
ctx: 渲染上下文
video_path: 无声视频路径
audio_path: 音频文件路径
output_path: 输出文件路径
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
str(video_path),
"-i",
str(audio_path),
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-shortest",
"-movflags",
"+faststart",
str(output_path),
]
logger.info("合并音视频: plan_id=%s", ctx.plan_id)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"合并音视频失败: plan_id=%s exit_code=%d",
ctx.plan_id,
e.returncode,
)
raise
@@ -1,255 +0,0 @@
"""ASS 字幕生成模块 — 从 unified_render_service.py 拆分.
职责
- title / subtitle 配置转换为 ASS 字幕文件
- 提供样式计算颜色对齐描边/阴影
- UnifiedRenderService._maybe_generate_ass 调用
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# Title/Subtitle 默认边距(像素)
TITLE_MARGIN_TOP = 60
TITLE_MARGIN_BOTTOM = 60
TITLE_MARGIN_SIDE = 40
# ── ASS 字幕工具 ─────────────────────────────────────────────────────────────
def _hex_to_ass_color(hex_color: str) -> str:
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。"""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H000000"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{b.upper()}{g.upper()}{r.upper()}"
def _position_to_ass_alignment(position: str) -> int:
"""将文字位置映射为 ASS \\an 对齐编号。
ASS 对齐编号数字小键盘布局
7 8 9
4 5 6
1 2 3
"""
mapping = {
"top": 8, # 顶部居中
"center": 5, # 居中
"bottom": 2, # 底部居中
}
return mapping.get(position, 8)
def _build_ass_style(
style_name: str,
*,
font_name: str = "思源黑体",
font_size: int = 48,
primary_color: str = "&H00FFFFFF",
outline_color: str = "&H00000000",
outline_width: float = 1.0,
shadow_blur: float = 0.0,
shadow_offset: tuple[int, int] = (0, 0),
bold: bool = False,
italic: bool = False,
alignment: int = 8,
margin_v: int = 60,
margin_l: int = 40,
margin_r: int = 40,
) -> str:
"""构建 ASS Style 行。
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
"""
bold_val = -1 if bold else 0
italic_val = -1 if italic else 0
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow
back_color = primary_color # 阴影颜色默认同文字色(带透明度由阴影模糊控制)
# Shadow 值:ASS 中 Shadow 字段是阴影偏移距离(像素),
# 我们用 shadow_offset[1] 作为纵向偏移,模糊由 BorderStyle=3 实现
# 简化:BorderStyle=1outline + drop shadow),Shadow 字段表示阴影深度
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
return (
f"Style: {style_name},{font_name},{font_size},{primary_color},"
f"&H000000FF,{outline_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"1,{outline_width},{shadow_depth},{alignment},"
f"{margin_l},{margin_r},{margin_v},1"
)
def _escape_ass_text(text: str) -> str:
r"""转义 ASS 文本中的特殊字符。
ASS 中换行用 \N硬换行 \n软换行
大括号 {} 用于覆盖样式需要转义
"""
# 将实际换行转为 ASS 硬换行
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
# 转义大括号(ASS 用它做样式覆盖标签)
text = text.replace("{", "(").replace("}", ")")
return text
def _format_ass_time(seconds: float) -> str:
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc。"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def generate_ass_subtitles(
output_path: Path,
*,
video_width: int,
video_height: int,
video_duration: float,
title_text: str = "",
title_config: dict[str, Any] | None = None,
subtitle_text: str = "",
subtitle_config: dict[str, Any] | None = None,
) -> Path:
"""生成 ASS 字幕文件。
支持 Title标题 Subtitle字幕两种字幕类型
各自可独立配置样式位置和内容
Args:
output_path: 输出 ASS 文件路径
video_width: 视频宽度用于 ASS PlayResX
video_height: 视频高度用于 ASS PlayResY
video_duration: 视频总时长字幕显示整个时长
title_text: 标题文本
title_config: 标题样式配置TitleConfig dict
subtitle_text: 字幕文本
subtitle_config: 字幕样式配置SubtitleConfig dict
Returns:
生成的 ASS 文件路径
"""
title_config = title_config or {}
subtitle_config = subtitle_config or {}
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
if not title_enabled and not subtitle_enabled:
# 没有字幕,生成空文件(仍返回路径,调用方自行判断是否使用)
output_path.write_text("", encoding="utf-8")
return output_path
styles: list[str] = []
events: list[str] = []
# ── Title 样式与事件 ──────────────────────────────────────────────────
if title_enabled:
title_color = _hex_to_ass_color(title_config.get("color", "#ffffff"))
title_stroke = title_config.get("stroke", {}) or {}
title_shadow = title_config.get("shadow", {}) or {}
stroke_color = _hex_to_ass_color(title_stroke.get("color", "#000000"))
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
shadow_offset = (
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
)
title_alignment = _position_to_ass_alignment(title_config.get("position", "top"))
styles.append(
_build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 48)),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
shadow_blur=shadow_blur,
shadow_offset=shadow_offset,
bold=bool(title_config.get("bold", True)),
italic=bool(title_config.get("italic", False)),
alignment=title_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
# 转义 ASS 特殊字符
safe_title_text = _escape_ass_text(title_text)
events.append(
"Dialogue: 0,0:00:00.00," f"{_format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
)
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
if subtitle_enabled:
sub_color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
sub_alignment = _position_to_ass_alignment(subtitle_config.get("position", "bottom"))
styles.append(
_build_ass_style(
"SubtitleStyle",
font_name=subtitle_config.get("font", "思源黑体"),
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
shadow_blur=0.0,
shadow_offset=(0, 0),
bold=False,
italic=False,
alignment=sub_alignment,
margin_v=TITLE_MARGIN_BOTTOM,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
safe_subtitle_text = _escape_ass_text(subtitle_text)
events.append(
"Dialogue: 0,0:00:00.00,"
f"{_format_ass_time(video_duration)},"
"SubtitleStyle,,0,0,0,,"
f"{safe_subtitle_text}"
)
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
PlayResY: {video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(styles)}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(ass_content, encoding="utf-8")
return output_path
@@ -1,116 +0,0 @@
"""视频倒放引擎 — 基于 FFmpeg reverse + areverse 滤镜实现视频/音频倒放.
支持能力
- 视频倒放reverse 滤镜
- 音频倒放areverse 滤镜
- clip 分段倒放每个 clip 独立配置
- 降级策略不支持时跳过不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ReverseConfig:
"""视频倒放配置.
clip.config.reverse 读取零侵入数据模型.
"""
enabled: bool = False
reverse_video: bool = True # 是否倒放视频
reverse_audio: bool = True # 是否倒放音频
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ReverseConfig":
"""从字典解析配置."""
if not data:
return cls(enabled=False)
try:
if not data.get("enabled", False):
return cls(enabled=False)
return cls(
enabled=True,
reverse_video=bool(data.get("reverse_video", True)),
reverse_audio=bool(data.get("reverse_audio", True)),
)
except (AttributeError, TypeError) as e:
logger.warning("倒放配置解析失败: %s,使用默认配置", e)
return cls(enabled=False)
# ── 倒放引擎 ──────────────────────────────────────────────────────────────────
class ReverseEngine:
"""视频倒放引擎 — 生成 FFmpeg 倒放滤镜.
视频倒放reverse 滤镜
音频倒放areverse 滤镜
注意事项
- reverse 滤镜需要将整个视频帧加载到内存长视频可能占用大量内存
- 建议对单 clip 时长做限制 < 60s超长视频建议降级
"""
# 安全限制:单 clip 超过此时长不启用倒放(防止内存溢出)
MAX_SAFE_DURATION = 120.0 # 秒
@staticmethod
def build_video_filter(config: ReverseConfig, duration: float = 0.0) -> str:
"""构建视频倒放滤镜字符串.
Args:
config: 倒放配置
duration: clip 时长用于安全检查
Returns:
FFmpeg 滤镜字符串 "reverse"无效果返回空字符串
"""
if not config.enabled or not config.reverse_video:
return ""
# 安全检查:超长视频不启用倒放
if duration > ReverseEngine.MAX_SAFE_DURATION:
logger.warning(
"视频倒放安全限制:clip 时长 %.1fs 超过上限 %.1fs,跳过倒放",
duration,
ReverseEngine.MAX_SAFE_DURATION,
)
return ""
return "reverse"
@staticmethod
def build_audio_filter(config: ReverseConfig, duration: float = 0.0) -> str:
"""构建音频倒放滤镜字符串.
Args:
config: 倒放配置
duration: clip 时长用于安全检查
Returns:
FFmpeg 音频滤镜字符串 "areverse"无效果返回空字符串
"""
if not config.enabled or not config.reverse_audio:
return ""
# 安全检查:超长音频不启用倒放
if duration > ReverseEngine.MAX_SAFE_DURATION:
logger.warning(
"音频倒放安全限制:clip 时长 %.1fs 超过上限 %.1fs,跳过倒放",
duration,
ReverseEngine.MAX_SAFE_DURATION,
)
return ""
return "areverse"
@@ -1,167 +0,0 @@
"""视频调速引擎 — 基于 FFmpeg setpts + atempo 的速度调整能力。
支持
- 0.25x ~ 4x 变速范围
- 视频调速setpts
- 音频调速atempo多级串联处理超范围值
- 音调修正pitch_correct默认开启
- 边界自动钳制不阻断渲染
"""
from dataclasses import dataclass
from typing import Optional
# ─── 常量 ───────────────────────────────────────────────
MIN_SPEED = 0.25
MAX_SPEED = 4.0
DEFAULT_SPEED = 1.0
# atempo 单级有效范围
_ATEMPO_MIN = 0.5
_ATEMPO_MAX = 2.0
@dataclass
class SpeedConfig:
"""调速配置。
Attributes:
speed: 播放速度0.25~4.01.0 为原速
pitch_correct: 是否保持音调默认 True atempo 时间拉伸算法
"""
speed: float = DEFAULT_SPEED
pitch_correct: bool = True
@classmethod
def parse(cls, data: Optional[dict]) -> "SpeedConfig":
"""从 dict 解析配置,无效值回退到默认。"""
if not data or not isinstance(data, dict):
return cls()
speed = data.get("speed", DEFAULT_SPEED)
if not isinstance(speed, (int, float)):
speed = DEFAULT_SPEED
pitch_correct = data.get("pitch_correct", True)
if not isinstance(pitch_correct, bool):
pitch_correct = True
config = cls(speed=float(speed), pitch_correct=pitch_correct)
config.clamp()
return config
def clamp(self) -> None:
"""将速度钳制到合法范围。"""
if self.speed <= 0:
self.speed = DEFAULT_SPEED
elif self.speed < MIN_SPEED:
self.speed = MIN_SPEED
elif self.speed > MAX_SPEED:
self.speed = MAX_SPEED
@property
def is_original(self) -> bool:
"""是否原速(无需调速)。"""
return abs(self.speed - 1.0) < 1e-6
class SpeedEngine:
"""调速引擎 — 生成 FFmpeg 调速滤镜链。
用法
engine = SpeedEngine()
video_filter = engine.build_video_filter(config)
audio_filter = engine.build_audio_filter(config)
new_duration = engine.adjust_duration(duration, config)
"""
def build_video_filter(self, config: SpeedConfig) -> str:
"""生成视频调速滤镜字符串。
返回 setpts 滤镜表达式原速时返回空字符串
"""
if config.is_original:
return ""
# setpts=PTS/speed — speed>1 加速,speed<1 减速
return f"setpts=PTS/{config.speed:.4f}"
def build_audio_filter(self, config: SpeedConfig) -> str:
"""生成音频调速滤镜字符串。
atempo 单级范围 0.5~2.0超出范围时自动多级串联
- 0.25x atempo=0.5,atempo=0.5
- 4x atempo=2.0,atempo=2.0
- 0.3x atempo=0.5,atempo=0.6
- 3x atempo=2.0,atempo=1.5
原速时返回空字符串
"""
if config.is_original:
return ""
speed = config.speed
stages: list[float] = self._split_atempo_stages(speed)
return ",".join(f"atempo={s:.4f}" for s in stages)
@staticmethod
def _split_atempo_stages(speed: float) -> list[float]:
"""将速度拆分为多级 atempo 串联,每级都在 [0.5, 2.0] 范围内。"""
if _ATEMPO_MIN <= speed <= _ATEMPO_MAX:
return [speed]
stages: list[float] = []
remaining = speed
# 加速场景(speed > 2.0
if speed > _ATEMPO_MAX:
while remaining > _ATEMPO_MAX:
stages.append(_ATEMPO_MAX)
remaining /= _ATEMPO_MAX
stages.append(remaining)
# 减速场景(speed < 0.5
else:
while remaining < _ATEMPO_MIN:
stages.append(_ATEMPO_MIN)
remaining /= _ATEMPO_MIN
stages.append(remaining)
return stages
def adjust_duration(self, original_duration: float, config: SpeedConfig) -> float:
"""计算调速后的时长。
加速 时长变短减速 时长变长
"""
if config.is_original or original_duration <= 0:
return original_duration
return original_duration / config.speed
def build_clip_speed_filter(
self,
speed: float,
pitch_correct: bool = True,
) -> tuple[str, str, SpeedConfig]:
"""便捷方法:从单一 speed 值生成视频+音频滤镜。
返回 (video_filter, audio_filter, config)
"""
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
config.clamp()
return (
self.build_video_filter(config),
self.build_audio_filter(config),
config,
)
@staticmethod
def resolve_clip_speed(
clip_config: dict,
global_speed: float = DEFAULT_SPEED,
) -> float:
"""从 clip config 中解析 playback_speed0 或缺失则使用全局速度。"""
speed = clip_config.get("playback_speed", 0) if clip_config else 0
if not isinstance(speed, (int, float)) or speed <= 0:
return global_speed
return float(speed)
@@ -1,574 +0,0 @@
"""贴纸叠加引擎 — 基于 FFmpeg overlay + drawtext 实现图片/文字贴纸.
支持能力
- 图片贴纸PNG/GIF位置大小透明度时间范围淡入淡出
- 文字贴纸花字字体颜色描边阴影位置时间范围动画
- 9宫格位置 + 自由坐标像素或百分比
- 多贴纸叠加 z_index 排序
- 降级策略素材不存在/无效时自动跳过不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 预设贴纸分类 ──────────────────────────────────────────────────────────────
# 预设贴纸分类(仅用于前端展示,后端不依赖具体素材)
STICKER_CATEGORIES = [
("emoji", "表情包"),
("text", "文字花字"),
("decoration", "装饰"),
("arrow", "箭头指示"),
("frame", "边框"),
]
# 9宫格位置映射
POSITION_PRESETS = {
"top_left": (0.05, 0.05),
"top_center": (0.5, 0.05),
"top_right": (0.95, 0.05),
"center_left": (0.05, 0.5),
"center": (0.5, 0.5),
"center_right": (0.95, 0.5),
"bottom_left": (0.05, 0.95),
"bottom_center": (0.5, 0.95),
"bottom_right": (0.95, 0.95),
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ImageStickerConfig:
"""图片贴纸配置."""
enabled: bool = False
type: str = "image" # image / text
# 位置
position: str = "top_right" # 9宫格预设
x: float | None = None # 自定义x(像素或百分比)
y: float | None = None # 自定义y
x_unit: str = "percent" # pixel / percent
y_unit: str = "percent"
# 大小
scale: float = 1.0 # 缩放比例(相对于原始大小)
width: int | None = None # 指定宽度(像素)
height: int | None = None # 指定高度(像素)
# 透明度
opacity: float = 1.0 # 0.0~1.0
# 时间范围
start_time: float = 0.0
duration: float = 0.0 # 0 表示持续到结束
# 动画
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长
# 层级
z_index: int = 10
# 素材
image_url: str = "" # 图片URL或本地路径
preset_id: str = "" # 预设贴纸ID
@dataclass
class TextStickerConfig:
"""文字贴纸配置."""
enabled: bool = False
type: str = "text"
text: str = ""
# 字体
font_size: int = 36
font_color: str = "#FFFFFF"
font_family: str = "sans"
# 描边
stroke_color: str = "#000000"
stroke_width: int = 2
# 阴影
shadow_color: str = "#000000"
shadow_x: int = 2
shadow_y: int = 2
shadow_alpha: float = 0.5
# 位置
position: str = "center"
x: float | None = None
y: float | None = None
x_unit: str = "percent"
y_unit: str = "percent"
# 时间范围
start_time: float = 0.0
duration: float = 0.0
# 动画
fade_in: float = 0.0
fade_out: float = 0.0
# 层级
z_index: int = 10
# 背景框
bg_color: str = "" # 空表示无背景
bg_padding: int = 8
bg_alpha: float = 0.8
bg_corner_radius: int = 8
@dataclass
class StickerOverlayResult:
"""贴纸叠加结果."""
filter_str: str # 滤镜字符串
output_label: str # 输出标签
extra_inputs: list[str] = field(default_factory=list) # 额外的输入文件路径
# ── 贴纸引擎 ──────────────────────────────────────────────────────────────────
class StickerEngine:
"""贴纸叠加引擎 — 生成 FFmpeg overlay / drawtext 滤镜链.
支持图片贴纸overlay和文字贴纸drawtext
多贴纸按 z_index 排序依次叠加
"""
@staticmethod
def _resolve_position(
config: ImageStickerConfig | TextStickerConfig,
canvas_w: int,
canvas_h: int,
sticker_w: int = 0,
sticker_h: int = 0,
) -> tuple[float, float]:
"""解析贴纸位置(像素坐标).
优先级自定义坐标 > 9宫格预设
"""
# 先取预设的基准位置
if config.position in POSITION_PRESETS:
px, py = POSITION_PRESETS[config.position]
else:
px, py = 0.5, 0.5 # 默认居中
# 自定义坐标覆盖
if config.x is not None:
if config.x_unit == "percent":
px = config.x / 100.0
else:
px = config.x / canvas_w if canvas_w > 0 else 0.5
if config.y is not None:
if config.y_unit == "percent":
py = config.y / 100.0
else:
py = config.y / canvas_h if canvas_h > 0 else 0.5
# 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点)
x = px * canvas_w - sticker_w / 2
y = py * canvas_h - sticker_h / 2
# 钳制在画布内
x = max(0, min(x, canvas_w - sticker_w))
y = max(0, min(y, canvas_h - sticker_h))
return x, y
@staticmethod
def _build_overlay_filter(
sticker: ImageStickerConfig,
sticker_idx: int,
input_label: str,
output_label: str,
canvas_w: int,
canvas_h: int,
) -> str:
"""构建单个图片贴纸的 overlay 滤镜.
Args:
sticker: 贴纸配置
sticker_idx: 贴纸索引用于生成滤镜标签
input_label: 输入视频标签 "[base]"
output_label: 输出视频标签
canvas_w: 画布宽度
canvas_h: 画布高度
Returns:
FFmpeg 滤镜字符串
"""
sticker_label = f"sticker_{sticker_idx}_scaled"
# 1. 贴纸缩放预处理
scale_parts = []
if sticker.width and sticker.height:
scale_parts.append(f"scale={sticker.width}:{sticker.height}")
elif sticker.scale != 1.0:
# 按比例缩放
scale_parts.append(f"scale=iw*{sticker.scale}:ih*{sticker.scale}")
# 透明度调整
if sticker.opacity < 1.0:
scale_parts.append(f"colorchannelmixer=aa={sticker.opacity}")
# 淡入淡出
fade_parts = []
if sticker.fade_in > 0:
fade_parts.append(f"fade=in:st={sticker.start_time}:d={sticker.fade_in}:alpha=1")
if sticker.fade_out > 0 and sticker.duration > 0:
fade_out_start = sticker.start_time + sticker.duration - sticker.fade_out
fade_parts.append(f"fade=out:st={max(0, fade_out_start)}:d={sticker.fade_out}:alpha=1")
pre_filters = scale_parts + fade_parts
# 2. overlay 位置
# 先估算贴纸尺寸(假设原始尺寸 ~ canvas_w * 0.3
est_w = int(canvas_w * 0.3 * sticker.scale) if not sticker.width else sticker.width
est_h = int(canvas_h * 0.3 * sticker.scale) if not sticker.height else sticker.height
pos_x, pos_y = StickerEngine._resolve_position(sticker, canvas_w, canvas_h, est_w, est_h)
# 3. enable 表达式(时间范围)
enable_expr = ""
if sticker.duration > 0:
enable_expr = f":enable='between(t,{sticker.start_time},{sticker.start_time + sticker.duration})'"
# 组合滤镜
filter_parts: list[str] = []
# 贴纸预处理
if pre_filters:
filter_parts.append(f"[{sticker_idx + 1}:v]{','.join(pre_filters)}[{sticker_label}]")
sticker_source = f"[{sticker_label}]"
else:
sticker_source = f"[{sticker_idx + 1}:v]"
# overlay 合成
filter_parts.append(f"{input_label}{sticker_source}overlay={pos_x:.0f}:{pos_y:.0f}{enable_expr}{output_label}")
return ";".join(filter_parts)
@staticmethod
def _build_drawtext_filter(
sticker: TextStickerConfig,
input_label: str,
output_label: str,
canvas_w: int,
canvas_h: int,
) -> str:
"""构建单个文字贴纸的 drawtext 滤镜.
Args:
sticker: 文字贴纸配置
input_label: 输入视频标签
output_label: 输出视频标签
canvas_w: 画布宽度
canvas_h: 画布高度
Returns:
FFmpeg 滤镜字符串
"""
if not sticker.text:
return f"{input_label}copy{output_label}"
# 估算文字尺寸(粗略)
est_w = len(sticker.text) * sticker.font_size * 0.6
est_h = sticker.font_size * 1.4
pos_x, pos_y = StickerEngine._resolve_position(sticker, canvas_w, canvas_h, int(est_w), int(est_h))
drawtext_params: list[str] = []
# 文字内容(转义特殊字符)
escaped_text = sticker.text.replace(":", "\\:").replace("'", "\\'")
drawtext_params.append(f"text='{escaped_text}'")
# 字体
drawtext_params.append(f"fontsize={sticker.font_size}")
drawtext_params.append(f"fontcolor={sticker.font_color}")
# 描边
if sticker.stroke_width > 0:
drawtext_params.append(f"borderw={sticker.stroke_width}")
drawtext_params.append(f"bordercolor={sticker.stroke_color}")
# 阴影
if sticker.shadow_alpha > 0:
drawtext_params.append(f"shadowx={sticker.shadow_x}")
drawtext_params.append(f"shadowy={sticker.shadow_y}")
drawtext_params.append(f"shadowcolor={sticker.shadow_color}@{sticker.shadow_alpha}")
# 位置
drawtext_params.append(f"x={pos_x:.0f}")
drawtext_params.append(f"y={pos_y:.0f}")
# 时间范围
if sticker.duration > 0:
drawtext_params.append(f"enable='between(t,{sticker.start_time},{sticker.start_time + sticker.duration})'")
# 淡入淡出(drawtext 没有直接的淡入淡出,用 alpha 表达式模拟)
if sticker.fade_in > 0 or sticker.fade_out > 0:
alpha_expr = "1"
parts: list[str] = []
if sticker.fade_in > 0:
parts.append(
f"if(lt(t,{sticker.start_time + sticker.fade_in})," f"(t-{sticker.start_time})/{sticker.fade_in},1)"
)
if sticker.fade_out > 0 and sticker.duration > 0:
fade_out_start = sticker.start_time + sticker.duration - sticker.fade_out
parts.append(
f"if(gt(t,{fade_out_start})," f"({sticker.start_time + sticker.duration}-t)/{sticker.fade_out},1)"
)
if parts:
alpha_expr = "*".join(parts)
drawtext_params.append(f"alpha='{alpha_expr}'")
filter_str = f"{input_label}drawtext={':'.join(drawtext_params)}{output_label}"
return filter_str
@classmethod
def build_sticker_chain(
cls,
stickers: list[dict[str, Any]],
input_label: str,
output_label: str,
canvas_w: int,
canvas_h: int,
) -> StickerOverlayResult:
"""构建多贴纸叠加滤镜链.
Args:
stickers: 贴纸配置列表
input_label: 初始输入标签
output_label: 最终输出标签
canvas_w: 画布宽度
canvas_h: 画布高度
Returns:
StickerOverlayResult包含滤镜字符串输出标签额外输入
"""
if not stickers:
return StickerOverlayResult(
filter_str=f"{input_label}copy{output_label}",
output_label=output_label,
extra_inputs=[],
)
# 解析配置
parsed_stickers: list[tuple[int, ImageStickerConfig | TextStickerConfig]] = []
image_stickers: list[ImageStickerConfig] = []
image_paths: list[str] = []
for i, s in enumerate(stickers):
try:
sticker_type = s.get("type", "image")
z = int(s.get("z_index", 10))
if sticker_type == "text":
config = TextStickerConfig(
enabled=True,
text=str(s.get("text", "")),
font_size=int(s.get("font_size", 36)),
font_color=str(s.get("font_color", "#FFFFFF")),
stroke_color=str(s.get("stroke_color", "#000000")),
stroke_width=int(s.get("stroke_width", 2)),
shadow_x=int(s.get("shadow_x", 2)),
shadow_y=int(s.get("shadow_y", 2)),
shadow_alpha=float(s.get("shadow_alpha", 0.5)),
position=str(s.get("position", "center")),
x=cls._safe_float(s.get("x")),
y=cls._safe_float(s.get("y")),
x_unit=str(s.get("x_unit", "percent")),
y_unit=str(s.get("y_unit", "percent")),
start_time=float(s.get("start_time", 0)),
duration=float(s.get("duration", 0)),
fade_in=float(s.get("fade_in", 0)),
fade_out=float(s.get("fade_out", 0)),
z_index=z,
bg_color=str(s.get("bg_color", "")),
bg_padding=int(s.get("bg_padding", 8)),
bg_alpha=float(s.get("bg_alpha", 0.8)),
bg_corner_radius=int(s.get("bg_corner_radius", 8)),
)
parsed_stickers.append((z, config))
else:
# 图片贴纸
image_path = s.get("image_path", "") or s.get("image_url", "")
if not image_path or not Path(image_path).exists():
logger.warning("贴纸素材不存在,跳过: %s", image_path)
continue
config = ImageStickerConfig(
enabled=True,
position=str(s.get("position", "top_right")),
x=cls._safe_float(s.get("x")),
y=cls._safe_float(s.get("y")),
x_unit=str(s.get("x_unit", "percent")),
y_unit=str(s.get("y_unit", "percent")),
scale=float(s.get("scale", 1.0)),
width=int(s["width"]) if s.get("width") else None,
height=int(s["height"]) if s.get("height") else None,
opacity=max(0.0, min(1.0, float(s.get("opacity", 1.0)))),
start_time=float(s.get("start_time", 0)),
duration=float(s.get("duration", 0)),
fade_in=float(s.get("fade_in", 0)),
fade_out=float(s.get("fade_out", 0)),
z_index=z,
image_url=str(s.get("image_url", "")),
)
parsed_stickers.append((z, config))
image_stickers.append(config)
image_paths.append(image_path)
except Exception as e:
logger.warning("贴纸配置解析失败,跳过: %s", e)
continue
if not parsed_stickers:
return StickerOverlayResult(
filter_str=f"{input_label}copy{output_label}",
output_label=output_label,
extra_inputs=[],
)
# 按 z_index 排序
parsed_stickers.sort(key=lambda x: x[0])
# 构建滤镜链
filter_parts: list[str] = []
current_label = input_label
img_idx = 0 # 图片贴纸的输入索引偏移
for idx, (_, sticker) in enumerate(parsed_stickers):
next_label = f"sticker_{idx}_out" if idx < len(parsed_stickers) - 1 else output_label
if isinstance(sticker, ImageStickerConfig):
# 图片贴纸:使用额外的输入(输入索引 = 1 + img_idx,0 是主视频)
# 注意:实际输入索引需要调用方根据输入列表确定
# 这里我们按 image_stickers 的顺序分配索引
# 主输入是 [0:v],贴纸输入从 [1:v] 开始
single_filter = cls._build_single_image_sticker(
sticker=sticker,
sticker_input_idx=img_idx + 1, # +1 因为 0 是主视频
input_label=current_label,
output_label=next_label,
canvas_w=canvas_w,
canvas_h=canvas_h,
)
filter_parts.append(single_filter)
img_idx += 1
else:
# 文字贴纸:drawtext,不需要额外输入
single_filter = cls._build_drawtext_filter(
sticker, # type: ignore
current_label,
next_label,
canvas_w,
canvas_h,
)
filter_parts.append(single_filter)
current_label = next_label
return StickerOverlayResult(
filter_str=";".join(filter_parts),
output_label=output_label,
extra_inputs=image_paths,
)
@classmethod
def _build_single_image_sticker(
cls,
sticker: ImageStickerConfig,
sticker_input_idx: int,
input_label: str,
output_label: str,
canvas_w: int,
canvas_h: int,
) -> str:
"""构建单个图片贴纸的完整滤镜(预处理 + overlay).
Args:
sticker: 贴纸配置
sticker_input_idx: 贴纸在 FFmpeg 输入中的索引
input_label: 输入视频标签
output_label: 输出标签
canvas_w: 画布宽
canvas_h: 画布高
"""
scaled_label = f"sticker_s{sticker_input_idx}"
# 预处理滤镜(缩放 + 透明度 + 淡入淡出)
pre_filters: list[str] = []
# 缩放
if sticker.width and sticker.height:
pre_filters.append(f"scale={sticker.width}:{sticker.height}")
elif sticker.scale != 1.0:
pre_filters.append(f"scale=iw*{sticker.scale}:ih*{sticker.scale}")
# 透明度
if sticker.opacity < 1.0:
pre_filters.append(f"format=rgba,colorchannelmixer=aa={sticker.opacity}")
# 淡入淡出(使用 fade 的 alpha 模式)
fade_filters: list[str] = []
if sticker.fade_in > 0:
fade_filters.append(f"fade=in:st={sticker.start_time}:d={sticker.fade_in}:alpha=1")
if sticker.fade_out > 0 and sticker.duration > 0:
fade_out_start = sticker.start_time + sticker.duration - sticker.fade_out
if fade_out_start > 0:
fade_filters.append(f"fade=out:st={fade_out_start}:d={sticker.fade_out}:alpha=1")
# 估算贴纸尺寸用于位置计算
est_w = int(canvas_w * 0.3 * sticker.scale) if not sticker.width else sticker.width
est_h = int(canvas_h * 0.3 * sticker.scale) if not sticker.height else sticker.height
pos_x, pos_y = cls._resolve_position(sticker, canvas_w, canvas_h, est_w, est_h)
# enable 表达式
enable_expr = ""
if sticker.duration > 0:
enable_expr = f":enable='between(t,{sticker.start_time},{sticker.start_time + sticker.duration})'"
parts: list[str] = []
# 贴纸预处理
all_pre = pre_filters + fade_filters
if all_pre:
parts.append(f"[{sticker_input_idx}:v]{','.join(all_pre)}[{scaled_label}]")
sticker_source = f"[{scaled_label}]"
else:
sticker_source = f"[{sticker_input_idx}:v]"
# overlay 合成
parts.append(f"{input_label}{sticker_source}overlay={pos_x:.0f}:{pos_y:.0f}{enable_expr}{output_label}")
return ";".join(parts)
@staticmethod
def _safe_float(val: Any) -> float | None:
"""安全转换 float."""
if val is None:
return None
try:
return float(val)
except (ValueError, TypeError):
return None
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]:
"""从 plan.config.stickers 解析贴纸列表."""
if not config:
return []
stickers = config.get("stickers", [])
if not isinstance(stickers, list):
return []
return stickers
def get_sticker_categories() -> list[tuple[str, str]]:
"""获取贴纸分类列表."""
return list(STICKER_CATEGORIES)
@@ -1,183 +0,0 @@
"""字幕生成器 — 将字幕时间轴转换为 ASS 字幕文件。
render_subtitles.py 的区别
- render_subtitles.py 处理静态整段标题/字幕
- 本模块处理带时间轴的多段 ASR 字幕
两者最终都输出 ASS 文件 FFmpeg 烧录
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from packages.domain.subtitle import SubtitleTimeline
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_MAX_CHARS_PER_LINE = 20 # 每行最多字符数
DEFAULT_MIN_CHARS_PER_SEGMENT = 8 # 每段最少字符数
# ── ASS 工具函数 ────────────────────────────────────────────────────────────
def _hex_to_ass_color(hex_color: str) -> str:
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。"""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H00FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{b.upper()}{g.upper()}{r.upper()}"
def _position_to_ass_alignment(position: str) -> int:
"""将文字位置映射为 ASS \\an 对齐编号。"""
mapping = {
"top": 8,
"center": 5,
"bottom": 2,
}
return mapping.get(position, 2)
def _format_ass_time(seconds: float) -> str:
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc。"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def _escape_ass_text(text: str) -> str:
"""转义 ASS 文本中的特殊字符。"""
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
text = text.replace("{", "(").replace("}", ")")
return text
def _wrap_text(text: str, max_chars: int) -> list[str]:
"""将长文本按字数换行。
优先在标点处换行没有合适标点时硬切
"""
if len(text) <= max_chars:
return [text]
lines: list[str] = []
remaining = text
while len(remaining) > max_chars:
# 在前 max_chars 个字符中找标点断开
break_point = max_chars
punctuations = ",。!?、;:,.;:!?"
for i in range(max_chars, max_chars // 2, -1):
if i < len(remaining) and remaining[i] in punctuations:
break_point = i + 1
break
lines.append(remaining[:break_point])
remaining = remaining[break_point:]
if remaining:
lines.append(remaining)
return lines
# ── 主生成器 ─────────────────────────────────────────────────────────────────
def generate_ass_from_timeline(
output_path: Path,
timeline: SubtitleTimeline,
*,
video_width: int,
video_height: int,
subtitle_config: dict[str, Any] | None = None,
) -> Path:
"""从字幕时间轴生成 ASS 字幕文件。
Args:
output_path: 输出 ASS 文件路径
timeline: 字幕时间轴
video_width: 视频宽度
video_height: 视频高度
subtitle_config: 字幕样式配置 SubtitleConfig dict
Returns:
生成的 ASS 文件路径
"""
subtitle_config = subtitle_config or {}
if not timeline.segments:
output_path.write_text("", encoding="utf-8")
return output_path
# 样式参数
font_name = subtitle_config.get("font", "思源黑体")
font_size = int(subtitle_config.get("size", 24))
color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
position = subtitle_config.get("position", "bottom")
alignment = _position_to_ass_alignment(position)
max_chars_per_line = int(subtitle_config.get("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE))
# 描边(默认黑色描边,保证可读性)
outline_color = "&H00000000"
outline_width = 1.5
# 边距
margin_v = 60 if position == "bottom" else 60
margin_l = 40
margin_r = 40
# 生成样式行
style_line = (
f"Style: Default,{font_name},{font_size},{color},"
f"&H000000FF,{outline_color},&H00000000,"
f"-1,0,0,0,100,100,0,0,"
f"1,{outline_width},0,{alignment},"
f"{margin_l},{margin_r},{margin_v},1"
)
# 生成事件行
events: list[str] = []
for seg in timeline.segments:
start_time = _format_ass_time(seg.start)
end_time = _format_ass_time(seg.end)
# 自动换行
lines = _wrap_text(seg.text, max_chars_per_line)
display_text = "\\N".join(lines)
safe_text = _escape_ass_text(display_text)
events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}")
# 组装 ASS 文件
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
PlayResY: {video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
{style_line}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(ass_content, encoding="utf-8")
return output_path
@@ -1,123 +0,0 @@
"""视频缩略图生成工具 — 抽取首帧上传到 OSS。"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def extract_first_frame(
video_path: str,
output_path: str | None = None,
*,
width: int = 640,
height: int = -1,
timeout: int = 30,
) -> str:
"""抽取视频第一帧作为封面图。
Args:
video_path: 视频文件路径
output_path: 输出图片路径不传则用临时文件
width: 输出宽度默认 640-1 表示按比例缩放
height: 输出高度默认 -1按比例缩放
timeout: 超时时间
Returns:
生成的缩略图文件路径
Raises:
subprocess.CalledProcessError: ffmpeg 执行失败
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
if output_path is None:
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
output_path = tmp.name
# -ss 00:00:01 取第1秒帧(避免首帧黑屏)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
cmd = [
FFMPEG_BIN,
"-y",
"-i",
video_path,
"-ss",
"00:00:01",
"-vframes",
"1",
"-vf",
scale_filter,
"-q:v",
"2",
output_path,
]
try:
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
except Exception:
# 短视频可能没有第1秒,退回到第0帧
cmd2 = [
FFMPEG_BIN,
"-y",
"-i",
video_path,
"-ss",
"00:00:00",
"-vframes",
"1",
"-vf",
scale_filter,
"-q:v",
"2",
output_path,
]
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
return output_path
def generate_and_upload_thumbnail(
video_path: str,
storage_key: str,
) -> str | None:
"""生成缩略图并上传到 OSS,返回 URL。
Args:
video_path: 本地视频路径
storage_key: OSS 存储 key generated/projects/xxx/thumbnails/yyy.jpg
Returns:
上传成功返回 URL失败返回 None
"""
thumbnail_path = None
try:
thumbnail_path = extract_first_frame(video_path)
except Exception as e:
logger.warning("Failed to extract thumbnail from %s: %s", video_path, e)
return None
try:
from video_processing.oss_helpers import upload_to_oss
url = upload_to_oss(thumbnail_path, storage_key)
return url
except Exception as e:
logger.warning("Failed to upload thumbnail to OSS: %s", e)
return None
finally:
# 清理临时文件
if thumbnail_path:
try:
Path(thumbnail_path).unlink(missing_ok=True)
except Exception:
pass
@@ -1,381 +0,0 @@
"""转场特效引擎 — Phase 8 智能增强.
基于 FFmpeg xfade 滤镜的统一转场抽象层提供
1. 转场类型枚举与预设管理
2. 转场配置解析与边界校验
3. 降级策略不支持的转场自动 fallback 到硬切
4. xfade 滤镜链构建封装底层 ffmpeg_utils
新增转场只需在 TransitionType 中加一项 + XFADE_TRANSITION_MAP 中映射
"""
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from enum import Enum
class StrEnum(str, Enum):
pass
from video_processing.ffmpeg_utils import build_xfade_filter_chain
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# 转场时长范围(秒)
MIN_TRANSITION_DURATION = 0.3
MAX_TRANSITION_DURATION = 2.0
DEFAULT_TRANSITION_DURATION = 0.5
# 硬切(无转场)
CUT_TRANSITION = "cut"
# ── 转场类型枚举 ──────────────────────────────────────────────────────────────
class TransitionType(StrEnum):
"""支持的转场效果类型.
每种类型对应 FFmpeg xfade filter 的一个 transition
新增转场只需在此添加一项并在 _FFMPEG_XFADE_MAP 中映射
"""
# 硬切(无转场效果,直接拼接)
CUT = "cut"
# 淡入淡出(最常用,默认 fallback)
FADE = "fade"
# 溶解(交叉溶解)
DISSOLVE = "dissolve"
# 滑入系列
SLIDE_LEFT = "slideleft"
SLIDE_RIGHT = "slideright"
SLIDE_UP = "slideup"
SLIDE_DOWN = "slidedown"
# 缩放
ZOOM = "zoom"
# 擦除系列
WIPE_LEFT = "wipeleft"
WIPE_RIGHT = "wiperight"
WIPE_UP = "wipeup"
WIPE_DOWN = "wipedown"
# 圆形扩散
CIRCLE_CROP = "circlecrop"
# 矩形覆盖
RECT_CROP = "rectcrop"
@classmethod
def all_supported(cls) -> list[str]:
"""返回所有支持的转场类型名称列表."""
return [t.value for t in cls if t != cls.CUT]
@classmethod
def is_supported(cls, name: str) -> bool:
"""检查转场类型是否支持(不区分大小写和下划线)."""
normalized = _normalize_transition_name(name)
return normalized in _NAME_TO_ENUM_MAP
# ── 名称 → 枚举 映射(支持多种别名)──────────────────────────────────────────
def _normalize_transition_name(name: str) -> str:
"""标准化转场名称:小写 + 去下划线."""
return name.lower().replace("_", "").replace("-", "")
# 构建别名映射
_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {}
for _t in TransitionType:
_NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t
# 额外的别名
_ALIASES: dict[str, TransitionType] = {
"dissolve": TransitionType.DISSOLVE,
"crossfade": TransitionType.DISSOLVE,
"crossdissolve": TransitionType.DISSOLVE,
"fadein": TransitionType.FADE,
"fadeout": TransitionType.FADE,
"fadeblack": TransitionType.FADE,
"slide": TransitionType.SLIDE_LEFT, # 默认向左滑
"wipe": TransitionType.WIPE_LEFT, # 默认向左擦
"zoomin": TransitionType.ZOOM,
"zoomout": TransitionType.ZOOM,
"circle": TransitionType.CIRCLE_CROP,
"rect": TransitionType.RECT_CROP,
}
for _alias, _type in _ALIASES.items():
_key = _normalize_transition_name(_alias)
if _key not in _NAME_TO_ENUM_MAP:
_NAME_TO_ENUM_MAP[_key] = _type
# ── TransitionType → FFmpeg xfade transition 名称映射 ─────────────────────────
_FFMPEG_XFADE_MAP: dict[TransitionType, str] = {
TransitionType.FADE: "fade",
TransitionType.DISSOLVE: "dissolve",
TransitionType.SLIDE_LEFT: "slideleft",
TransitionType.SLIDE_RIGHT: "slideright",
TransitionType.SLIDE_UP: "slideup",
TransitionType.SLIDE_DOWN: "slidedown",
TransitionType.ZOOM: "zoomin",
TransitionType.WIPE_LEFT: "wipeleft",
TransitionType.WIPE_RIGHT: "wiperight",
TransitionType.WIPE_UP: "wipeup",
TransitionType.WIPE_DOWN: "wipedown",
TransitionType.CIRCLE_CROP: "circlecrop",
TransitionType.RECT_CROP: "rectcrop",
}
# ── 转场配置 ──────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class TransitionConfig:
"""转场效果配置.
Attributes:
effect: 转场效果名称 TransitionType
duration: 转场时长范围 0.3~2.0默认 0.5
"""
effect: str = CUT_TRANSITION
duration: float = DEFAULT_TRANSITION_DURATION
@classmethod
def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig":
"""解析并验证转场配置,自动处理边界和降级.
Args:
effect: 转场效果名称None 或空则使用默认 cut
duration: 转场时长None 则使用默认值
Returns:
验证后的 TransitionConfig
"""
# 处理 effect
final_effect = CUT_TRANSITION
if effect and effect.strip():
effect_clean = effect.strip()
if TransitionType.is_supported(effect_clean):
final_effect = _resolve_transition_enum(effect_clean).value
elif effect_clean.lower() == CUT_TRANSITION:
final_effect = CUT_TRANSITION
else:
# 降级:不支持的转场 → 硬切,不阻断渲染
logger.warning(
"不支持的转场效果 '%s',已降级为硬切(cut",
effect_clean,
)
final_effect = CUT_TRANSITION
# 处理 duration:边界钳制
final_duration = DEFAULT_TRANSITION_DURATION
if duration is not None:
try:
d = float(duration)
if d < MIN_TRANSITION_DURATION:
logger.warning(
"转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值",
d,
MIN_TRANSITION_DURATION,
)
final_duration = MIN_TRANSITION_DURATION
elif d > MAX_TRANSITION_DURATION:
logger.warning(
"转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值",
d,
MAX_TRANSITION_DURATION,
)
final_duration = MAX_TRANSITION_DURATION
else:
final_duration = d
except (TypeError, ValueError):
logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION)
final_duration = DEFAULT_TRANSITION_DURATION
return cls(effect=final_effect, duration=final_duration)
@property
def is_cut(self) -> bool:
"""是否为硬切(无转场效果)."""
return self.effect == CUT_TRANSITION
@property
def ffmpeg_transition(self) -> str:
"""获取对应的 FFmpeg xfade transition 名称."""
if self.is_cut:
return ""
enum_type = _resolve_transition_enum(self.effect)
return _FFMPEG_XFADE_MAP.get(enum_type, "fade")
def _resolve_transition_enum(name: str) -> TransitionType:
"""将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验."""
normalized = _normalize_transition_name(name)
return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE)
# ── 转场引擎 ──────────────────────────────────────────────────────────────────
class TransitionEngine:
"""转场特效引擎.
封装转场配置验证降级策略和 xfade 滤镜链构建
UnifiedRenderService 等上层调用
用法::
engine = TransitionEngine(default_duration=0.5)
config = engine.resolve_config("fade", 0.8)
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0, 5.0],
clip_video_labels=["v0", "v1", "v2"],
transitions=["cut", "fade", "dissolve"],
)
"""
def __init__(self, default_duration: float = DEFAULT_TRANSITION_DURATION) -> None:
"""初始化转场引擎.
Args:
default_duration: 默认转场时长用于未指定时长的 clip
"""
self._default_duration = default_duration
def resolve_config(
self,
effect: str | None = None,
duration: float | None = None,
) -> TransitionConfig:
"""解析单个转场配置,应用验证和降级.
Args:
effect: 转场效果名称
duration: 转场时长
Returns:
验证后的 TransitionConfig
"""
# 若未指定 duration,使用引擎默认值
dur = duration if duration is not None else self._default_duration
return TransitionConfig.parse(effect=effect, duration=dur)
def resolve_clip_transitions(
self,
clip_transitions: list[str],
clip_durations: list[float] | None = None,
) -> list[TransitionConfig]:
"""批量解析 clip 级别的转场配置.
Args:
clip_transitions: 每个 clip 的转场效果名称列表
clip_durations: 每个 clip 的时长列表用于验证转场时长不超过片段时长
Returns:
TransitionConfig 列表
"""
configs: list[TransitionConfig] = []
for i, effect in enumerate(clip_transitions):
cfg = self.resolve_config(effect=effect)
# 额外校验:转场时长不能超过对应 clip 时长的一半(保守限制)
if clip_durations and i < len(clip_durations) and not cfg.is_cut:
max_safe_duration = max(MIN_TRANSITION_DURATION, clip_durations[i] * 0.5)
if cfg.duration > max_safe_duration:
cfg = TransitionConfig(effect=cfg.effect, duration=max_safe_duration)
configs.append(cfg)
return configs
def build_xfade_chain(
self,
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float | None = None,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链.
对每步转场应用验证和降级然后调用底层 ffmpeg_utils 构建
Args:
clip_durations: 每个片段的时长
clip_video_labels: 每个片段的视频流标签
transitions: 每个片段对应的转场效果
transition_duration: 统一转场时长None 则使用引擎默认值
output_label: 最终输出标签
Returns:
(filter_string, estimated_total_duration)
"""
if len(clip_durations) <= 1:
return build_xfade_filter_chain(
clip_durations=clip_durations,
clip_video_labels=clip_video_labels,
transitions=transitions,
transition_duration=transition_duration or self._default_duration,
output_label=output_label,
)
# 解析所有转场配置
resolved = self.resolve_clip_transitions(transitions, clip_durations)
resolved_effects = [c.effect for c in resolved]
# 使用统一的时长(取各转场中最大的时长作为基准,底层会做每步钳制)
dur = transition_duration or self._default_duration
if not dur:
dur = max(c.duration for c in resolved) if resolved else DEFAULT_TRANSITION_DURATION
# 调用底层构建
return build_xfade_filter_chain(
clip_durations=clip_durations,
clip_video_labels=clip_video_labels,
transitions=resolved_effects,
transition_duration=dur,
output_label=output_label,
)
@staticmethod
def supported_transitions() -> list[dict[str, str]]:
"""获取所有支持的转场效果列表(用于 API 返回给前端).
Returns:
[{name, display_name, category}, ...]
"""
return [
{"name": "cut", "display_name": "硬切", "category": "basic"},
{"name": "fade", "display_name": "淡入淡出", "category": "basic"},
{"name": "dissolve", "display_name": "溶解", "category": "basic"},
{"name": "slideleft", "display_name": "左滑入", "category": "slide"},
{"name": "slideright", "display_name": "右滑入", "category": "slide"},
{"name": "slideup", "display_name": "上滑入", "category": "slide"},
{"name": "slidedown", "display_name": "下滑入", "category": "slide"},
{"name": "zoom", "display_name": "缩放", "category": "zoom"},
{"name": "wipeleft", "display_name": "左擦除", "category": "wipe"},
{"name": "wiperight", "display_name": "右擦除", "category": "wipe"},
{"name": "wipeup", "display_name": "上擦除", "category": "wipe"},
{"name": "wipedown", "display_name": "下擦除", "category": "wipe"},
{"name": "circlecrop", "display_name": "圆形扩散", "category": "special"},
{"name": "rectcrop", "display_name": "矩形扩散", "category": "special"},
]
-339
View File
@@ -1,339 +0,0 @@
"""裁剪引擎 — 基于 FFmpeg trim/atrim 的精确帧级裁剪.
支持
- 入点出点裁剪start_time / end_time / duration 三选二
- 边界自动钳制超出素材时长自动修正不阻断渲染
- 多段裁剪一个素材裁剪出多段
- 音画同步视频 + 音频同步裁剪
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# 最小裁剪时长(秒),低于此值视为无效
MIN_TRIM_DURATION = 0.1
@dataclass
class TrimConfig:
"""裁剪配置.
三选二规则start_time / end_time / duration 中必须至少给出两个
第三个会被自动推导如果三个都给了 start_time + duration 为准
边界保护
- start_time < 0 钳制到 0
- end_time > 素材时长 钳制到素材时长
- 计算出的 duration < 最小阈值 标记为无效
"""
start_time: float = 0.0 # 入点(素材内时间,秒)
end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定
duration: float = 0.0 # 裁剪时长(秒),0 表示未指定
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> TrimConfig | None:
"""从字典构造,无有效裁剪参数时返回 None(不裁剪)."""
if not data:
return None
start = float(data.get("start_time", 0) or 0)
end = float(data.get("end_time", 0) or 0)
dur = float(data.get("duration", 0) or 0)
# 三个参数都没有 → 不裁剪
if start <= 0 and end <= 0 and dur <= 0:
return None
# 至少有两个参数(或一个合理的 start/duration
# 兼容:只传了 start_time → 从 start 开始取到末尾
# 兼容:只传了 duration → 从 0 开始取 duration
if start > 0 and end <= 0 and dur <= 0:
# 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效
pass
elif dur > 0 and start <= 0 and end <= 0:
# 只有 duration → 从开头取 duration,算有效
pass
elif start <= 0 and end <= 0 and dur <= 0:
return None
return cls(start_time=start, end_time=end, duration=dur)
def validate_and_resolve(self, asset_duration: float) -> TrimConfig:
"""根据素材实际时长,解析并钳制裁剪参数.
返回一个新的 TrimConfig其中 start_time / end_time / duration 都已确定
如果裁剪无效时长为0或负数仍返回但调用方应检查 is_valid
"""
start = self.start_time
end = self.end_time
dur = self.duration
# 边界:start 不能为负
if start < 0:
start = 0.0
# 边界:asset_duration 为 0 时保守处理(不裁剪,取全部)
if asset_duration <= 0:
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
# 三选二推导
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
# 情况1start + end 都有显式值
if start > 0 and end > 0:
if end <= start:
# 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效)
return TrimConfig(start_time=start, end_time=start, duration=0.0)
dur = end - start
# 情况2end + duration 都有显式值
elif end > 0 and dur > 0:
start = end - dur
if start < 0:
start = 0.0
dur = end # 重新计算
# 情况3start + duration 都有值(start 可以是 0
elif dur > 0:
end = start + dur
# 情况4:只有 start → 取到素材末尾
elif start > 0 and end <= 0 and dur <= 0:
end = asset_duration
dur = end - start
# 情况5:只有 end → 从开头取到 end
elif end > 0 and start <= 0 and dur <= 0:
start = 0.0
dur = end
else:
# 都没有 → 不裁剪
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
# 边界钳制:end 不能超过素材时长
if end > asset_duration:
end = asset_duration
dur = end - start
# 边界钳制:start 不能超过素材时长
if start >= asset_duration:
start = max(0.0, asset_duration - MIN_TRIM_DURATION)
dur = asset_duration - start
end = asset_duration
# 保证 duration 不为负
if dur < 0:
dur = 0.0
return TrimConfig(start_time=start, end_time=end, duration=dur)
@property
def is_valid(self) -> bool:
"""裁剪是否有效(时长大于最小阈值)."""
return self.duration >= MIN_TRIM_DURATION
@property
def is_noop(self) -> bool:
"""是否等价于不裁剪(从0开始取全部)."""
return self.start_time <= 0 and self.duration <= 0
@property
def trim_from_start(self) -> bool:
"""是否从开头裁剪(start_time == 0."""
return self.start_time <= 0
@dataclass
class TrimSegment:
"""多段裁剪中的一段."""
segment_id: str # 段 ID(用于生成唯一标签)
trim: TrimConfig # 裁剪配置
order: int = 0 # 排序
@classmethod
def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> TrimSegment:
"""从字典构造."""
return cls(
segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"),
trim=TrimConfig(
start_time=float(data.get("start_time", 0) or 0),
end_time=float(data.get("end_time", 0) or 0),
duration=float(data.get("duration", 0) or 0),
),
order=int(data.get("order", default_order)),
)
class TrimEngine:
"""裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜."""
@staticmethod
def build_video_trim_filter(
input_label: str,
trim: TrimConfig,
output_label: str,
) -> str:
"""构建视频裁剪滤镜链.
Args:
input_label: 输入视频标签 "[0:v]"
trim: 裁剪配置已解析钳制
output_label: 输出视频标签 "[v0_trimmed]"
Returns:
FFmpeg filter 字符串 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
"""
if trim.is_noop:
# 不裁剪,直接直通
return f"{input_label}copy{output_label}" if False else f"{input_label}setpts=PTS-STARTPTS{output_label}"
parts: list[str] = []
# trim 滤镜参数
trim_args: list[str] = []
if trim.start_time > 0:
trim_args.append(f"start={trim.start_time:.3f}")
if trim.duration > 0:
trim_args.append(f"duration={trim.duration:.3f}")
elif trim.end_time > 0:
# end 用 duration 表示(start 到 end 的时长)
# 但 validate_and_resolve 后应该已经有 duration 了
pass
parts.append(f"trim={':'.join(trim_args)}")
parts.append("setpts=PTS-STARTPTS")
filter_str = f"{input_label}{','.join(parts)}{output_label}"
return filter_str
@staticmethod
def build_audio_trim_filter(
input_label: str,
trim: TrimConfig,
output_label: str,
) -> str:
"""构建音频裁剪滤镜链.
Args:
input_label: 输入音频标签 "[0:a]"
trim: 裁剪配置已解析钳制
output_label: 输出音频标签 "[a0_trimmed]"
Returns:
FFmpeg filter 字符串 "[0:a]atrim=start=10:duration=5,asetpts=PTS-STARTPTS[a0_trimmed]"
"""
if trim.is_noop:
return f"{input_label}asetpts=PTS-STARTPTS{output_label}"
parts: list[str] = []
trim_args: list[str] = []
if trim.start_time > 0:
trim_args.append(f"start={trim.start_time:.3f}")
if trim.duration > 0:
trim_args.append(f"duration={trim.duration:.3f}")
parts.append(f"atrim={':'.join(trim_args)}")
parts.append("asetpts=PTS-STARTPTS")
filter_str = f"{input_label}{','.join(parts)}{output_label}"
return filter_str
@staticmethod
def resolve_segments(
segments: list[TrimSegment],
asset_duration: float,
) -> list[TrimSegment]:
"""解析并钳制多段裁剪配置,过滤无效段.
Args:
segments: 原始段列表
asset_duration: 素材实际时长
Returns:
解析后的有效段列表 order 排序
"""
resolved: list[TrimSegment] = []
for i, seg in enumerate(segments):
resolved_trim = seg.trim.validate_and_resolve(asset_duration)
if not resolved_trim.is_valid:
logger.warning("裁剪段无效,跳过: segment_id=%s duration=%.3f", seg.segment_id, resolved_trim.duration)
continue
resolved.append(
TrimSegment(
segment_id=seg.segment_id,
trim=resolved_trim,
order=seg.order if seg.order >= 0 else i,
)
)
resolved.sort(key=lambda s: s.order)
return resolved
@staticmethod
def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]:
"""从 clip config 中解析多段裁剪配置.
config 中支持
- trim_segments: [ {segment_id, start_time, end_time, duration, order}, ... ]
- trim_start / trim_end / trim_duration: 单段裁剪兼容旧格式
"""
if not config:
return []
# 优先解析多段
raw_segments = config.get("trim_segments", [])
if raw_segments and isinstance(raw_segments, list):
segments = []
for i, raw in enumerate(raw_segments):
if isinstance(raw, dict):
segments.append(TrimSegment.from_dict(raw, default_order=i))
return segments
# 单段裁剪兼容:从 trim_start/trim_end/trim_duration 构造
has_single = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
if has_single:
seg = TrimSegment(
segment_id="main",
trim=TrimConfig(
start_time=float(config.get("trim_start", 0) or 0),
end_time=float(config.get("trim_end", 0) or 0),
duration=float(config.get("trim_duration", 0) or 0),
),
order=0,
)
return [seg]
return []
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None:
"""从 clip config 中提取单段裁剪配置.
兼容以下字段名
- trim_start / trim_end / trim_duration
- start_time / end_time / duration trim 子字典里
"""
if not config:
return None
# trim 子字典
if "trim" in config and isinstance(config["trim"], dict):
return TrimConfig.from_dict(config["trim"])
# 扁平字段
has_any = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
if not has_any:
return None
data = {
"start_time": config.get("trim_start", 0),
"end_time": config.get("trim_end", 0),
"duration": config.get("trim_duration", 0),
}
return TrimConfig.from_dict(data)
-275
View File
@@ -1,275 +0,0 @@
"""TTS 配音引擎 — 集成到统一渲染管道的配音能力.
负责
- 根据 TtsConfig 生成配音音频
- 字幕联动按字幕片段分段合成自动对齐时间轴
- 整段配音整段文本生成一条音频
- 失败降级TTS 失败不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from packages.domain.tts_config import TtsConfig
from packages.ports.tts_service import TtsError, TtsService
logger = logging.getLogger(__name__)
@dataclass
class VoiceoverSegment:
"""配音片段.
Attributes:
text: 文本内容
start_time: 开始时间
end_time: 结束时间
audio_path: 合成后的音频文件路径
duration: 音频实际时长
"""
text: str
start_time: float = 0.0
end_time: float = 0.0
audio_path: Path | None = None
duration: float = 0.0
@dataclass
class VoiceoverResult:
"""配音结果.
Attributes:
success: 是否成功
segments: 配音片段列表
total_duration: 总时长
error_message: 错误信息失败时
"""
success: bool = False
segments: list[VoiceoverSegment] = field(default_factory=list)
total_duration: float = 0.0
error_message: str = ""
class TtsEngine:
"""TTS 配音引擎.
封装 TtsService 调用支持
- 整段配音
- 字幕联动配音
- 失败降级
"""
def __init__(
self,
tts_service: TtsService,
work_dir: Path,
) -> None:
self._tts = tts_service
self._work_dir = work_dir
self._work_dir.mkdir(parents=True, exist_ok=True)
def generate_full_voiceover(
self,
config: TtsConfig,
*,
total_duration: float = 0.0,
) -> VoiceoverResult:
"""生成整段配音.
Args:
config: TTS 配置
total_duration: 视频总时长用于调整配音速度适配
Returns:
配音结果
"""
if not config.enabled or not config.text.strip():
return VoiceoverResult(success=False, error_message="配音未启用或文本为空")
try:
output_path = self._work_dir / "voiceover_full.wav"
audio_path = self._tts.synthesize(
text=config.text,
voice_id=config.voice_id,
speed=config.speed,
pitch=config.pitch,
output_path=output_path,
)
# 探测实际时长
duration = self._probe_duration(audio_path)
segment = VoiceoverSegment(
text=config.text,
start_time=0.0,
end_time=duration,
audio_path=audio_path,
duration=duration,
)
return VoiceoverResult(
success=True,
segments=[segment],
total_duration=duration,
)
except TtsError as e:
logger.warning("TTS 整段配音失败,降级跳过: %s", e)
return VoiceoverResult(success=False, error_message=str(e))
except Exception as e:
logger.warning("TTS 整段配音异常,降级跳过: %s", e)
return VoiceoverResult(success=False, error_message=str(e))
def generate_subtitle_voiceover(
self,
config: TtsConfig,
subtitles: list[dict[str, Any]],
) -> VoiceoverResult:
"""根据字幕生成配音(字幕联动).
每个字幕片段独立合成按字幕时间轴对齐
Args:
config: TTS 配置
subtitles: 字幕列表每项含 text/start_time/end_time
Returns:
配音结果
"""
if not config.enabled:
return VoiceoverResult(success=False, error_message="配音未启用")
if not subtitles:
return VoiceoverResult(success=False, error_message="字幕为空")
segments: list[VoiceoverSegment] = []
total_duration = 0.0
for i, sub in enumerate(subtitles):
text = sub.get("text", "").strip()
if not text:
continue
start_time = float(sub.get("start_time", 0))
end_time = float(sub.get("end_time", 0))
target_duration = max(0.1, end_time - start_time)
try:
# 计算适配时长所需语速:让配音时长 ≈ 字幕时长
estimated = self._tts.estimate_duration(text, speed=config.speed)
adjusted_speed = config.speed
if estimated > 0 and target_duration > 0:
# 按目标时长调整语速,限制在 0.5~2.0 范围内
speed_factor = estimated / target_duration
adjusted_speed = max(0.5, min(2.0, config.speed * speed_factor))
output_path = self._work_dir / f"voiceover_seg_{i:03d}.wav"
audio_path = self._tts.synthesize(
text=text,
voice_id=config.voice_id,
speed=adjusted_speed,
pitch=config.pitch,
output_path=output_path,
)
actual_duration = self._probe_duration(audio_path)
segment = VoiceoverSegment(
text=text,
start_time=start_time,
end_time=start_time + actual_duration,
audio_path=audio_path,
duration=actual_duration,
)
segments.append(segment)
total_duration = max(total_duration, start_time + actual_duration)
except TtsError as e:
logger.warning("TTS 字幕片段 %d 合成失败,跳过: %s", i, e)
continue
except Exception as e:
logger.warning("TTS 字幕片段 %d 异常,跳过: %s", i, e)
continue
if not segments:
return VoiceoverResult(success=False, error_message="所有字幕片段合成失败")
return VoiceoverResult(
success=True,
segments=segments,
total_duration=total_duration,
)
def build_audio_mix_filter(
self,
result: VoiceoverResult,
*,
video_duration: float,
base_label: str = "0:a",
) -> tuple[str, list[Path]]:
"""构建配音混音滤镜.
将配音片段按时间轴排列生成 amix 混入
Args:
result: 配音结果
video_duration: 视频总时长
base_label: 基础音轨标签
Returns:
(filter_complex 字符串, 配音音频文件列表)
"""
if not result.success or not result.segments:
return "", []
filter_parts: list[str] = []
audio_files: list[Path] = []
delay_labels: list[str] = []
for i, seg in enumerate(result.segments):
if seg.audio_path is None or not seg.audio_path.exists():
continue
audio_files.append(seg.audio_path)
seg_label = f"v{i}"
# 音量调整
# 用 adelay 延迟到字幕开始时间
delay_ms = int(max(0, int(seg.start_time * 1000)))
filter_parts.append(f"[{i}:a]adelay={delay_ms}:all=1,volume=0.8[{seg_label}]")
delay_labels.append(f"[{seg_label}]")
if not delay_labels:
return "", []
# 所有片段 concat 成一条配音音轨(用 amix 叠加多个延时后的片段
mix_inputs = "".join(delay_labels)
n_inputs = len(delay_labels)
tts_label = "tts_mixed"
if n_inputs == 1:
# 单个片段直接用
filter_parts.append(f"{delay_labels[0]}[{tts_label}]")
else:
# 多个片段 amix 叠加
filter_parts.append(f"{mix_inputs}amix=inputs={n_inputs}:duration=longest[{tts_label}]")
return ";".join(filter_parts), audio_files
def _probe_duration(self, audio_path: Path) -> float:
"""探测音频时长."""
try:
from video_processing.ffmpeg_utils import probe_duration
return probe_duration(audio_path)
except Exception:
# 探测失败,按文件名估算
return 0.0
File diff suppressed because it is too large Load Diff
@@ -1,315 +0,0 @@
"""水印引擎 — 基于 FFmpeg overlay 滤镜的水印叠加.
支持
- 图片水印PNG/logo
- 文字水印drawtext
- 9宫格位置 + 边距配置
- 透明度/大小缩放
- 滚动水印跑马灯
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# 9宫格位置枚举
WATERMARK_POSITIONS = {
"top_left": "左上",
"top_center": "中上",
"top_right": "右上",
"center_left": "左中",
"center": "中心",
"center_right": "右中",
"bottom_left": "左下",
"bottom_center": "中下",
"bottom_right": "右下",
}
@dataclass
class WatermarkConfig:
"""水印配置.
mode: "image" 图片水印 | "text" 文字水印
position: 9宫格位置
opacity: 透明度 0.0-1.0
scale: 缩放比例图片水印0.1-1.0
margin: 边距像素
scroll: 是否滚动跑马灯
scroll_speed: 滚动速度像素/
"""
mode: str = "text" # image | text
position: str = "bottom_right"
# 图片水印
image_path: str = "" # 本地图片路径
scale: float = 0.2 # 相对输出宽度的比例
opacity: float = 0.8 # 0.0-1.0
# 文字水印
text: str = ""
font_size: int = 24
font_color: str = "white"
font_path: str = "" # 字体文件路径
# 边距
margin_x: int = 20
margin_y: int = 20
# 滚动水印
scroll: bool = False
scroll_speed: int = 50 # 像素/秒
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> WatermarkConfig | None:
"""从字典构造,空配置返回 None(不加水印)."""
if not data:
return None
enabled = data.get("enabled", False)
if not enabled:
return None
mode = data.get("mode", "text")
# 图片模式需要 image_path;文字模式需要 text
if mode == "image":
image_path = data.get("image_path", "") or data.get("image", "") or ""
if not image_path:
logger.warning("图片水印缺少 image_path,跳过水印")
return None
elif mode == "text":
text = data.get("text", "") or ""
if not text:
logger.warning("文字水印缺少 text,跳过水印")
return None
position = data.get("position", "bottom_right")
if position not in WATERMARK_POSITIONS:
position = "bottom_right"
return cls(
mode=mode,
position=position,
image_path=str(data.get("image_path", data.get("image", "")) or ""),
scale=float(data.get("scale", 0.2)),
opacity=float(data.get("opacity", 0.8)),
text=str(data.get("text", "") or ""),
font_size=int(data.get("font_size", 24)),
font_color=str(data.get("font_color", "white")),
font_path=str(data.get("font_path", "") or ""),
margin_x=int(data.get("margin_x", 20)),
margin_y=int(data.get("margin_y", 20)),
scroll=bool(data.get("scroll", False)),
scroll_speed=int(data.get("scroll_speed", 50)),
)
def validate(self) -> tuple[bool, str]:
"""校验配置是否有效."""
if self.position not in WATERMARK_POSITIONS:
return False, f"不支持的位置: {self.position}"
if not (0.0 <= self.opacity <= 1.0):
return False, "透明度必须在 0-1 之间"
if self.mode == "image":
if not self.image_path:
return False, "图片水印缺少图片路径"
if not (0.01 <= self.scale <= 1.0):
return False, "缩放比例必须在 0.01-1.0 之间"
elif self.mode == "text":
if not self.text:
return False, "文字水印缺少文字内容"
if self.font_size <= 0:
return False, "字体大小必须大于 0"
else:
return False, f"不支持的水印模式: {self.mode}"
return True, ""
class WatermarkEngine:
"""水印引擎 — 生成 FFmpeg 水印滤镜."""
@staticmethod
def calc_position(
position: str,
output_width: int,
output_height: int,
wm_width: int,
wm_height: int,
margin_x: int,
margin_y: int,
) -> tuple[int, int]:
"""根据9宫格位置计算水印坐标 (x, y).
坐标系左上角为 (0, 0)
"""
if position == "top_left":
return margin_x, margin_y
elif position == "top_center":
return (output_width - wm_width) // 2, margin_y
elif position == "top_right":
return output_width - wm_width - margin_x, margin_y
elif position == "center_left":
return margin_x, (output_height - wm_height) // 2
elif position == "center":
return (output_width - wm_width) // 2, (output_height - wm_height) // 2
elif position == "center_right":
return output_width - wm_width - margin_x, (output_height - wm_height) // 2
elif position == "bottom_left":
return margin_x, output_height - wm_height - margin_y
elif position == "bottom_center":
return (output_width - wm_width) // 2, output_height - wm_height - margin_y
elif position == "bottom_right":
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
else:
# 默认右下角
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
@staticmethod
def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str:
"""生成滚动水印的 x 坐标表达式.
从右向左滚动跑马灯效果
"""
# x 从 W 到 -wm_width,整个宽度 + wm_width 的距离
# 使用 overlay 的 enable 表达式
# x = 'W - (t * speed)' → 不对,应该是持续滚动
# 标准跑马灯:x = -w + (t * speed) % (W + w)
# 但 FFmpeg overlay 支持表达式
return f"mod({output_width}-mod({speed}*t\\,{output_width}+{wm_width})"
@staticmethod
def build_image_watermark_filter(
input_video_label: str,
wm_image_path: str,
output_width: int,
output_height: int,
output_label: str,
config: WatermarkConfig,
) -> tuple[str, list[str]]:
"""构建图片水印滤镜链.
Args:
input_video_label: 输入视频标签 "[final_video]"
wm_image_path: 水印图片本地路径
output_width: 输出视频宽度
output_height: 输出视频高度
output_label: 输出标签
config: 水印配置
Returns:
(filter_complex_str, input_args_list)
input_args ["-i", wm_image_path] 格式
"""
# 计算水印尺寸(按输出宽度比例缩放)
wm_width = int(output_width * config.scale)
wm_height = -1 # 保持比例
wm_filter = f"scale={wm_width}:{wm_height}"
# 透明度处理
if config.opacity < 1.0:
wm_filter += f",format=rgba,colorchannelmixer=aa={config.opacity}"
# 水印预处理标签
wm_pre_label = "[wm_scaled]"
# 计算位置
x, y = WatermarkEngine.calc_position(
config.position,
output_width,
output_height,
wm_width,
wm_width, # 高度未知,先用宽度估算
config.margin_x,
config.margin_y,
)
# 滚动水印
if config.scroll:
# 从右向左滚动:x = W - (t * speed) mod (W + wm_w)
# 使用 overlay 表达式
x_expr = f"{output_width}-mod({config.scroll_speed}*t\\,{output_width}+{wm_width}"
y_expr = str(y)
overlay_expr = f"x={x_expr}:y={y_expr}"
else:
overlay_expr = f"x={x}:y={y}"
# 构建滤镜
# 先缩放水印图
wm_input_idx = 1 # 假设水印图是第二个输入(索引1
filter_parts = [
f"[1:v]{wm_filter}{wm_pre_label}",
f"{input_video_label}{wm_pre_label}overlay={overlay_expr}{output_label}",
]
filter_complex = ";".join(filter_parts)
input_args = ["-i", wm_image_path]
return filter_complex, input_args
@staticmethod
def build_text_watermark_filter(
input_video_label: str,
output_label: str,
config: WatermarkConfig,
output_width: int,
output_height: int,
) -> str:
"""构建文字水印滤镜(drawtext.
Args:
input_video_label: 输入视频标签
output_label: 输出标签
config: 水印配置
output_width: 输出宽度
output_height: 输出高度
Returns:
FFmpeg filter 字符串
"""
# 转义文字中的特殊字符
text = config.text.replace(":", "\\:").replace("'", "\\'")
# 字体配置
font_config = []
if config.font_path:
font_path_escaped = config.font_path.replace(":", "\\:").replace("'", "\\'")
font_config.append(f"fontfile='{font_path_escaped}'")
font_config.append(f"fontsize={config.font_size}")
font_config.append(f"fontcolor={config.font_color}@{config.opacity}")
# 估算文字宽高(粗略估算,用于位置计算)
# 每个汉字约等于 font_size 宽高
approx_w = len(config.text) * config.font_size
approx_h = config.font_size
# 位置计算
x, y = WatermarkEngine.calc_position(
config.position,
output_width,
output_height,
approx_w,
approx_h,
config.margin_x,
config.margin_y,
)
# 滚动水印
if config.scroll:
x_expr = f"w-mod({config.scroll_speed}*t\\,W+w)"
pos_config = [f"x={x_expr}", f"y={y}"]
else:
pos_config = [f"x={x}", f"y={y}"]
# 组装 drawtext
drawtext_parts = [f"text='{text}'"] + font_config + pos_config
drawtext = "drawtext=" + ":".join(drawtext_parts)
return f"{input_video_label}{drawtext}{output_label}"
-1
View File
@@ -16,6 +16,5 @@ celery_app.conf.imports = (
"worker_app.tasks.tts_synthesis",
"worker_app.tasks.edit_plan_generation",
"worker_app.tasks.compose_video",
"worker_app.tasks.batch_download",
"apps.worker.video_processing.dedup",
)
@@ -10,10 +10,12 @@ from __future__ import annotations
import json
import logging
import math
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from PIL import Image
@@ -1,112 +0,0 @@
"""批量下载任务 — 将多个成片打包为 zip 上传到 OSS。"""
from __future__ import annotations
import logging
import os
import tempfile
import uuid
import zipfile
from pathlib import Path
from worker_app.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="worker.batch_download_videos", max_retries=1)
def batch_download_videos(self, video_ids: list[str], user_id: str = "") -> dict:
"""批量下载视频并打包为 zip。
Args:
video_ids: 视频 ID 列表
user_id: 发起用户 ID
Returns:
{"download_url": "...", "file_count": N, "total_size": total_bytes}
"""
from video_processing.oss_helpers import download_asset, upload_to_oss
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
session = SessionLocal()
try:
repo = SQLAlchemyGeneratedVideoRepository(session)
videos = repo.get_by_ids(video_ids)
finally:
session.close()
if not videos:
raise ValueError("No videos found for batch download")
# 创建临时工作目录
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
zip_filename = f"videos-{len(videos)}-{video_ids[0][:8]}.zip"
zip_path = tmpdir_path / zip_filename
# 逐个下载视频并加入 zip
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf:
for idx, video in enumerate(videos, 1):
logger.info("Batch download: downloading %d/%d %s", idx, len(videos), video.id)
try:
# 下载视频到临时文件
local_name = f"{idx:03d}_{video.name}"
local_path = tmpdir_path / local_name
# 使用 oss_helpers 的 download_asset,或者直接从 URL 下载
if video.file_url:
_download_video_to_file(video.file_url, str(local_path))
if local_path.exists() and local_path.stat().st_size > 0:
zf.write(str(local_path), arcname=local_name)
local_path.unlink(missing_ok=True)
else:
logger.warning("Video %s download failed, skipping", video.id)
except Exception as e:
logger.warning("Failed to download video %s: %s", video.id, e)
continue
# 上传 zip 到 OSS
if not zip_path.exists() or zip_path.stat().st_size == 0:
raise RuntimeError("Batch download zip file is empty")
zip_storage_key = f"batch-downloads/{uuid.uuid4().hex}/{zip_filename}"
download_url = upload_to_oss(str(zip_path), zip_storage_key)
total_size = zip_path.stat().st_size
file_count = len(zipfile.ZipFile(str(zip_path), "r").namelist())
logger.info(
"Batch download complete: %d files, %d bytes, url=%s",
file_count,
total_size,
download_url,
)
return {
"download_url": download_url,
"file_count": file_count,
"total_size": total_size,
"video_count": len(videos),
}
def _download_video_to_file(url: str, dest_path: str) -> None:
"""下载视频文件到本地路径。优先用 OSS SDK 走内网,回退到 HTTP 下载。"""
from video_processing.oss_helpers import download_asset
try:
# 尝试走 OSS 下载(如果是 OSS URL 的话)
success = download_asset(url, dest_path)
if success:
return
except Exception:
pass
# 回退到 HTTP 下载
import urllib.request
urllib.request.urlretrieve(url, dest_path) # nosec B310
@@ -1,3 +1,4 @@
from celery import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
@@ -8,6 +9,7 @@ from packages.adapters.sqlalchemy_impl.classification_job_repository import (
SQLAlchemyClassificationJobRepository,
)
from packages.domain import (
ClassificationJob,
ClassificationJobStatus,
ClassificationStatus,
)
@@ -68,12 +70,6 @@ def classify_asset(self, job_id: str) -> dict:
# Update asset with classification status and result
asset.classification_status = ClassificationStatus.COMPLETED
# 把分类结果写入 metadata,供列表筛选和智能视图使用
asset.metadata = {
**(asset.metadata or {}),
"classification": classification,
"classification_confidence": confidence,
}
asset_repo.update(asset)
session.commit()
@@ -5,9 +5,12 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from celery.utils.log import get_task_logger
@@ -21,6 +21,7 @@ import logging
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
+228 -550
View File
@@ -13,13 +13,15 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from typing import Any, Optional
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
@@ -67,11 +69,7 @@ def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool:
action(**kwargs)
repo.update(task)
logger.info(
"GenerationTask 状态更新成功: task_id=%s action=%s",
task_id,
status_action,
)
logger.info("GenerationTask 状态更新成功: task_id=%s action=%s", task_id, status_action)
return True
finally:
session.close()
@@ -86,36 +84,6 @@ def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool:
return False
def _build_error_info(error: Exception, stage: str = "render") -> dict:
"""构建结构化错误信息。
Args:
error: 异常对象
stage: 发生错误的阶段download/render/merge/upload等
Returns:
包含 error_type, message, stack_trace, stage, failed_at 的字典
"""
import traceback
from datetime import datetime, timezone
tb_str = traceback.format_exc()
# 截取堆栈前20行,避免字段过大
tb_lines = tb_str.strip().splitlines()
if len(tb_lines) > 20:
tb_summary = "\n".join(tb_lines[:20]) + f"\n... (truncated, total {len(tb_lines)} lines)"
else:
tb_summary = tb_str
return {
"error_type": type(error).__name__,
"message": str(error),
"stack_trace": tb_summary,
"stage": stage,
"failed_at": datetime.now(timezone.utc).isoformat(),
}
# ── 日志持久化辅助 ────────────────────────────────────────────────────────────
@@ -138,7 +106,6 @@ def _flush_logs(task_id: str, gen_task) -> None:
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
from services.asr_service_factory import get_asr_service
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.oss_helpers import (
@@ -207,6 +174,7 @@ def _build_plan_and_clips_from_task(
path_duration[p] = probe_duration(p)
clips: list[_VirtualClip] = []
n = len(downloaded_paths)
if mode == "pip":
# 1 main + N-1 overlay
@@ -335,86 +303,6 @@ def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
return download_asset(storage_key, local_path)
def _prepare_bgm_track(
*,
bgm_config: dict,
temp_path: Path,
task_id: str = "",
) -> str | None:
"""准备 BGM 音频文件(下载到本地).
支持 3 种来源按优先级
1. audio_url 外部直链 URL最高优先级
2. asset_id 素材库中的音频素材
3. preset_id 预设 BGM
Returns:
BGM 本地文件路径准备失败返回 None
"""
from urllib.parse import urlparse
audio_url = bgm_config.get("audio_url", "") or ""
asset_id = bgm_config.get("asset_id", "") or ""
preset_id = bgm_config.get("preset_id", "") or ""
bgm_file = temp_path / f"bgm_{task_id or 'track'}.mp3"
# 优先级1:外部直链 URL
if audio_url:
try:
parsed = urlparse(audio_url)
if parsed.scheme in ("http", "https"):
import urllib.request
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
urllib.request.urlretrieve(audio_url, bgm_file) # nosec B310
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[task_id=%s] [BGM] URL下载失败: %s", task_id, e)
# 优先级2:素材库素材
if asset_id:
try:
from app.core.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = SessionLocal()
try:
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model and model.file_url:
storage_key = model.file_url
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
ok = download_asset(storage_key, bgm_file)
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
finally:
session.close()
except Exception as e:
logger.warning("[task_id=%s] [BGM] 素材库下载失败: %s", task_id, e)
# 优先级3:预设 BGM 库
if preset_id:
try:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset and preset.audio_url:
import urllib.request
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
urllib.request.urlretrieve(preset.audio_url, bgm_file) # nosec B310
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[task_id=%s] [BGM] 预设库下载失败: %s", task_id, e)
# 所有来源都失败
logger.warning("[task_id=%s] [BGM] 所有来源都无法获取BGM,跳过", task_id)
return None
def _verify_url_accessible(url: str, timeout: float = 10.0, retries: int = 2) -> bool:
"""HEAD 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
@@ -573,10 +461,7 @@ def _download_library_assets(
if not storage_key:
failed_assets.append(f"{asset.name}({asset.id})")
logger.warning(
"[task_id=%s] 素材缺少 file_url, 跳过: asset_id=%s name=%s",
task_id,
asset.id,
asset.name,
"[task_id=%s] 素材缺少 file_url, 跳过: asset_id=%s name=%s", task_id, asset.id, asset.name
)
if gen_task:
gen_task.append_log(
@@ -622,12 +507,7 @@ def _download_library_assets(
)
else:
failed_assets.append(f"{asset.name}({asset.id})")
logger.warning(
"[task_id=%s] Failed to download asset: %s (id=%s)",
task_id,
asset.name,
asset.id,
)
logger.warning("[task_id=%s] Failed to download asset: %s (id=%s)", task_id, asset.name, asset.id)
if gen_task:
gen_task.append_log(
"下载素材",
@@ -836,256 +716,6 @@ def _render_with_legacy_engine(
return duration, file_size
# ── generate_video 阶段子函数 ─────────────────────────────────────────────────
def _load_task_info(task_id: str) -> dict | None:
"""从数据库加载 GenerationTask 元数据。
Returns:
包含任务元数据的字典任务不存在时返回 None
"""
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
session = SessionLocal()
try:
task_repo = SQLAlchemyGenerationTaskRepository(session)
gen_task = task_repo.get(task_id)
if gen_task is None:
return None
return {
"project_id": gen_task.project_id,
"asset_library_id": gen_task.asset_library_id,
"voice_library_id": gen_task.voice_library_id or "",
"template_id": getattr(gen_task, "template_id", "") or "",
"mode": gen_task.strategy_id or "one_take",
"task_asset_ids": list(gen_task.asset_ids or []),
"batch_id": getattr(gen_task, "batch_id", "") or "",
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
}
finally:
session.close()
def _download_all_assets(
temp_path: Path,
asset_library_id: str,
project_id: str,
task_asset_ids: list[str],
voice_library_id: str,
task_id: str,
) -> tuple[list[Path], str | None]:
"""下载视频素材和配音素材。
Returns:
(downloaded_videos, audio_path)
Note: gen_task 不传入下载函数session 已关闭
主函数在下载前后已有汇总日志
"""
logger.info("[task_id=%s] [下载素材] 开始下载视频素材", task_id)
download_start = time.monotonic()
downloaded_videos = _download_library_assets(
temp_path,
asset_library_id=asset_library_id,
project_id=project_id,
asset_ids=task_asset_ids or None,
task_id=task_id,
)
download_elapsed = time.monotonic() - download_start
logger.info(
"[task_id=%s] [下载素材] 完成: 成功=%d个, 耗时=%.1fs",
task_id,
len(downloaded_videos),
download_elapsed,
)
audio_path: str | None = None
if voice_library_id:
local_audio = temp_path / "voice.mp3"
if _download_voice_asset(voice_library_id, local_audio):
audio_path = str(local_audio)
logger.info("[task_id=%s] [下载配音] 配音下载成功", task_id)
return downloaded_videos, audio_path
def _render_video(
task_id: str,
downloaded_videos: list[Path],
voice_path: str | None,
editing_mode,
project_id: str,
template_id: str,
user_id: str,
temp_path: Path,
output_name: str,
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
Returns:
(output_path, render_duration)
"""
if not downloaded_videos:
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
# 构建虚拟 plan + clips
virtual_plan, virtual_clips, asset_path_map = _build_plan_and_clips_from_task(
task_id=task_id,
downloaded_paths=downloaded_videos,
mode=editing_mode.value,
)
total_duration = sum(c.duration for c in virtual_clips)
logger.info(
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
task_id,
len(virtual_clips),
total_duration,
)
# 选择渲染引擎
engine = _resolve_render_engine(user_id) if user_id else ENGINE_UNIFIED
logger.info("[task_id=%s] [渲染] 引擎选择: %s (user_id=%s)", task_id, engine, user_id)
render_start = time.monotonic()
render_output_path = temp_path / f"rendered-{task_id}.mp4"
if engine == ENGINE_LEGACY:
render_duration, _ = _render_with_legacy_engine(
task_id=task_id,
virtual_clips=virtual_clips,
asset_path_map=asset_path_map,
work_dir=temp_path,
output_path=render_output_path,
)
else:
logger.info("[task_id=%s] [渲染] unified 引擎 FFmpeg 渲染开始", task_id)
# ── 准备 BGM 音频 ──
bgm_path: str | None = None
plan_config = virtual_plan.config or {}
bgm_config = plan_config.get("bgm", {}) or {}
if bgm_config.get("enabled", False):
try:
bgm_path = _prepare_bgm_track(
bgm_config=bgm_config,
temp_path=temp_path,
task_id=task_id,
)
except Exception as bgm_err:
logger.warning("[task_id=%s] [BGM] 准备失败,跳过BGM: %s", task_id, bgm_err)
bgm_path = None
render_service = UnifiedRenderService(
plan=virtual_plan,
clips=virtual_clips,
asset_path_map=asset_path_map,
work_dir=temp_path,
output_width=OUTPUT_WIDTH,
output_height=OUTPUT_HEIGHT,
output_fps=int(OUTPUT_FPS),
asr_service=get_asr_service(),
bgm_path=bgm_path,
)
render_result = render_service.render()
render_output_path = render_result.output_path
render_duration = render_result.duration
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] %s 引擎完成: 耗时=%.1fs, 时长=%.2fs",
task_id,
engine,
render_elapsed,
render_duration,
)
# 配音混音
if voice_path:
final_path = temp_path / f"final-{task_id}.mp4"
try:
_mux_audio_track(render_output_path, voice_path, final_path)
output_path = final_path
except Exception as mux_err:
logger.warning("[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err)
output_path = render_output_path
else:
output_path = render_output_path
return output_path, render_duration
def _upload_and_record(
task_id: str,
output_path: Path,
project_id: str,
batch_id: str,
editing_mode,
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
Returns:
(file_url, duration, file_size, video_count)
"""
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_path.name}"
file_size = output_path.stat().st_size
# 上传 OSS
logger.info("[task_id=%s] [OSS上传] 开始上传: size=%d", task_id, file_size)
upload_start = time.monotonic()
file_url = upload_to_oss(output_path, storage_key)
upload_elapsed = time.monotonic() - upload_start
if not file_url:
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
# 校验 URL 可达性(P0-2: 私有 bucket 用预签名 + object_exists 降级)
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
if not _verify_url_accessible(verify_url):
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
bucket = oss_bucket()
key = normalize_storage_key(file_url)
if not (bucket and bucket.object_exists(key)):
raise RuntimeError(
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
)
logger.info(
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
key,
)
logger.info(
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
task_id,
upload_elapsed,
file_url,
)
# 创建 GeneratedVideo 记录 + 查重
duration = probe_duration(output_path)
dedup_session = SessionLocal()
try:
video_count = create_video_record_and_dedup(
generation_task_id=task_id,
project_id=project_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=editing_mode.value,
session=dedup_session,
)
finally:
dedup_session.close()
return file_url, duration, file_size, video_count or 1
# ── Celery Task ──────────────────────────────────────────────────────────────
@@ -1108,190 +738,294 @@ def generate_video(self, task_id: str) -> dict:
Returns:
生成结果字典
"""
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.domain import EditingMode
logger.info("[task_id=%s] [接收任务] 开始生成视频任务", task_id)
# ── 1. 加载任务信息 ──────────────────────────────────────────────────────
task_info = _load_task_info(task_id)
if task_info is None:
logger.error("[task_id=%s] [接收任务] 任务不存在", task_id)
return {"status": "failed", "error": f"generation task {task_id} not found"}
project_id = task_info["project_id"]
asset_library_id = task_info["asset_library_id"]
voice_library_id = task_info["voice_library_id"]
template_id = task_info["template_id"]
task_asset_ids = task_info["task_asset_ids"]
batch_id = task_info["batch_id"]
user_id = task_info["user_id"]
# 加载 gen_task(用于全程进度日志;_flush_logs 使用独立 session 持久化)
_session = SessionLocal()
# 从数据库加载任务信息
session = SessionLocal()
try:
_repo = SQLAlchemyGenerationTaskRepository(_session)
gen_task = _repo.get(task_id)
finally:
_session.close()
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
# 记录接收任务日志
if gen_task:
task_repo = SQLAlchemyGenerationTaskRepository(session)
gen_task = task_repo.get(task_id)
if gen_task is None:
logger.error("[task_id=%s] [接收任务] 任务不存在", task_id)
return {"status": "failed", "error": f"generation task {task_id} not found"}
project_id = gen_task.project_id
asset_library_id = gen_task.asset_library_id
voice_library_id = gen_task.voice_library_id or ""
template_id = getattr(gen_task, "template_id", "") or ""
mode = gen_task.strategy_id or "one_take"
task_asset_ids = list(gen_task.asset_ids or [])
batch_id = getattr(gen_task, "batch_id", "") or ""
# 记录接收任务日志
gen_task.append_log(
"接收任务",
f"模式={task_info['mode']}, 模板={template_id}, 素材数={len(task_asset_ids)}",
mode=task_info["mode"],
f"模式={mode}, 模板={template_id}, 素材数={len(task_asset_ids)}",
mode=mode,
template_id=template_id,
asset_count=len(task_asset_ids),
)
_flush_logs(task_id, gen_task)
finally:
session.close()
# 标记任务为 running
_update_task_status(task_id, "mark_processing")
try:
editing_mode = EditingMode(mode) if (mode := task_info["mode"]) else EditingMode.ONE_TAKE
editing_mode = EditingMode(mode)
except ValueError:
editing_mode = EditingMode.ONE_TAKE
output_name = f"generated-{task_id}.mp4"
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_name}"
try:
# P1: template_id 存在性校验
if template_id:
_validate_template_exists(template_id)
# P1: asset_ids 归属校验 — 已合并到 _download_library_assets 同一 sessionP3-2
with tempfile.TemporaryDirectory(prefix="xiaoxia-generation-") as temp_dir:
temp_path = Path(temp_dir)
output_path = temp_path / output_name
# ── 2. 下载素材 ──────────────────────────────────────────────────
downloaded_videos, audio_path = _download_all_assets(
# 1. 从素材库/项目下载视频素材
logger.info("[task_id=%s] [下载素材] 开始下载视频素材", task_id)
download_start = time.monotonic()
downloaded_videos = _download_library_assets(
temp_path,
asset_library_id=asset_library_id,
project_id=project_id,
task_asset_ids=task_asset_ids,
voice_library_id=voice_library_id,
asset_ids=task_asset_ids or None,
task_id=task_id,
gen_task=gen_task,
)
download_elapsed = time.monotonic() - download_start
logger.info(
"[task_id=%s] [下载素材] 完成: 成功=%d个, 耗时=%.1fs",
task_id,
len(downloaded_videos),
download_elapsed,
)
# 重新加载 gen_task 以追加日志(session 已关闭)
_session = SessionLocal()
try:
_repo = SQLAlchemyGenerationTaskRepository(_session)
gen_task = _repo.get(task_id)
finally:
_session.close()
if gen_task:
gen_task.append_log(
"下载素材",
f"成功下载 {len(downloaded_videos)} 个视频素材",
count=len(downloaded_videos),
duration=round(download_elapsed, 2),
)
_flush_logs(task_id, gen_task)
# ── 3. 渲染 + 混音 ───────────────────────────────────────────────
output_path, render_duration = _render_video(
# 2. 下载配音(如有)
audio_path: str | None = None
if voice_library_id:
local_audio = temp_path / "voice.mp3"
if _download_voice_asset(voice_library_id, local_audio):
audio_path = str(local_audio)
logger.info("[task_id=%s] [下载配音] 配音下载成功", task_id)
# 3. 渲染
if not downloaded_videos:
# 素材下载为空(不应到达此处,_download_library_assets 已做校验)
raise RuntimeError(
f"素材下载结果为空: task_id={task_id}, "
f"asset_library_id={asset_library_id}, project_id={project_id}, "
f"asset_ids={task_asset_ids}"
)
# 构建虚拟 plan + clips + asset_path_map
virtual_plan, virtual_clips, asset_path_map = _build_plan_and_clips_from_task(
task_id=task_id,
downloaded_videos=downloaded_videos,
voice_path=audio_path,
editing_mode=editing_mode,
project_id=project_id,
template_id=template_id,
user_id=user_id,
temp_path=temp_path,
output_name=output_name,
downloaded_paths=downloaded_videos,
mode=editing_mode.value,
)
total_duration = sum(c.duration for c in virtual_clips)
logger.info(
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
task_id,
len(virtual_clips),
total_duration,
)
if gen_task:
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
gen_task.append_log(
"剪辑计划",
f"片段数={len(virtual_clips)}, 总时长={total_duration:.1f}s",
segment_count=len(virtual_clips),
total_duration=round(total_duration, 2),
)
_flush_logs(task_id, gen_task)
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────────────
file_url, duration, file_size, video_count = _upload_and_record(
task_id=task_id,
output_path=output_path,
project_id=project_id,
batch_id=batch_id,
editing_mode=editing_mode,
# 3. 根据 Feature Flag 选择渲染引擎
user_id = getattr(gen_task, "created_by_user_id", "") if gen_task else ""
engine = _resolve_render_engine(user_id) if user_id else ENGINE_UNIFIED
logger.info("[task_id=%s] [渲染] 引擎选择: %s (user_id=%s)", task_id, engine, user_id)
render_start = time.monotonic()
render_output_path = temp_path / f"rendered-{task_id}.mp4"
if engine == ENGINE_LEGACY:
# 旧引擎:filter_complex + concat(保持原帧率,无 fps 归一化)
render_duration, render_file_size = _render_with_legacy_engine(
task_id=task_id,
virtual_clips=virtual_clips,
asset_path_map=asset_path_map,
work_dir=temp_path,
output_path=render_output_path,
)
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] legacy 引擎完成: 耗时=%.1fs, 时长=%.2fs",
task_id,
render_elapsed,
render_duration,
)
else:
# 新引擎:UnifiedRenderService 图层架构
logger.info("[task_id=%s] [渲染] unified 引擎 FFmpeg 渲染开始", task_id)
render_service = UnifiedRenderService(
plan=virtual_plan,
clips=virtual_clips,
asset_path_map=asset_path_map,
work_dir=temp_path,
output_width=OUTPUT_WIDTH,
output_height=OUTPUT_HEIGHT,
output_fps=int(OUTPUT_FPS),
)
render_result = render_service.render()
render_output_path = render_result.output_path
render_duration = render_result.duration
render_file_size = render_result.file_size
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] unified 引擎完成: 耗时=%.1fs",
task_id,
render_elapsed,
)
if gen_task:
gen_task.append_log(
"渲染",
f"引擎={engine}, 耗时={render_elapsed:.1f}s",
duration=round(render_elapsed, 2),
engine=engine,
)
_flush_logs(task_id, gen_task)
# 4. 如有配音,后处理混音
if audio_path:
final_path = temp_path / f"final-{task_id}.mp4"
try:
_mux_audio_track(render_output_path, audio_path, final_path)
# 混音成功,使用混音后的文件
output_path = final_path
except Exception as mux_err:
logger.warning("[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err)
output_path = render_output_path
else:
output_path = render_output_path
file_size = output_path.stat().st_size
duration = probe_duration(output_path)
# 5. 上传到 OSS — 失败必须抛异常,不能静默忽略
logger.info("[task_id=%s] [OSS上传] 开始上传: size=%d", task_id, file_size)
upload_start = time.monotonic()
file_url = upload_to_oss(output_path, storage_key)
upload_elapsed = time.monotonic() - upload_start
if not file_url:
# OSS 未配置或上传失败
if gen_task:
gen_task.append_log("OSS上传", "上传失败", level="ERROR")
_flush_logs(task_id, gen_task)
raise RuntimeError(
f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}, " f"output_path={output_path}"
)
# P0-2 修复:私有 bucket 下裸 URL 永远 403,改用预签名 URL 校验
# 先用预签名 URL 校验,失败则降级为检查文件是否存在(object_exists
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
if not _verify_url_accessible(verify_url):
# 预签名 URL 也访问失败时,退一步用 object_exists 确认上传成功
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
bucket = oss_bucket()
key = normalize_storage_key(file_url)
if bucket and bucket.object_exists(key):
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
if gen_task:
gen_task.append_log("OSS上传", "URL校验降级: object_exists确认存在", level="WARN")
else:
if gen_task:
gen_task.append_log("OSS上传", "上传后URL不可访问", level="ERROR", file_url=file_url)
_flush_logs(task_id, gen_task)
raise RuntimeError(
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, "
f"storage_key={storage_key}"
)
logger.info(
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
task_id,
upload_elapsed,
file_url,
)
if gen_task:
gen_task.append_log(
"OSS上传",
f"上传成功, 大小={file_size}",
f"上传成功, 大小={file_size}, 耗时={upload_elapsed:.1f}s",
file_size=file_size,
duration=round(upload_elapsed, 2),
file_url=file_url,
)
_flush_logs(task_id, gen_task)
# ── 5. 标记完成 ──────────────────────────────────────────────────
_update_task_status(task_id, "mark_completed", result_count=video_count)
# 5.1 更新标题使用次数
# 6. 创建 GeneratedVideo 记录 + 查重
dedup_session = SessionLocal()
try:
_title_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.title_library_repository import (
SQLAlchemyTitleLibraryRepository,
)
video_count = create_video_record_and_dedup(
generation_task_id=task_id,
project_id=project_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=editing_mode.value,
session=dedup_session,
)
finally:
dedup_session.close()
_task_repo = SQLAlchemyGenerationTaskRepository(_title_session)
_gen_task = _task_repo.get(task_id)
if _gen_task and _gen_task.title_ids and _gen_task.created_by_user_id:
_title_repo = SQLAlchemyTitleLibraryRepository(_title_session)
for _tid in _gen_task.title_ids:
try:
_title_repo.increment_usage_count(_tid, _gen_task.created_by_user_id)
except Exception:
logger.warning(
"[task_id=%s] 更新标题使用次数失败: title_id=%s",
task_id,
_tid,
exc_info=True,
)
finally:
_title_session.close()
except Exception:
logger.warning("[task_id=%s] 更新标题使用次数异常(不影响主流程)", task_id, exc_info=True)
# 7. 标记任务为 completed
_update_task_status(task_id, "mark_completed", result_count=video_count or 1)
# 5.2 更新素材使用次数 + 最近使用时间
try:
from worker_app.core.asset_usage import mark_asset_used_for_generation
_asset_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
_gen_task = _task_repo.get(task_id)
if _gen_task and _gen_task.asset_ids:
for _aid in _gen_task.asset_ids:
try:
_asset = _asset_repo.get(_aid)
if _asset:
mark_asset_used_for_generation(_asset)
_asset_repo.update(_asset)
except Exception:
logger.warning(
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
task_id,
_aid,
exc_info=True,
)
finally:
_asset_session.close()
except Exception:
logger.warning("[task_id=%s] 更新素材使用次数异常(不影响主流程)", task_id, exc_info=True)
# 记录完成日志
if gen_task:
gen_task.append_log(
"任务完成",
f"视频生成完成: 时长={duration:.2f}s, 大小={file_size}",
duration=round(duration, 2),
file_size=file_size,
video_count=video_count,
video_count=video_count or 1,
)
_flush_logs(task_id, gen_task)
@@ -1315,9 +1049,6 @@ def generate_video(self, task_id: str) -> dict:
except Exception as error:
logger.error("[task_id=%s] [任务失败] %s", task_id, error, exc_info=True)
# 构建结构化错误信息
error_info = _build_error_info(error, stage="render")
# 记录失败日志
try:
_session = SessionLocal()
@@ -1330,7 +1061,6 @@ def generate_video(self, task_id: str) -> dict:
str(error),
level="ERROR",
error_type=type(error).__name__,
stage="render",
)
_flush_logs(task_id, gen_task)
finally:
@@ -1338,59 +1068,7 @@ def generate_video(self, task_id: str) -> dict:
except Exception:
logger.warning("[task_id=%s] 记录失败日志异常", task_id, exc_info=True)
_update_task_status(
task_id,
"mark_failed",
error_message=str(error),
error_info=error_info,
)
# ── 自动重试逻辑 ──────────────────────────────────────────────────
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_s = SessionLocal()
try:
_r = SQLAlchemyGenerationTaskRepository(_s)
_task = _r.get(task_id)
if _task and _task.auto_retry_enabled and _task.auto_retry_max > 0:
current_retry = _task.retry_count or 0
if current_retry < _task.auto_retry_max:
logger.info(
"[task_id=%s] 触发自动重试: 当前重试次数=%d, 最大重试次数=%d",
task_id,
current_retry,
_task.auto_retry_max,
)
# 计算退避延迟(指数退避,基础5s,最大60s)
backoff_seconds = min(5 * (2**current_retry), 60)
# 原地重试
_task.mark_pending_from_failed()
_r.update(_task)
# 延迟重新入队
celery_app.send_task(
"worker.generate_video",
args=[task_id],
countdown=backoff_seconds,
)
logger.info(
"[task_id=%s] 自动重试已入队: 延迟=%ds, 第%d次重试",
task_id,
backoff_seconds,
current_retry + 1,
)
finally:
_s.close()
except Exception as retry_err:
logger.warning(
"[task_id=%s] 自动重试逻辑执行失败: %s",
task_id,
retry_err,
exc_info=True,
)
_update_task_status(task_id, "mark_failed", error_message=str(error))
return {
"status": "failed",
"task_id": task_id,
+3
View File
@@ -1,6 +1,9 @@
import subprocess
from datetime import datetime, timezone
from typing import Optional
from celery import Celery
from celery.app.task import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.core.asset_types import infer_mime_type_from_storage_key
@@ -1,11 +1,14 @@
"""Voice extraction tasks - extract voice tracks and background music from videos."""
import json
import logging
import os
import subprocess
import tempfile
from typing import Optional
from celery import Task
from sqlalchemy.orm import Session
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
+1 -1
View File
@@ -112,7 +112,7 @@
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliyuncs.com` |
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliiyuncs.com` |
| `OSS_ACCESS_KEY_ID` | OSS Access Key ID | `""`(空) |
| `OSS_ACCESS_KEY_SECRET` | OSS Access Key Secret | `""`(空) |
| `OSS_BUCKET_NAME` | OSS Bucket 名称 | `xiaoxia-autocut` |

Some files were not shown because too many files have changed in this diff Show More